mirror of
https://github.com/PhilKes/NotallyX.git
synced 2025-06-29 12:49:54 +00:00
Compare commits
319 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 |
255 changed files with 307231 additions and 3515 deletions
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
|
||||
|
|
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
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -12,4 +12,5 @@ fastlane/*
|
|||
!fastlane/join-testers.png
|
||||
!fastlane/metadata
|
||||
Gemfile*
|
||||
*.sh
|
||||
*.sh
|
||||
!generate-changelogs.sh
|
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)*
|
|
@ -4,9 +4,9 @@
|
|||
<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>
|
||||
<a href="https://github.com/PhilKes/NotallyX/issues/120"><img alt="JoinTesters" height="80" src="fastlane/join-testers.png" /></a>
|
||||
</center>
|
||||
</p>
|
||||
</h2>
|
||||
|
@ -27,7 +27,7 @@
|
|||
[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
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
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")
|
||||
|
@ -8,25 +10,27 @@ plugins {
|
|||
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.4"
|
||||
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 = 648
|
||||
versionName = "7.0.0"
|
||||
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"
|
||||
"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")
|
||||
|
@ -130,12 +134,46 @@ tasks.register<Copy>("installLocalGitHooks") {
|
|||
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -152,11 +190,12 @@ dependencies {
|
|||
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")
|
||||
|
|
271494
app/obfuscation/mapping.txt
Normal file
271494
app/obfuscation/mapping.txt
Normal file
File diff suppressed because it is too large
Load diff
12
app/proguard-rules.pro
vendored
12
app/proguard-rules.pro
vendored
|
@ -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
|
||||
|
||||
|
|
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,16 +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.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
|
||||
|
||||
<application
|
||||
|
@ -69,9 +68,30 @@
|
|||
<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" />
|
||||
|
|
|
@ -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,14 +1,18 @@
|
|||
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 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
|
||||
|
@ -32,7 +36,7 @@ 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<BiometricLock>
|
||||
private lateinit var preferences: NotallyXPreferences
|
||||
|
@ -42,11 +46,17 @@ class NotallyXApplication : Application() {
|
|||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
registerActivityLifecycleCallbacks(this)
|
||||
if (isTestRunner()) return
|
||||
|
||||
preferences = NotallyXPreferences.getInstance(this)
|
||||
preferences.theme.observeForever { theme ->
|
||||
if (preferences.useDynamicColors.value) {
|
||||
if (DynamicColors.isDynamicColorAvailable()) {
|
||||
DynamicColors.applyToActivitiesIfAvailable(this)
|
||||
}
|
||||
} else {
|
||||
setTheme(R.style.AppTheme)
|
||||
}
|
||||
preferences.theme.observeForeverWithPrevious { (oldTheme, theme) ->
|
||||
when (theme) {
|
||||
Theme.DARK ->
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
||||
|
@ -59,6 +69,9 @@ class NotallyXApplication : Application() {
|
|||
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
)
|
||||
}
|
||||
if (oldTheme != null) {
|
||||
WidgetProvider.updateWidgets(this, locked = locked.value)
|
||||
}
|
||||
}
|
||||
|
||||
preferences.backupsFolder.observeForeverWithPrevious { (backupFolderBefore, backupFolder) ->
|
||||
|
@ -159,4 +172,20 @@ class NotallyXApplication : Application() {
|
|||
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) {}
|
||||
}
|
||||
|
|
|
@ -16,8 +16,11 @@ 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.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
|
||||
|
@ -26,10 +29,11 @@ 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 = 7)
|
||||
@Database(entities = [BaseNote::class, Label::class], version = 9)
|
||||
abstract class NotallyDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun getLabelDao(): LabelDao
|
||||
|
@ -43,7 +47,7 @@ abstract class NotallyDatabase : RoomDatabase() {
|
|||
}
|
||||
|
||||
private var biometricLockObserver: Observer<BiometricLock>? = null
|
||||
private var externalDataFolderObserver: Observer<Boolean>? = null
|
||||
private var dataInPublicFolderObserver: Observer<Boolean>? = null
|
||||
|
||||
companion object {
|
||||
|
||||
|
@ -75,7 +79,16 @@ abstract class NotallyDatabase : RoomDatabase() {
|
|||
return context.getDatabasePath(DATABASE_NAME)
|
||||
}
|
||||
|
||||
fun getCurrentDatabaseName(context: ContextWrapper): String {
|
||||
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 {
|
||||
|
@ -96,6 +109,10 @@ abstract class NotallyDatabase : RoomDatabase() {
|
|||
}
|
||||
}
|
||||
|
||||
fun getFreshDatabase(context: ContextWrapper): NotallyDatabase {
|
||||
return createInstance(context, NotallyXPreferences.getInstance(context), false)
|
||||
}
|
||||
|
||||
private fun createInstance(
|
||||
context: ContextWrapper,
|
||||
preferences: NotallyXPreferences,
|
||||
|
@ -114,11 +131,14 @@ abstract class NotallyDatabase : RoomDatabase() {
|
|||
Migration5,
|
||||
Migration6,
|
||||
Migration7,
|
||||
Migration8,
|
||||
Migration9,
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
SQLiteDatabase.loadLibs(context)
|
||||
if (preferences.biometricLock.value == BiometricLock.ENABLED) {
|
||||
if (
|
||||
SQLCipherUtils.getDatabaseState(context, getCurrentDatabaseName(context)) ==
|
||||
SQLCipherUtils.getDatabaseState(getCurrentDatabaseFile(context)) ==
|
||||
SQLCipherUtils.State.ENCRYPTED
|
||||
) {
|
||||
initializeDecryption(preferences, instanceBuilder)
|
||||
|
@ -127,7 +147,7 @@ abstract class NotallyDatabase : RoomDatabase() {
|
|||
}
|
||||
} else {
|
||||
if (
|
||||
SQLCipherUtils.getDatabaseState(context, getCurrentDatabaseName(context)) ==
|
||||
SQLCipherUtils.getDatabaseState(getCurrentDatabaseFile(context)) ==
|
||||
SQLCipherUtils.State.ENCRYPTED
|
||||
) {
|
||||
preferences.biometricLock.save(BiometricLock.ENABLED)
|
||||
|
@ -150,18 +170,18 @@ abstract class NotallyDatabase : RoomDatabase() {
|
|||
instance.biometricLockObserver!!
|
||||
)
|
||||
|
||||
instance.externalDataFolderObserver = Observer {
|
||||
NotallyDatabase.instance?.value?.externalDataFolderObserver?.let {
|
||||
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.externalDataFolderObserver!!
|
||||
newInstance.dataInPublicFolderObserver!!
|
||||
)
|
||||
}
|
||||
preferences.dataInPublicFolder.observeForeverSkipFirst(
|
||||
instance.externalDataFolderObserver!!
|
||||
instance.dataInPublicFolderObserver!!
|
||||
)
|
||||
}
|
||||
return instance
|
||||
|
@ -229,5 +249,28 @@ abstract class NotallyDatabase : RoomDatabase() {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,7 +11,6 @@ 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
|
||||
|
@ -74,6 +73,8 @@ interface BaseNoteDao {
|
|||
@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 != '[]'"
|
||||
)
|
||||
|
@ -94,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)
|
||||
|
@ -155,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)
|
||||
|
@ -172,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
|
||||
|
|
|
@ -24,4 +24,7 @@ interface LabelDao {
|
|||
@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
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import com.philkes.notallyx.R
|
|||
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
|
||||
|
@ -39,6 +40,7 @@ class NotesImporter(private val app: Application, private val database: NotallyD
|
|||
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)
|
||||
|
@ -153,6 +155,13 @@ enum class ImportSource(
|
|||
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,10 +14,10 @@ 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.log
|
||||
import com.philkes.notallyx.utils.startsWithAnyOf
|
||||
|
@ -143,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),
|
||||
|
@ -156,6 +156,7 @@ fun EvernoteNote.mapToBaseNote(): BaseNote {
|
|||
files = files,
|
||||
audios = audios,
|
||||
reminders = mutableListOf(),
|
||||
NoteViewMode.EDIT,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -11,10 +11,10 @@ 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
|
||||
|
@ -150,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,
|
||||
|
@ -163,6 +163,7 @@ class GoogleKeepImporter : ExternalImporter {
|
|||
files = files,
|
||||
audios = audios,
|
||||
reminders = mutableListOf(),
|
||||
NoteViewMode.EDIT,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -7,14 +7,13 @@ 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.Color
|
||||
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 java.io.BufferedReader
|
||||
import com.philkes.notallyx.utils.readFileContents
|
||||
import java.io.File
|
||||
import java.io.InputStreamReader
|
||||
|
||||
class PlainTextImporter : ExternalImporter {
|
||||
|
||||
|
@ -36,12 +35,7 @@ class PlainTextImporter : ExternalImporter {
|
|||
return
|
||||
}
|
||||
val fileNameWithoutExtension = file.name?.substringBeforeLast(".") ?: ""
|
||||
var content =
|
||||
app.contentResolver.openInputStream(file.uri)?.use { inputStream ->
|
||||
BufferedReader(InputStreamReader(inputStream)).use { reader ->
|
||||
reader.readText()
|
||||
}
|
||||
} ?: ""
|
||||
var content = app.contentResolver.readFileContents(file.uri)
|
||||
val listItems = mutableListOf<ListItem>()
|
||||
content.findListSyntaxRegex()?.let { listSyntaxRegex ->
|
||||
listItems.addAll(content.extractListItems(listSyntaxRegex))
|
||||
|
@ -53,7 +47,7 @@ class PlainTextImporter : ExternalImporter {
|
|||
id = 0L, // Auto-generated
|
||||
type = if (listItems.isEmpty()) Type.NOTE else Type.LIST,
|
||||
folder = Folder.NOTES,
|
||||
color = Color.DEFAULT,
|
||||
color = BaseNote.COLOR_DEFAULT,
|
||||
title = fileNameWithoutExtension,
|
||||
pinned = false,
|
||||
timestamp = timestamp,
|
||||
|
@ -66,6 +60,7 @@ class PlainTextImporter : ExternalImporter {
|
|||
files = listOf(),
|
||||
audios = listOf(),
|
||||
reminders = listOf(),
|
||||
NoteViewMode.EDIT,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
@ -22,7 +25,60 @@ data class BaseNote(
|
|||
val files: List<FileAttachment>,
|
||||
val audios: List<Audio>,
|
||||
val reminders: List<Reminder>,
|
||||
) : Item
|
||||
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(
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,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 {
|
||||
|
@ -24,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")
|
||||
|
@ -47,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")
|
||||
|
@ -58,31 +60,63 @@ object Converters {
|
|||
}
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun jsonToSpans(json: String): List<SpanRepresentation> {
|
||||
val iterable = JSONArray(json).iterable<JSONObject>()
|
||||
return iterable.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")
|
||||
val start = jsonObject.getInt("start")
|
||||
val end = jsonObject.getInt("end")
|
||||
SpanRepresentation(start, end, bold, link, linkData, italic, monospace, strikethrough)
|
||||
}
|
||||
@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,
|
||||
)
|
||||
} 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())
|
||||
|
@ -103,39 +137,25 @@ object Converters {
|
|||
return JSONArray(objects)
|
||||
}
|
||||
|
||||
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 remindersToJson(reminders: List<Reminder>): String {
|
||||
fun remindersToJson(reminders: List<Reminder>) = remindersToJSONArray(reminders).toString()
|
||||
|
||||
fun remindersToJSONArray(reminders: List<Reminder>): JSONArray {
|
||||
val objects =
|
||||
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 { repetitionToJson(it) })
|
||||
put("repetition", reminder.repetition?.let { repetitionToJsonObject(it) })
|
||||
}
|
||||
}
|
||||
return JSONArray(objects).toString()
|
||||
return JSONArray(objects)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun jsonToReminders(json: String): List<Reminder> {
|
||||
val iterable = JSONArray(json).iterable<JSONObject>()
|
||||
return iterable.map { jsonObject ->
|
||||
@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) }
|
||||
|
@ -145,10 +165,14 @@ object Converters {
|
|||
|
||||
@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.toString()
|
||||
return jsonObject
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
|
|
|
@ -5,5 +5,14 @@ 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 MutableList<ListItem>.containsId(id: Int): Boolean {
|
||||
return this.any { it.id == id }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
fun ListItem.check(checked: Boolean, checkChildren: Boolean = true) {
|
||||
this.checked = checked
|
||||
if (checkChildren) {
|
||||
this.children.forEach { child -> child.checked = checked }
|
||||
}
|
||||
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.shouldParentBeUnchecked(): Boolean {
|
||||
return children.isNotEmpty() && !children.areAllChecked() && checked
|
||||
}
|
||||
|
||||
fun ListItem.shouldParentBeChecked(): Boolean {
|
||||
return children.isNotEmpty() && children.areAllChecked() && !checked
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ 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
|
||||
|
@ -12,6 +13,7 @@ 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://"
|
||||
|
@ -41,7 +43,15 @@ fun String.getNoteTypeFromUrl(): Type {
|
|||
|
||||
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/")
|
||||
}
|
||||
|
||||
fun BaseNote.toTxt(includeTitle: Boolean = true, includeCreationDate: Boolean = true) =
|
||||
|
@ -67,10 +77,11 @@ fun BaseNote.toJson(): String {
|
|||
val jsonObject =
|
||||
JSONObject()
|
||||
.put("type", type.name)
|
||||
.put("color", color.name)
|
||||
.put("color", color)
|
||||
.put("title", title)
|
||||
.put("pinned", pinned)
|
||||
.put("date-created", timestamp)
|
||||
.put("timestamp", timestamp)
|
||||
.put("modifiedTimestamp", modifiedTimestamp)
|
||||
.put("labels", JSONArray(labels))
|
||||
|
||||
when (type) {
|
||||
|
@ -83,10 +94,85 @@ fun BaseNote.toJson(): String {
|
|||
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)
|
||||
|
@ -222,3 +308,15 @@ fun List<ListItem>.toText() = buildString {
|
|||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -21,14 +21,15 @@ 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 {
|
||||
liveData =
|
||||
if (keyword.isNotEmpty()) baseNoteDao.getBaseNotesByKeyword(keyword, folder)
|
||||
else baseNoteDao.getFrom(folder)
|
||||
liveData = baseNoteDao.getBaseNotesByKeyword(keyword, folder, label)
|
||||
// if (keyword.isNotEmpty())
|
||||
// baseNoteDao.getBaseNotesByKeyword(keyword, folder, label)
|
||||
// else baseNoteDao.getFrom(folder)
|
||||
liveData?.observeForever(observer)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,8 @@ 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.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
|
@ -21,6 +23,7 @@ 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
|
||||
|
@ -36,6 +39,7 @@ 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
|
||||
|
@ -49,22 +53,29 @@ import androidx.activity.result.ActivityResultLauncher
|
|||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
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.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
|
||||
|
@ -73,21 +84,23 @@ 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.Color
|
||||
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.databinding.DialogColorBinding
|
||||
import com.philkes.notallyx.databinding.DialogInputBinding
|
||||
import com.philkes.notallyx.databinding.DialogProgressBinding
|
||||
import com.philkes.notallyx.databinding.LabelBinding
|
||||
import com.philkes.notallyx.presentation.view.main.ColorAdapter
|
||||
import com.philkes.notallyx.presentation.view.misc.ItemListener
|
||||
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
|
||||
|
@ -109,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)
|
||||
|
@ -131,6 +144,13 @@ 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.
|
||||
*
|
||||
|
@ -221,14 +241,23 @@ fun ViewGroup.addIconButton(
|
|||
title: Int,
|
||||
drawable: Int,
|
||||
marginStart: Int = 10,
|
||||
onClick: ((item: View) -> Unit)? = null,
|
||||
): View {
|
||||
onLongClick: View.OnLongClickListener? = null,
|
||||
onClick: View.OnClickListener? = null,
|
||||
): ImageButton {
|
||||
val view =
|
||||
ImageButton(ContextThemeWrapper(context, R.style.AppTheme)).apply {
|
||||
setImageResource(drawable)
|
||||
contentDescription = context.getString(title)
|
||||
setBackgroundResource(R.color.Transparent)
|
||||
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 =
|
||||
|
@ -236,8 +265,8 @@ fun ViewGroup.addIconButton(
|
|||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
.apply { setMargins(marginStart.dp(context), marginTop, 0, marginBottom) }
|
||||
setPadding(8.dp(context))
|
||||
.apply { setMargins(marginStart.dp, marginTop, 0, marginBottom) }
|
||||
setPadding(8.dp)
|
||||
}
|
||||
addView(view)
|
||||
return view
|
||||
|
@ -255,13 +284,11 @@ fun TextView.displayFormattedTimestamp(
|
|||
} 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
|
||||
|
@ -278,11 +305,11 @@ fun EditText.createListTextWatcherWithHistory(
|
|||
onTextChanged: ((text: CharSequence, start: Int, count: Int) -> Boolean)? = null,
|
||||
) =
|
||||
object : TextWatcher {
|
||||
private lateinit var stateBefore: EditTextState
|
||||
private var ignoreOriginalChange: Boolean = false
|
||||
private lateinit var textBefore: Editable
|
||||
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
|
||||
stateBefore = EditTextState(getText()!!.clone(), selectionStart)
|
||||
textBefore = text.clone()
|
||||
}
|
||||
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||
|
@ -290,13 +317,14 @@ fun EditText.createListTextWatcherWithHistory(
|
|||
}
|
||||
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
val textAfter = s!!.clone()
|
||||
if (textAfter.hasNotChanged(textBefore)) {
|
||||
return
|
||||
}
|
||||
if (!ignoreOriginalChange) {
|
||||
listManager.changeText(
|
||||
this@createListTextWatcherWithHistory,
|
||||
this,
|
||||
positionGetter.invoke(),
|
||||
EditTextState(getText()!!.clone(), selectionStart),
|
||||
before = stateBefore,
|
||||
EditTextState(textAfter, selectionStart),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -320,6 +348,9 @@ fun StylableEditTextWithHistory.createTextWatcherWithHistory(
|
|||
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
val textAfter = requireNotNull(s).clone()
|
||||
if (textAfter.hasNotChanged(stateBefore.text)) {
|
||||
return
|
||||
}
|
||||
updateModel.invoke(textAfter)
|
||||
changeHistory.push(
|
||||
EditTextWithHistoryChange(
|
||||
|
@ -332,6 +363,10 @@ fun StylableEditTextWithHistory.createTextWatcherWithHistory(
|
|||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
@ -354,11 +389,16 @@ fun RadioGroup.checkedTag(): Any {
|
|||
return this.findViewById<RadioButton?>(this.checkedRadioButtonId).tag
|
||||
}
|
||||
|
||||
fun Activity.showKeyboard(view: View) {
|
||||
fun Context.showKeyboard(view: View) {
|
||||
ContextCompat.getSystemService(this, InputMethodManager::class.java)
|
||||
?.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) {
|
||||
setupProgressDialog(activity, activity.layoutInflater, activity as LifecycleOwner, titleId)
|
||||
}
|
||||
|
@ -399,6 +439,13 @@ fun <T, C> NotNullLiveData<T>.merge(liveData: NotNullLiveData<C>): MediatorLiveD
|
|||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
@ -491,6 +538,64 @@ fun Activity.checkAlarmPermission(
|
|||
} else onSuccess()
|
||||
}
|
||||
|
||||
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) {
|
||||
|
@ -499,36 +604,12 @@ private fun formatTimestamp(timestamp: Long, dateFormat: DateFormat): String {
|
|||
}
|
||||
}
|
||||
|
||||
fun Activity.showColorSelectDialog(
|
||||
setNavigationbarLight: Boolean?,
|
||||
callback: (selectedColor: Color) -> Unit,
|
||||
) {
|
||||
val dialog = MaterialAlertDialogBuilder(this).setTitle(R.string.change_color).create()
|
||||
val colorAdapter =
|
||||
ColorAdapter(
|
||||
object : ItemListener {
|
||||
override fun onClick(position: Int) {
|
||||
dialog.dismiss()
|
||||
callback(Color.entries[position])
|
||||
}
|
||||
|
||||
override fun onLongClick(position: Int) {}
|
||||
}
|
||||
)
|
||||
DialogColorBinding.inflate(layoutInflater).apply {
|
||||
RecyclerView.adapter = colorAdapter
|
||||
dialog.setView(root)
|
||||
dialog.setOnShowListener {
|
||||
setNavigationbarLight?.let { window?.apply { setLightStatusAndNavBar(it, root) } }
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
@ -550,7 +631,11 @@ fun MaterialAlertDialogBuilder.showAndFocus(
|
|||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
}
|
||||
onShowListener?.let { setOnShowListener(it) }
|
||||
show()
|
||||
applyToPositiveButton?.let {
|
||||
getButton(AlertDialog.BUTTON_POSITIVE)?.let { positiveButton -> it(positiveButton) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -571,11 +656,16 @@ fun @receiver:ColorInt Int.withAlpha(alpha: Float): Int {
|
|||
fun Context.getColorFromAttr(@AttrRes attr: Int): Int {
|
||||
val typedValue = TypedValue()
|
||||
val resolved = theme.resolveAttribute(attr, typedValue, true)
|
||||
if (resolved) {
|
||||
return typedValue.data // Returns the color as an Int
|
||||
} else {
|
||||
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(
|
||||
|
@ -602,6 +692,15 @@ fun View.setControlsColorForAllViews(
|
|||
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(
|
||||
|
@ -627,15 +726,7 @@ fun View.setControlsColorForAllViews(
|
|||
val highlight = controlsColor.withAlpha(0.4f)
|
||||
setHintTextColor(highlight)
|
||||
highlightColor = highlight
|
||||
val selectHandleColor = controlsColor.withAlpha(0.8f)
|
||||
textSelectHandleLeft?.withTint(selectHandleColor)?.let {
|
||||
setTextSelectHandleLeft(it)
|
||||
}
|
||||
textSelectHandleRight?.withTint(selectHandleColor)?.let {
|
||||
setTextSelectHandleRight(it)
|
||||
}
|
||||
textSelectHandle?.withTint(selectHandleColor)?.let { setTextSelectHandle(it) }
|
||||
textCursorDrawable?.let { DrawableCompat.setTint(it, selectHandleColor) }
|
||||
setSelectionHandleColor(controlsColor.withAlpha(0.8f))
|
||||
}
|
||||
}
|
||||
if (this is CompoundButton) {
|
||||
|
@ -656,6 +747,70 @@ fun View.setControlsColorForAllViews(
|
|||
}
|
||||
}
|
||||
|
||||
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) }
|
||||
|
@ -675,21 +830,12 @@ fun Drawable.withTint(@ColorInt color: Int): Drawable {
|
|||
}
|
||||
|
||||
@ColorInt
|
||||
private fun Context.getContrastFontColor(@ColorInt backgroundColor: Int): Int {
|
||||
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(): Boolean {
|
||||
val red = android.graphics.Color.red(this) / 255.0
|
||||
val green = android.graphics.Color.green(this) / 255.0
|
||||
val blue = android.graphics.Color.blue(this) / 255.0
|
||||
|
||||
// Calculate relative luminance
|
||||
val luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
|
||||
|
||||
return luminance > 0.5
|
||||
}
|
||||
fun @receiver:ColorInt Int.isLightColor() = ColorUtils.calculateLuminance(this) > 0.5
|
||||
|
||||
fun MaterialAlertDialogBuilder.setCancelButton(listener: DialogInterface.OnClickListener? = null) =
|
||||
setNegativeButton(R.string.cancel, listener)
|
||||
|
@ -726,34 +872,38 @@ fun Context.showToast(message: CharSequence) =
|
|||
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(context), 0)
|
||||
.setPadding(0, 0, 2.dp, 0)
|
||||
.build()
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
fun Context.extractColor(color: Color): Int {
|
||||
val id =
|
||||
when (color) {
|
||||
Color.DEFAULT -> return getColorFromAttr(R.attr.colorSurface)
|
||||
Color.CORAL -> R.color.Coral
|
||||
Color.ORANGE -> R.color.Orange
|
||||
Color.SAND -> R.color.Sand
|
||||
Color.STORM -> R.color.Storm
|
||||
Color.FOG -> R.color.Fog
|
||||
Color.SAGE -> R.color.Sage
|
||||
Color.MINT -> R.color.Mint
|
||||
Color.DUSK -> R.color.Dusk
|
||||
Color.FLOWER -> R.color.Flower
|
||||
Color.BLOSSOM -> R.color.Blossom
|
||||
Color.CLAY -> R.color.Clay
|
||||
}
|
||||
return ContextCompat.getColor(this, id)
|
||||
}
|
||||
|
||||
fun Window.setLightStatusAndNavBar(value: Boolean, view: View = decorView) {
|
||||
val windowInsetsControllerCompat = WindowInsetsControllerCompat(this, view)
|
||||
windowInsetsControllerCompat.isAppearanceLightStatusBars = value
|
||||
|
@ -765,6 +915,8 @@ fun ChipGroup.bindLabels(
|
|||
textSize: TextSize,
|
||||
paddingTop: Boolean,
|
||||
color: Int? = null,
|
||||
onClick: ((label: String) -> Unit)? = null,
|
||||
onLongClick: ((label: String) -> Unit)? = null,
|
||||
) {
|
||||
if (labels.isEmpty()) {
|
||||
visibility = View.GONE
|
||||
|
@ -772,7 +924,7 @@ fun ChipGroup.bindLabels(
|
|||
apply {
|
||||
visibility = View.VISIBLE
|
||||
removeAllViews()
|
||||
updatePadding(top = if (paddingTop) 8.dp(context) else 0)
|
||||
updatePadding(top = if (paddingTop) 8.dp else 0)
|
||||
}
|
||||
val inflater = LayoutInflater.from(context)
|
||||
val labelSize = textSize.displayBodySize
|
||||
|
@ -782,6 +934,13 @@ fun ChipGroup.bindLabels(
|
|||
setTextSize(TypedValue.COMPLEX_UNIT_SP, labelSize)
|
||||
text = label
|
||||
color?.let { setControlsContrastColorForAllViews(it) }
|
||||
onClick?.let { setOnClickListener { it(label) } }
|
||||
onLongClick?.let {
|
||||
setOnLongClickListener {
|
||||
it(label)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -807,3 +966,43 @@ fun RecyclerView.initListView(context: 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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,7 +32,7 @@ class ConfigureWidgetActivity : PickNoteActivity() {
|
|||
preferences.updateWidget(id, baseNote.id, baseNote.type)
|
||||
|
||||
val manager = AppWidgetManager.getInstance(this)
|
||||
WidgetProvider.updateWidget(this, manager, id, baseNote.id, baseNote.type)
|
||||
WidgetProvider.updateWidget(application, manager, id, baseNote.id, baseNote.type)
|
||||
|
||||
val success = Intent()
|
||||
success.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id)
|
||||
|
|
|
@ -23,7 +23,6 @@ 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.disableBiometricLock
|
||||
import com.philkes.notallyx.utils.security.showBiometricOrPinPrompt
|
||||
|
||||
abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() {
|
||||
|
@ -34,7 +33,7 @@ abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() {
|
|||
|
||||
protected lateinit var binding: T
|
||||
protected lateinit var preferences: NotallyXPreferences
|
||||
protected val baseModel: BaseNoteModel by viewModels()
|
||||
val baseModel: BaseNoteModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -65,7 +64,10 @@ abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() {
|
|||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
if (preferences.biometricLock.value == BiometricLock.ENABLED) {
|
||||
if (
|
||||
preferences.biometricLock.value == BiometricLock.ENABLED &&
|
||||
notallyXApplication.locked.value
|
||||
) {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
@ -84,7 +86,7 @@ abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() {
|
|||
.setMessage(R.string.unlock_with_biometrics_not_setup)
|
||||
.setPositiveButton(R.string.disable) { _, _ ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
disableBiometricLock(baseModel)
|
||||
baseModel.disableBiometricLock()
|
||||
}
|
||||
show()
|
||||
}
|
||||
|
@ -104,7 +106,7 @@ abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() {
|
|||
|
||||
BIOMETRIC_ERROR_HW_NOT_PRESENT -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
disableBiometricLock(baseModel)
|
||||
baseModel.disableBiometricLock()
|
||||
showToast(R.string.biometrics_disable_success)
|
||||
}
|
||||
show()
|
||||
|
|
|
@ -1,33 +1,24 @@
|
|||
package com.philkes.notallyx.presentation.activity.main
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.print.PdfPrintListener
|
||||
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.View.GONE
|
||||
import android.view.View.VISIBLE
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.GravityCompat
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
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
|
||||
|
@ -40,36 +31,31 @@ import com.philkes.notallyx.R
|
|||
import com.philkes.notallyx.data.NotallyDatabase
|
||||
import com.philkes.notallyx.data.model.BaseNote
|
||||
import com.philkes.notallyx.data.model.Folder
|
||||
import com.philkes.notallyx.data.model.Type
|
||||
import com.philkes.notallyx.data.model.toText
|
||||
import com.philkes.notallyx.databinding.ActivityMainBinding
|
||||
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.main.fragment.SearchFragment.Companion.EXTRA_INITIAL_FOLDER
|
||||
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.setCancelButton
|
||||
import com.philkes.notallyx.presentation.showColorSelectDialog
|
||||
import com.philkes.notallyx.presentation.view.misc.MenuDialog
|
||||
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.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.utils.backup.exportPdfFile
|
||||
import com.philkes.notallyx.utils.backup.exportPlainTextFile
|
||||
import com.philkes.notallyx.utils.getExportedPath
|
||||
import com.philkes.notallyx.utils.getUriForFile
|
||||
import com.philkes.notallyx.utils.nameWithoutExtension
|
||||
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.wrapWithChooser
|
||||
import java.io.File
|
||||
import com.philkes.notallyx.utils.showColorSelectDialog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class MainActivity : LockedActivity<ActivityMainBinding>() {
|
||||
|
||||
|
@ -78,6 +64,7 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
|
|||
private lateinit var exportFileActivityResultLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var exportNotesActivityResultLauncher: ActivityResultLauncher<Intent>
|
||||
|
||||
private var isStartViewFragment = false
|
||||
private val actionModeCancelCallback =
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
|
@ -88,6 +75,7 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
|
|||
var getCurrentFragmentNotes: (() -> Collection<BaseNote>?)? = null
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
baseModel.keyword = ""
|
||||
return navController.navigateUp(configuration)
|
||||
}
|
||||
|
||||
|
@ -100,16 +88,51 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
|
|||
setupMenu()
|
||||
setupActionMode()
|
||||
setupNavigation()
|
||||
setupSearch()
|
||||
|
||||
setupActivityResultLaunchers()
|
||||
onBackPressedDispatcher.addCallback(this, actionModeCancelCallback)
|
||||
|
||||
val fragmentIdToLoad = intent.getIntExtra(EXTRA_FRAGMENT_TO_OPEN, -1)
|
||||
if (fragmentIdToLoad != -1) {
|
||||
val bundle = Bundle()
|
||||
navController.navigate(fragmentIdToLoad, bundle)
|
||||
navController.navigate(fragmentIdToLoad, intent.extras)
|
||||
} else if (savedInstanceState == null) {
|
||||
navigateToStartView()
|
||||
}
|
||||
|
||||
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() {
|
||||
|
@ -144,6 +167,8 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
|
|||
private fun setupMenu() {
|
||||
binding.NavigationView.menu.apply {
|
||||
add(0, R.id.Notes, 0, R.string.notes).setCheckable(true).setIcon(R.drawable.home)
|
||||
|
||||
addStaticLabelsMenuItems()
|
||||
NotallyDatabase.getDatabase(application).observe(this@MainActivity) { database ->
|
||||
labelsLiveData?.removeObservers(this@MainActivity)
|
||||
labelsLiveData =
|
||||
|
@ -168,7 +193,7 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
|
|||
.setCheckable(true)
|
||||
.setIcon(R.drawable.settings)
|
||||
}
|
||||
baseModel.preferences.labelsHiddenInNavigation.observe(this) { hiddenLabels ->
|
||||
baseModel.preferences.labelsHidden.observe(this) { hiddenLabels ->
|
||||
hideLabelsInNavigation(hiddenLabels, baseModel.preferences.maxLabels.value)
|
||||
}
|
||||
baseModel.preferences.maxLabels.observe(this) { maxLabels ->
|
||||
|
@ -176,21 +201,29 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun Menu.setupLabelsMenuItems(labels: List<String>, maxLabelsToDisplay: Int) {
|
||||
removeGroup(1)
|
||||
add(1, R.id.Labels, CATEGORY_CONTAINER + 1, R.string.labels)
|
||||
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 + 2, 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 {
|
||||
val bundle = Bundle().apply { putString(EXTRA_DISPLAYED_LABEL, label) }
|
||||
navController.navigate(R.id.DisplayLabel, bundle)
|
||||
navigateToLabel(label)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
@ -209,10 +242,12 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
|
|||
} else null
|
||||
configuration = AppBarConfiguration(binding.NavigationView.menu, binding.DrawerLayout)
|
||||
setupActionBarWithNavController(navController, configuration)
|
||||
hideLabelsInNavigation(
|
||||
baseModel.preferences.labelsHiddenInNavigation.value,
|
||||
maxLabelsToDisplay,
|
||||
)
|
||||
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) {
|
||||
|
@ -257,28 +292,34 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
|
|||
|
||||
val menu = binding.ActionMode.menu
|
||||
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 = 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) { baseModel.moveBaseNotes(ids, folderFrom) } }
|
||||
.show()
|
||||
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) { baseModel.moveBaseNotes(ids, folderFrom) } }
|
||||
.show()
|
||||
} finally {
|
||||
baseModel.actionMode.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun share() {
|
||||
val baseNote = baseModel.actionMode.getFirstNote()
|
||||
val body =
|
||||
when (baseNote.type) {
|
||||
Type.NOTE -> baseNote.body.applySpans(baseNote.spans)
|
||||
Type.LIST -> baseNote.items.toText()
|
||||
}
|
||||
this.shareNote(baseNote.title, body)
|
||||
this.shareNote(baseNote)
|
||||
}
|
||||
|
||||
private fun deleteForever() {
|
||||
|
@ -358,86 +399,12 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
|
|||
}
|
||||
|
||||
private fun exportSelectedNotes(mimeType: ExportMimeType) {
|
||||
if (baseModel.actionMode.count.value == 1) {
|
||||
val baseNote = baseModel.actionMode.getFirstNote()
|
||||
when (mimeType) {
|
||||
ExportMimeType.PDF -> {
|
||||
exportPdfFile(
|
||||
application,
|
||||
baseNote,
|
||||
DocumentFile.fromFile(application.getExportedPath()),
|
||||
pdfPrintListener =
|
||||
object : PdfPrintListener {
|
||||
|
||||
override fun onSuccess(file: DocumentFile) {
|
||||
showFileOptionsDialog(file, ExportMimeType.PDF.mimeType)
|
||||
}
|
||||
|
||||
override fun onFailure(message: CharSequence?) {
|
||||
Toast.makeText(
|
||||
this@MainActivity,
|
||||
R.string.something_went_wrong,
|
||||
Toast.LENGTH_SHORT,
|
||||
)
|
||||
.show()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
ExportMimeType.TXT,
|
||||
ExportMimeType.JSON,
|
||||
ExportMimeType.HTML ->
|
||||
lifecycleScope.launch {
|
||||
exportPlainTextFile(
|
||||
application,
|
||||
baseNote,
|
||||
mimeType,
|
||||
DocumentFile.fromFile(application.getExportedPath()),
|
||||
)
|
||||
?.let { showFileOptionsDialog(it, mimeType.mimeType) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lifecycleScope.launch {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
.apply { addCategory(Intent.CATEGORY_DEFAULT) }
|
||||
.wrapWithChooser(this@MainActivity)
|
||||
baseModel.selectedExportMimeType = mimeType
|
||||
exportNotesActivityResultLauncher.launch(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showFileOptionsDialog(file: DocumentFile, mimeType: String) {
|
||||
MenuDialog(this)
|
||||
.add(R.string.view_file) { viewFile(getUriForFile(File(file.uri.path!!)), 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
|
||||
}
|
||||
.wrapWithChooser(this@MainActivity)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun saveFileToDevice(file: DocumentFile, mimeType: String) {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT)
|
||||
.apply {
|
||||
type = mimeType
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
putExtra(Intent.EXTRA_TITLE, file.nameWithoutExtension!!)
|
||||
}
|
||||
.wrapWithChooser(this@MainActivity)
|
||||
baseModel.selectedExportFile = file
|
||||
exportFileActivityResultLauncher.launch(intent)
|
||||
exportNotes(
|
||||
mimeType,
|
||||
baseModel.actionMode.selectedNotes.values,
|
||||
exportFileActivityResultLauncher,
|
||||
exportNotesActivityResultLauncher,
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupNavigation() {
|
||||
|
@ -468,52 +435,47 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
|
|||
}
|
||||
)
|
||||
|
||||
navController.addOnDestinationChangedListener { _, destination, _ ->
|
||||
navController.addOnDestinationChangedListener { _, destination, bundle ->
|
||||
fragmentIdToLoad = destination.id
|
||||
binding.NavigationView.setCheckedItem(destination.id)
|
||||
if (destination.id != R.id.Search) {
|
||||
binding.EnterSearchKeyword.apply {
|
||||
setText("")
|
||||
clearFocus()
|
||||
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)
|
||||
}
|
||||
when (destination.id) {
|
||||
R.id.Notes,
|
||||
R.id.Deleted,
|
||||
R.id.Archived -> binding.EnterSearchKeywordLayout.visibility = VISIBLE
|
||||
else -> binding.EnterSearchKeywordLayout.visibility = GONE
|
||||
else -> {
|
||||
baseModel.currentLabel = CURRENT_LABEL_EMPTY
|
||||
binding.NavigationView.setCheckedItem(destination.id)
|
||||
}
|
||||
}
|
||||
handleDestinationChange(destination)
|
||||
when (destination.id) {
|
||||
R.id.Notes,
|
||||
R.id.DisplayLabel,
|
||||
R.id.Unlabeled -> {
|
||||
binding.TakeNote.show()
|
||||
binding.MakeList.show()
|
||||
}
|
||||
|
||||
else -> {
|
||||
binding.TakeNote.hide()
|
||||
binding.MakeList.hide()
|
||||
}
|
||||
}
|
||||
isStartViewFragment = isStartViewFragment(destination.id, bundle)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDestinationChange(destination: NavDestination) {
|
||||
when (destination.id) {
|
||||
R.id.Notes,
|
||||
R.id.DisplayLabel -> {
|
||||
binding.TakeNote.show()
|
||||
binding.MakeList.show()
|
||||
}
|
||||
else -> {
|
||||
binding.TakeNote.hide()
|
||||
binding.MakeList.hide()
|
||||
}
|
||||
}
|
||||
|
||||
val inputManager = ContextCompat.getSystemService(this, InputMethodManager::class.java)
|
||||
if (destination.id == R.id.Search) {
|
||||
binding.EnterSearchKeyword.apply {
|
||||
// setText("")
|
||||
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) {
|
||||
|
@ -530,34 +492,6 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
|
|||
navController.navigate(id, null, options)
|
||||
}
|
||||
|
||||
private fun setupSearch() {
|
||||
binding.EnterSearchKeyword.apply {
|
||||
setText(baseModel.keyword)
|
||||
doAfterTextChanged { text ->
|
||||
baseModel.keyword = requireNotNull(text).trim().toString()
|
||||
if (
|
||||
baseModel.keyword.isNotEmpty() &&
|
||||
navController.currentDestination?.id != R.id.Search
|
||||
) {
|
||||
val bundle =
|
||||
Bundle().apply {
|
||||
putSerializable(EXTRA_INITIAL_FOLDER, baseModel.folder.value)
|
||||
}
|
||||
navController.navigate(R.id.Search, bundle)
|
||||
}
|
||||
}
|
||||
setOnFocusChangeListener { v, hasFocus ->
|
||||
if (hasFocus && navController.currentDestination?.id != R.id.Search) {
|
||||
val bundle =
|
||||
Bundle().apply {
|
||||
putSerializable(EXTRA_INITIAL_FOLDER, baseModel.folder.value)
|
||||
}
|
||||
navController.navigate(R.id.Search, bundle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupActivityResultLaunchers() {
|
||||
exportFileActivityResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
|
@ -648,7 +582,38 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
|
|||
showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM
|
||||
): MenuItem {
|
||||
return add(R.string.change_color, R.drawable.change_color, showAsAction) {
|
||||
showColorSelectDialog(null) { selectedColor -> model.colorBaseNote(selectedColor) }
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -717,5 +682,6 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
|
|||
|
||||
companion object {
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,8 +6,8 @@ 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
|
||||
|
@ -18,6 +18,7 @@ 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
|
||||
|
@ -43,7 +44,7 @@ class LabelsFragment : Fragment(), LabelListener {
|
|||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
labelAdapter = LabelAdapter(this)
|
||||
|
||||
binding?.RecyclerView?.apply {
|
||||
binding?.MainListView?.apply {
|
||||
initListView(requireContext())
|
||||
adapter = labelAdapter
|
||||
binding?.ImageView?.setImageResource(R.drawable.label)
|
||||
|
@ -76,7 +77,7 @@ class LabelsFragment : Fragment(), LabelListener {
|
|||
|
||||
override fun onEdit(position: Int) {
|
||||
labelAdapter?.currentList?.get(position)?.let { (label, _) ->
|
||||
displayEditLabelDialog(label)
|
||||
displayEditLabelDialog(label, model)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -86,13 +87,13 @@ class LabelsFragment : Fragment(), LabelListener {
|
|||
|
||||
override fun onToggleVisibility(position: Int) {
|
||||
labelAdapter?.currentList?.get(position)?.let { value ->
|
||||
val hiddenLabels = model.preferences.labelsHiddenInNavigation.value.toMutableSet()
|
||||
val hiddenLabels = model.preferences.labelsHidden.value.toMutableSet()
|
||||
if (value.visibleInNavigation) {
|
||||
hiddenLabels.add(value.label)
|
||||
} else {
|
||||
hiddenLabels.remove(value.label)
|
||||
}
|
||||
model.savePreference(model.preferences.labelsHiddenInNavigation, hiddenLabels)
|
||||
model.savePreference(model.preferences.labelsHidden, hiddenLabels)
|
||||
|
||||
val currentList = labelAdapter!!.currentList.toMutableList()
|
||||
currentList[position] =
|
||||
|
@ -103,7 +104,7 @@ class LabelsFragment : Fragment(), LabelListener {
|
|||
|
||||
private fun setupObserver() {
|
||||
model.labels.observe(viewLifecycleOwner) { labels ->
|
||||
val hiddenLabels = model.preferences.labelsHiddenInNavigation.value
|
||||
val hiddenLabels = model.preferences.labelsHidden.value
|
||||
val labelsData = labels.map { label -> LabelData(label, !hiddenLabels.contains(label)) }
|
||||
labelAdapter?.submitList(labelsData)
|
||||
binding?.ImageView?.isVisible = labels.isEmpty()
|
||||
|
@ -131,7 +132,12 @@ class LabelsFragment : Fragment(), LabelListener {
|
|||
}
|
||||
}
|
||||
}
|
||||
.showAndFocus(dialogBinding.EditText, allowFullSize = true)
|
||||
.showAndFocus(dialogBinding.EditText, allowFullSize = true) { positiveButton ->
|
||||
dialogBinding.EditText.doAfterTextChanged { text ->
|
||||
positiveButton.isEnabled = !text.isNullOrEmpty()
|
||||
}
|
||||
positiveButton.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmDeletion(value: String) {
|
||||
|
@ -142,32 +148,4 @@ class LabelsFragment : Fragment(), LabelListener {
|
|||
.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)
|
||||
.setCancelButton()
|
||||
.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, allowFullSize = true)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,9 +9,11 @@ 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
|
||||
|
@ -23,6 +25,8 @@ 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.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
|
||||
|
@ -30,7 +34,9 @@ import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EX
|
|||
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.showKeyboard
|
||||
import com.philkes.notallyx.presentation.view.main.BaseNoteAdapter
|
||||
import com.philkes.notallyx.presentation.view.main.BaseNoteVHPreferences
|
||||
import com.philkes.notallyx.presentation.view.misc.ItemListener
|
||||
|
@ -55,7 +61,7 @@ abstract class NotallyFragment : Fragment(), ItemListener {
|
|||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
val layoutManager = binding?.RecyclerView?.layoutManager as? LinearLayoutManager
|
||||
val layoutManager = binding?.MainListView?.layoutManager as? LinearLayoutManager
|
||||
if (layoutManager != null) {
|
||||
val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()
|
||||
if (firstVisiblePosition != RecyclerView.NO_POSITION) {
|
||||
|
@ -73,6 +79,7 @@ abstract class NotallyFragment : Fragment(), ItemListener {
|
|||
setupAdapter()
|
||||
setupRecyclerView()
|
||||
setupObserver()
|
||||
setupSearch()
|
||||
|
||||
setupActivityResultLaunchers()
|
||||
|
||||
|
@ -80,8 +87,8 @@ abstract class NotallyFragment : Fragment(), ItemListener {
|
|||
val scrollPosition = bundle.getInt(EXTRA_SCROLL_POS, -1)
|
||||
val scrollOffset = bundle.getInt(EXTRA_SCROLL_OFFSET, 0)
|
||||
if (scrollPosition > -1) {
|
||||
binding?.RecyclerView?.post {
|
||||
val layoutManager = binding?.RecyclerView?.layoutManager as? LinearLayoutManager
|
||||
binding?.MainListView?.post {
|
||||
val layoutManager = binding?.MainListView?.layoutManager as? LinearLayoutManager
|
||||
layoutManager?.scrollToPositionWithOffset(scrollPosition, scrollOffset)
|
||||
}
|
||||
}
|
||||
|
@ -170,6 +177,43 @@ abstract class NotallyFragment : Fragment(), ItemListener {
|
|||
}
|
||||
}
|
||||
|
||||
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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNoteSelection(id: Long, position: Int, baseNote: BaseNote) {
|
||||
if (model.actionMode.selectedNotes.contains(id)) {
|
||||
model.actionMode.remove(id)
|
||||
|
@ -192,7 +236,8 @@ abstract class NotallyFragment : Fragment(), ItemListener {
|
|||
maxItems.value,
|
||||
maxLines.value,
|
||||
maxTitle.value,
|
||||
labelsHiddenInOverview.value,
|
||||
labelTagsHiddenInOverview.value,
|
||||
imagesHiddenInOverview.value,
|
||||
),
|
||||
model.imageRoot,
|
||||
this@NotallyFragment,
|
||||
|
@ -203,12 +248,12 @@ abstract class NotallyFragment : Fragment(), ItemListener {
|
|||
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(false)
|
||||
}
|
||||
|
@ -242,7 +287,7 @@ abstract class NotallyFragment : Fragment(), ItemListener {
|
|||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
binding?.RecyclerView?.layoutManager =
|
||||
binding?.MainListView?.layoutManager =
|
||||
if (model.preferences.notesView.value == NotesView.GRID) {
|
||||
StaggeredGridLayoutManager(2, RecyclerView.VERTICAL)
|
||||
} else LinearLayoutManager(requireContext())
|
||||
|
|
|
@ -36,7 +36,7 @@ class RemindersFragment : Fragment(), NoteReminderListener {
|
|||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
reminderAdapter = NoteReminderAdapter(this)
|
||||
|
||||
binding?.RecyclerView?.apply {
|
||||
binding?.MainListView?.apply {
|
||||
initListView(requireContext())
|
||||
adapter = reminderAdapter
|
||||
binding?.ImageView?.setImageResource(R.drawable.notifications)
|
||||
|
|
|
@ -4,38 +4,49 @@ 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 checked =
|
||||
when (initialFolder ?: model.folder.value) {
|
||||
Folder.NOTES -> R.id.Notes
|
||||
Folder.DELETED -> R.id.Deleted
|
||||
Folder.ARCHIVED -> R.id.Archived
|
||||
}
|
||||
|
||||
binding?.ChipGroup?.apply {
|
||||
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
|
||||
val initialLabel = arguments?.getString(EXTRA_INITIAL_LABEL)
|
||||
model.currentLabel = initialLabel
|
||||
if (initialLabel?.isEmpty() == true) {
|
||||
val checked =
|
||||
when (initialFolder ?: model.folder.value) {
|
||||
Folder.NOTES -> R.id.Notes
|
||||
Folder.DELETED -> R.id.Deleted
|
||||
Folder.ARCHIVED -> R.id.Archived
|
||||
}
|
||||
|
||||
binding?.ChipGroup?.apply {
|
||||
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
|
||||
}
|
||||
check(checked)
|
||||
} else binding?.ChipGroup?.isVisible = false
|
||||
getObservable().observe(viewLifecycleOwner) { items ->
|
||||
model.actionMode.updateSelected(items?.filterIsInstance<BaseNote>()?.map { it.id })
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -45,5 +56,6 @@ class SearchFragment : NotallyFragment() {
|
|||
|
||||
companion object {
|
||||
const val EXTRA_INITIAL_FOLDER = "notallyx.intent.extra.INITIAL_FOLDER"
|
||||
const val EXTRA_INITIAL_LABEL = "notallyx.intent.extra.INITIAL_LABEL"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -6,19 +6,23 @@ 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.DialogDateFormatBinding
|
||||
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
|
||||
|
@ -31,12 +35,15 @@ 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
|
||||
|
||||
|
@ -194,28 +201,35 @@ fun PreferenceBinding.setup(
|
|||
Value.text = dateFormatValue.getText(context)
|
||||
|
||||
root.setOnClickListener {
|
||||
val layout = DialogDateFormatBinding.inflate(layoutInflater, null, false)
|
||||
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.DateFormatRadioGroup.addView(this)
|
||||
layout.EnumRadioGroup.addView(this)
|
||||
if (dateFormat == dateFormatValue) {
|
||||
layout.DateFormatRadioGroup.check(this.id)
|
||||
layout.EnumRadioGroup.check(this.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
layout.ApplyToNoteView.isChecked = applyToNoteViewValue
|
||||
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.DateFormatRadioGroup.checkedTag() as DateFormat
|
||||
val applyToNoteView = layout.ApplyToNoteView.isChecked
|
||||
val dateFormat = layout.EnumRadioGroup.checkedTag() as DateFormat
|
||||
val applyToNoteView = layout.Toggle.isChecked
|
||||
onSave(dateFormat, applyToNoteView)
|
||||
}
|
||||
.setCancelButton()
|
||||
|
@ -223,6 +237,52 @@ fun PreferenceBinding.setup(
|
|||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
@ -429,3 +489,71 @@ fun PreferenceSeekbarBinding.setup(
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,7 +31,9 @@ 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
|
||||
|
@ -39,24 +41,23 @@ 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.BiometricLock
|
||||
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.disableBiometricLock
|
||||
import com.philkes.notallyx.utils.security.encryptDatabase
|
||||
import com.philkes.notallyx.utils.security.showBiometricOrPinPrompt
|
||||
import com.philkes.notallyx.utils.wrapWithChooser
|
||||
import java.util.Date
|
||||
|
@ -97,6 +98,27 @@ class SettingsFragment : Fragment() {
|
|||
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() {
|
||||
|
@ -171,11 +193,12 @@ class SettingsFragment : Fragment() {
|
|||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
if (
|
||||
model.importPreferences(requireContext(), uri, ::askForUriPermissions)
|
||||
model.importPreferences(
|
||||
requireContext(),
|
||||
uri,
|
||||
::askForUriPermissions,
|
||||
{ showToast(R.string.import_settings_success) },
|
||||
) {
|
||||
showToast(R.string.import_settings_success)
|
||||
} else {
|
||||
showToast(R.string.import_settings_failure)
|
||||
}
|
||||
}
|
||||
|
@ -224,9 +247,27 @@ class SettingsFragment : Fragment() {
|
|||
}
|
||||
}
|
||||
|
||||
theme.observe(viewLifecycleOwner) { value ->
|
||||
binding.Theme.setup(theme, value, requireContext()) { newValue ->
|
||||
model.savePreference(theme, 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -259,14 +300,29 @@ class SettingsFragment : Fragment() {
|
|||
model,
|
||||
)
|
||||
}
|
||||
// TODO: Hide for now until checked auto-sort is working reliably
|
||||
// listItemSorting.observe(viewLifecycleOwner) { value ->
|
||||
// binding.CheckedListItemSorting.setup(ListItemSorting, value)
|
||||
// }
|
||||
|
||||
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) {
|
||||
|
@ -281,15 +337,29 @@ class SettingsFragment : Fragment() {
|
|||
MaxLines.setup(maxLines, requireContext()) { newValue ->
|
||||
model.savePreference(maxLines, newValue)
|
||||
}
|
||||
labelsHiddenInOverview.observe(viewLifecycleOwner) { value ->
|
||||
MaxLabels.setup(maxLabels, requireContext()) { newValue ->
|
||||
model.savePreference(maxLabels, newValue)
|
||||
}
|
||||
labelTagsHiddenInOverview.observe(viewLifecycleOwner) { value ->
|
||||
binding.LabelsHiddenInOverview.setup(
|
||||
labelsHiddenInOverview,
|
||||
labelTagsHiddenInOverview,
|
||||
value,
|
||||
requireContext(),
|
||||
layoutInflater,
|
||||
R.string.labels_hidden_in_overview,
|
||||
) { enabled ->
|
||||
model.savePreference(labelsHiddenInOverview, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -382,7 +452,7 @@ class SettingsFragment : Fragment() {
|
|||
when (selectedImportSource.mimeType) {
|
||||
FOLDER_OR_FILE_MIMETYPE ->
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.plain_text_files)
|
||||
.setTitle(selectedImportSource.displayNameResId)
|
||||
.setItems(
|
||||
arrayOf(
|
||||
getString(R.string.folder),
|
||||
|
@ -464,9 +534,17 @@ class SettingsFragment : Fragment() {
|
|||
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 = BACKUP_PERIOD_DAYS_MIN),
|
||||
preference.value.copy(periodInDays = periodInDays, maxBackups = maxBackups),
|
||||
)
|
||||
} else {
|
||||
model.savePreference(preference, preference.value.copy(periodInDays = 0))
|
||||
|
@ -526,6 +604,14 @@ class SettingsFragment : Fragment() {
|
|||
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) {
|
||||
|
@ -558,8 +644,7 @@ class SettingsFragment : Fragment() {
|
|||
}
|
||||
ResetSettings.setOnClickListener {
|
||||
showDialog(R.string.reset_settings_message, R.string.reset_settings) { _, _ ->
|
||||
model.resetPreferences()
|
||||
showToast(R.string.reset_settings_success)
|
||||
model.resetPreferences { _ -> showToast(R.string.reset_settings_success) }
|
||||
}
|
||||
}
|
||||
dataInPublicFolder.observe(viewLifecycleOwner) { value ->
|
||||
|
@ -577,6 +662,11 @@ class SettingsFragment : Fragment() {
|
|||
}
|
||||
}
|
||||
}
|
||||
AutoSaveAfterIdle.setupAutoSaveIdleTime(autoSaveAfterIdleTime, requireContext()) {
|
||||
newValue ->
|
||||
model.savePreference(autoSaveAfterIdleTime, newValue)
|
||||
}
|
||||
|
||||
ClearData.setOnClickListener {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(R.string.clear_data_message)
|
||||
|
@ -591,34 +681,14 @@ class SettingsFragment : Fragment() {
|
|||
private fun setupAbout(binding: FragmentSettingsBinding) {
|
||||
binding.apply {
|
||||
SendFeedback.setOnClickListener {
|
||||
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)
|
||||
}
|
||||
}
|
||||
CreateIssue.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.create_github_issue)
|
||||
.setTitle(R.string.send_feedback)
|
||||
.setItems(options) { _, which ->
|
||||
when (which) {
|
||||
0 -> {
|
||||
|
@ -627,7 +697,7 @@ class SettingsFragment : Fragment() {
|
|||
reportBug(logs)
|
||||
}
|
||||
|
||||
else ->
|
||||
1 ->
|
||||
requireContext().catchNoBrowserInstalled {
|
||||
startActivity(
|
||||
Intent(
|
||||
|
@ -639,11 +709,40 @@ class SettingsFragment : Fragment() {
|
|||
.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 =
|
||||
|
@ -657,6 +756,7 @@ class SettingsFragment : Fragment() {
|
|||
"SQLCipher",
|
||||
"Zip4J",
|
||||
"AndroidFastScroll",
|
||||
"ColorPickerView",
|
||||
)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.libraries)
|
||||
|
@ -680,11 +780,13 @@ class SettingsFragment : Fragment() {
|
|||
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 =
|
||||
|
@ -710,14 +812,7 @@ class SettingsFragment : Fragment() {
|
|||
R.string.enable_lock_description,
|
||||
onSuccess = { cipher ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
model.savePreference(model.preferences.iv, cipher.iv)
|
||||
val passphrase = model.preferences.databaseEncryptionKey.init(cipher)
|
||||
encryptDatabase(requireContext(), passphrase)
|
||||
model.savePreference(
|
||||
model.preferences.fallbackDatabaseEncryptionKey,
|
||||
passphrase,
|
||||
)
|
||||
model.savePreference(model.preferences.biometricLock, BiometricLock.ENABLED)
|
||||
model.enableBiometricLock(cipher)
|
||||
}
|
||||
val app = (activity?.application as NotallyXApplication)
|
||||
app.locked.value = false
|
||||
|
@ -737,7 +832,7 @@ class SettingsFragment : Fragment() {
|
|||
model.preferences.iv.value!!,
|
||||
onSuccess = { cipher ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
requireContext().disableBiometricLock(model, cipher)
|
||||
model.disableBiometricLock(cipher)
|
||||
}
|
||||
showToast(R.string.biometrics_disable_success)
|
||||
},
|
||||
|
@ -773,4 +868,9 @@ class SettingsFragment : Fragment() {
|
|||
}
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_SHOW_IMPORT_BACKUPS_FOLDER =
|
||||
"notallyx.intent.extra.SHOW_IMPORT_BACKUPS_FOLDER"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,15 +8,20 @@ import android.graphics.drawable.Drawable
|
|||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import android.text.Editable
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.View.GONE
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.LayoutParams
|
||||
import android.view.ViewGroup.VISIBLE
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.ImageButton
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
|
@ -32,13 +37,18 @@ import androidx.recyclerview.widget.PagerSnapHelper
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.NotallyDatabase
|
||||
import com.philkes.notallyx.data.model.Audio
|
||||
import com.philkes.notallyx.data.model.FileAttachment
|
||||
import com.philkes.notallyx.data.model.Folder
|
||||
import com.philkes.notallyx.data.model.NoteViewMode
|
||||
import com.philkes.notallyx.data.model.Type
|
||||
import com.philkes.notallyx.data.model.toText
|
||||
import com.philkes.notallyx.data.model.isImageMimeType
|
||||
import com.philkes.notallyx.databinding.ActivityEditBinding
|
||||
import com.philkes.notallyx.presentation.activity.LockedActivity
|
||||
import com.philkes.notallyx.presentation.activity.main.MainActivity
|
||||
import com.philkes.notallyx.presentation.activity.main.MainActivity.Companion.EXTRA_FRAGMENT_TO_OPEN
|
||||
import com.philkes.notallyx.presentation.activity.main.MainActivity.Companion.EXTRA_SKIP_START_VIEW_ON_BACK
|
||||
import com.philkes.notallyx.presentation.activity.main.fragment.DisplayLabelFragment.Companion.EXTRA_DISPLAYED_LABEL
|
||||
import com.philkes.notallyx.presentation.activity.note.SelectLabelsActivity.Companion.EXTRA_SELECTED_LABELS
|
||||
import com.philkes.notallyx.presentation.activity.note.reminders.RemindersActivity
|
||||
|
@ -46,15 +56,16 @@ import com.philkes.notallyx.presentation.add
|
|||
import com.philkes.notallyx.presentation.addFastScroll
|
||||
import com.philkes.notallyx.presentation.addIconButton
|
||||
import com.philkes.notallyx.presentation.bindLabels
|
||||
import com.philkes.notallyx.presentation.displayEditLabelDialog
|
||||
import com.philkes.notallyx.presentation.displayFormattedTimestamp
|
||||
import com.philkes.notallyx.presentation.extractColor
|
||||
import com.philkes.notallyx.presentation.getQuantityString
|
||||
import com.philkes.notallyx.presentation.hideKeyboard
|
||||
import com.philkes.notallyx.presentation.isLightColor
|
||||
import com.philkes.notallyx.presentation.setCancelButton
|
||||
import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews
|
||||
import com.philkes.notallyx.presentation.setLightStatusAndNavBar
|
||||
import com.philkes.notallyx.presentation.setupProgressDialog
|
||||
import com.philkes.notallyx.presentation.showColorSelectDialog
|
||||
import com.philkes.notallyx.presentation.showKeyboard
|
||||
import com.philkes.notallyx.presentation.showToast
|
||||
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
|
||||
|
@ -67,21 +78,28 @@ import com.philkes.notallyx.presentation.view.note.action.MoreNoteBottomSheet
|
|||
import com.philkes.notallyx.presentation.view.note.audio.AudioAdapter
|
||||
import com.philkes.notallyx.presentation.view.note.preview.PreviewFileAdapter
|
||||
import com.philkes.notallyx.presentation.view.note.preview.PreviewImageAdapter
|
||||
import com.philkes.notallyx.presentation.viewmodel.ExportMimeType
|
||||
import com.philkes.notallyx.presentation.viewmodel.NotallyModel
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.DateFormat
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.ListItemSort
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortBy
|
||||
import com.philkes.notallyx.presentation.widget.WidgetProvider
|
||||
import com.philkes.notallyx.utils.FileError
|
||||
import com.philkes.notallyx.utils.backup.exportNotes
|
||||
import com.philkes.notallyx.utils.changehistory.ChangeHistory
|
||||
import com.philkes.notallyx.utils.getMimeType
|
||||
import com.philkes.notallyx.utils.getUriForFile
|
||||
import com.philkes.notallyx.utils.log
|
||||
import com.philkes.notallyx.utils.mergeSkipFirst
|
||||
import com.philkes.notallyx.utils.observeSkipFirst
|
||||
import com.philkes.notallyx.utils.shareNote
|
||||
import com.philkes.notallyx.utils.showColorSelectDialog
|
||||
import com.philkes.notallyx.utils.wrapWithChooser
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
abstract class EditActivity(private val type: Type) :
|
||||
LockedActivity<ActivityEditBinding>(), AddActions, MoreActions {
|
||||
|
@ -93,6 +111,8 @@ abstract class EditActivity(private val type: Type) :
|
|||
private lateinit var selectLabelsActivityResultLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var playAudioActivityResultLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var attachFilesActivityResultLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var exportNotesActivityResultLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var exportFileActivityResultLauncher: ActivityResultLauncher<Intent>
|
||||
|
||||
private lateinit var pinMenuItem: MenuItem
|
||||
protected var search = Search()
|
||||
|
@ -106,17 +126,38 @@ abstract class EditActivity(private val type: Type) :
|
|||
protected var colorInt: Int = -1
|
||||
protected var inputMethodManager: InputMethodManager? = null
|
||||
|
||||
protected lateinit var toggleViewMode: ImageButton
|
||||
protected val canEdit
|
||||
get() = notallyModel.viewMode.value == NoteViewMode.EDIT
|
||||
|
||||
private val autoSaveHandler = Handler(Looper.getMainLooper())
|
||||
private val autoSaveRunnable = Runnable {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
updateModel()
|
||||
if (notallyModel.isModified()) {
|
||||
Log.d(TAG, "Auto-saving note...")
|
||||
saveNote(checkAutoSave = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
if (notallyModel.isEmpty()) {
|
||||
notallyModel.deleteBaseNote(checkAutoSave = false)
|
||||
} else if (notallyModel.isModified()) {
|
||||
saveNote()
|
||||
} else {
|
||||
notallyModel.checkBackupOnSave()
|
||||
}
|
||||
super.finish()
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun updateModel() {
|
||||
notallyModel.modifiedTimestamp = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putLong("id", notallyModel.id)
|
||||
|
@ -125,9 +166,9 @@ abstract class EditActivity(private val type: Type) :
|
|||
}
|
||||
}
|
||||
|
||||
open suspend fun saveNote() {
|
||||
notallyModel.modifiedTimestamp = System.currentTimeMillis()
|
||||
notallyModel.saveNote()
|
||||
open suspend fun saveNote(checkAutoSave: Boolean = true) {
|
||||
updateModel()
|
||||
notallyModel.saveNote(checkAutoSave)
|
||||
WidgetProvider.sendBroadcast(application, longArrayOf(notallyModel.id))
|
||||
}
|
||||
|
||||
|
@ -144,14 +185,18 @@ abstract class EditActivity(private val type: Type) :
|
|||
val persistedId = savedInstanceState?.getLong("id")
|
||||
val selectedId = intent.getLongExtra(EXTRA_SELECTED_BASE_NOTE, 0L)
|
||||
val id = persistedId ?: selectedId
|
||||
if (persistedId == null) {
|
||||
if (persistedId == null || notallyModel.originalNote == null) {
|
||||
notallyModel.setState(id)
|
||||
}
|
||||
if (notallyModel.isNewNote && intent.action == Intent.ACTION_SEND) {
|
||||
handleSharedNote()
|
||||
} else if (notallyModel.isNewNote) {
|
||||
intent.getStringExtra(EXTRA_DISPLAYED_LABEL)?.let {
|
||||
notallyModel.setLabels(listOf(it))
|
||||
if (notallyModel.isNewNote) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_SEND,
|
||||
Intent.ACTION_SEND_MULTIPLE -> handleSharedNote()
|
||||
Intent.ACTION_VIEW -> handleViewNote()
|
||||
else ->
|
||||
intent.getStringExtra(EXTRA_DISPLAYED_LABEL)?.let {
|
||||
notallyModel.setLabels(listOf(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -167,6 +212,43 @@ abstract class EditActivity(private val type: Type) :
|
|||
}
|
||||
|
||||
setupActivityResultLaunchers()
|
||||
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
try {
|
||||
updateModel()
|
||||
runBlocking(Dispatchers.IO) { saveNote() }
|
||||
} catch (e: Exception) {
|
||||
log(TAG, msg = "Saving note on Crash failed", throwable = e)
|
||||
} finally {
|
||||
// Let the system handle the crash
|
||||
DEFAULT_EXCEPTION_HANDLER?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun toggleCanEdit(mode: NoteViewMode) {
|
||||
binding.EnterTitle.apply {
|
||||
if (isFocused) {
|
||||
when {
|
||||
mode == NoteViewMode.EDIT -> showKeyboard(this)
|
||||
else -> hideKeyboard(this)
|
||||
}
|
||||
}
|
||||
setCanEdit(mode == NoteViewMode.EDIT)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
autoSaveHandler.removeCallbacks(autoSaveRunnable)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
protected fun resetIdleTimer() {
|
||||
autoSaveHandler.removeCallbacks(autoSaveRunnable)
|
||||
val idleTime = preferences.autoSaveAfterIdleTime.value
|
||||
if (idleTime > -1) {
|
||||
autoSaveHandler.postDelayed(autoSaveRunnable, idleTime.toLong() * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupActivityResultLaunchers() {
|
||||
|
@ -210,10 +292,7 @@ abstract class EditActivity(private val type: Type) :
|
|||
selectLabelsActivityResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
val list =
|
||||
result.data?.getStringArrayListExtra(
|
||||
SelectLabelsActivity.EXTRA_SELECTED_LABELS
|
||||
)
|
||||
val list = result.data?.getStringArrayListExtra(EXTRA_SELECTED_LABELS)
|
||||
if (list != null && list != notallyModel.labels) {
|
||||
notallyModel.setLabels(list)
|
||||
binding.LabelGroup.bindLabels(
|
||||
|
@ -222,6 +301,7 @@ abstract class EditActivity(private val type: Type) :
|
|||
paddingTop = true,
|
||||
colorInt,
|
||||
)
|
||||
resetIdleTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -256,6 +336,21 @@ abstract class EditActivity(private val type: Type) :
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.exportNotesToFolder(uri, listOf(notallyModel.getBaseNote()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
|
@ -281,6 +376,7 @@ abstract class EditActivity(private val type: Type) :
|
|||
ChangeHistory().apply {
|
||||
canUndo.observe(this@EditActivity) { canUndo -> undo?.isEnabled = canUndo }
|
||||
canRedo.observe(this@EditActivity) { canRedo -> redo?.isEnabled = canRedo }
|
||||
stackPointer.observe(this@EditActivity) { _ -> resetIdleTimer() }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -294,26 +390,6 @@ abstract class EditActivity(private val type: Type) :
|
|||
pinMenuItem =
|
||||
add(R.string.pin, R.drawable.pin, MenuItem.SHOW_AS_ACTION_ALWAYS) { pin() }
|
||||
bindPinned()
|
||||
|
||||
when (notallyModel.folder) {
|
||||
Folder.NOTES -> {
|
||||
add(R.string.delete, R.drawable.delete, MenuItem.SHOW_AS_ACTION_ALWAYS) {
|
||||
delete()
|
||||
}
|
||||
}
|
||||
|
||||
Folder.DELETED -> {
|
||||
add(R.string.restore, R.drawable.restore, MenuItem.SHOW_AS_ACTION_ALWAYS) {
|
||||
restore()
|
||||
}
|
||||
}
|
||||
|
||||
Folder.ARCHIVED -> {
|
||||
add(R.string.unarchive, R.drawable.unarchive, MenuItem.SHOW_AS_ACTION_ALWAYS) {
|
||||
restore()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
search.results.mergeSkipFirst(search.resultPos).observe(this) { (amount, pos) ->
|
||||
|
@ -429,7 +505,19 @@ abstract class EditActivity(private val type: Type) :
|
|||
binding.BottomAppBarCenter.apply {
|
||||
removeAllViews()
|
||||
undo =
|
||||
addIconButton(R.string.undo, R.drawable.undo, marginStart = 2) {
|
||||
addIconButton(
|
||||
R.string.undo,
|
||||
R.drawable.undo,
|
||||
marginStart = 2,
|
||||
onLongClick = {
|
||||
try {
|
||||
changeHistory.undoAll()
|
||||
} catch (e: ChangeHistory.ChangeHistoryException) {
|
||||
application.log(TAG, throwable = e)
|
||||
}
|
||||
true
|
||||
},
|
||||
) {
|
||||
try {
|
||||
changeHistory.undo()
|
||||
} catch (e: ChangeHistory.ChangeHistoryException) {
|
||||
|
@ -439,7 +527,19 @@ abstract class EditActivity(private val type: Type) :
|
|||
.apply { isEnabled = changeHistory.canUndo.value }
|
||||
|
||||
redo =
|
||||
addIconButton(R.string.redo, R.drawable.redo, marginStart = 2) {
|
||||
addIconButton(
|
||||
R.string.redo,
|
||||
R.drawable.redo,
|
||||
marginStart = 2,
|
||||
onLongClick = {
|
||||
try {
|
||||
changeHistory.redoAll()
|
||||
} catch (e: ChangeHistory.ChangeHistoryException) {
|
||||
application.log(TAG, throwable = e)
|
||||
}
|
||||
true
|
||||
},
|
||||
) {
|
||||
try {
|
||||
changeHistory.redo()
|
||||
} catch (e: ChangeHistory.ChangeHistoryException) {
|
||||
|
@ -450,41 +550,132 @@ abstract class EditActivity(private val type: Type) :
|
|||
}
|
||||
binding.BottomAppBarRight.apply {
|
||||
removeAllViews()
|
||||
addIconButton(R.string.more, R.drawable.more_vert, marginStart = 0) {
|
||||
MoreNoteBottomSheet(this@EditActivity, createFolderActions(), colorInt)
|
||||
|
||||
addToggleViewMode()
|
||||
addIconButton(R.string.tap_for_more_options, R.drawable.more_vert, marginStart = 0) {
|
||||
MoreNoteBottomSheet(
|
||||
this@EditActivity,
|
||||
createNoteTypeActions() + createFolderActions(),
|
||||
colorInt,
|
||||
)
|
||||
.show(supportFragmentManager, MoreNoteBottomSheet.TAG)
|
||||
}
|
||||
}
|
||||
setBottomAppBarColor(colorInt)
|
||||
}
|
||||
|
||||
protected fun ViewGroup.addToggleViewMode() {
|
||||
toggleViewMode =
|
||||
addIconButton(R.string.edit, R.drawable.visibility) {
|
||||
notallyModel.viewMode.value =
|
||||
when (notallyModel.viewMode.value) {
|
||||
NoteViewMode.EDIT -> NoteViewMode.READ_ONLY
|
||||
NoteViewMode.READ_ONLY -> NoteViewMode.EDIT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected fun createFolderActions() =
|
||||
when (notallyModel.folder) {
|
||||
Folder.NOTES ->
|
||||
listOf(
|
||||
Action(R.string.archive, R.drawable.archive, callback = ::archive),
|
||||
Action(R.string.delete, R.drawable.delete, callback = ::delete),
|
||||
Action(R.string.archive, R.drawable.archive) { _ ->
|
||||
archive()
|
||||
true
|
||||
},
|
||||
Action(R.string.delete, R.drawable.delete) { _ ->
|
||||
delete()
|
||||
true
|
||||
},
|
||||
)
|
||||
|
||||
Folder.DELETED ->
|
||||
listOf(
|
||||
Action(R.string.delete_forever, R.drawable.delete, callback = ::deleteForever),
|
||||
Action(R.string.restore, R.drawable.restore, callback = ::restore),
|
||||
Action(R.string.delete_forever, R.drawable.delete) { _ ->
|
||||
deleteForever()
|
||||
true
|
||||
},
|
||||
Action(R.string.restore, R.drawable.restore) { _ ->
|
||||
restore()
|
||||
true
|
||||
},
|
||||
)
|
||||
|
||||
Folder.ARCHIVED ->
|
||||
listOf(
|
||||
Action(R.string.delete, R.drawable.delete, callback = ::delete),
|
||||
Action(R.string.unarchive, R.drawable.unarchive, callback = ::restore),
|
||||
Action(R.string.delete, R.drawable.delete) { _ ->
|
||||
delete()
|
||||
true
|
||||
},
|
||||
Action(R.string.unarchive, R.drawable.unarchive) { _ ->
|
||||
restore()
|
||||
true
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
protected fun createNoteTypeActions() =
|
||||
when (notallyModel.type) {
|
||||
Type.NOTE ->
|
||||
listOf(
|
||||
Action(R.string.convert_to_list_note, R.drawable.convert_to_text) { _ ->
|
||||
convertTo(Type.LIST)
|
||||
true
|
||||
}
|
||||
)
|
||||
Type.LIST ->
|
||||
listOf(
|
||||
Action(R.string.convert_to_text_note, R.drawable.convert_to_text) { _ ->
|
||||
convertTo(Type.NOTE)
|
||||
true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun convertTo(type: Type) {
|
||||
updateModel()
|
||||
lifecycleScope.launch {
|
||||
notallyModel.convertTo(type)
|
||||
val intent =
|
||||
Intent(
|
||||
this@EditActivity,
|
||||
when (type) {
|
||||
Type.NOTE -> EditNoteActivity::class.java
|
||||
Type.LIST -> EditListActivity::class.java
|
||||
},
|
||||
)
|
||||
intent.putExtra(EXTRA_SELECTED_BASE_NOTE, notallyModel.id)
|
||||
startActivity(intent)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun configureUI()
|
||||
|
||||
open fun setupListeners() {
|
||||
binding.EnterTitle.initHistory(changeHistory) { text ->
|
||||
notallyModel.title = text.trim().toString()
|
||||
}
|
||||
notallyModel.viewMode.observe(this) { value ->
|
||||
toggleViewMode.apply {
|
||||
setImageResource(
|
||||
when (value) {
|
||||
NoteViewMode.READ_ONLY -> R.drawable.edit
|
||||
else -> R.drawable.visibility
|
||||
}
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
tooltipText =
|
||||
getString(
|
||||
when (value) {
|
||||
NoteViewMode.READ_ONLY -> R.string.edit
|
||||
else -> R.string.read_only
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
value?.let { toggleCanEdit(it) }
|
||||
}
|
||||
}
|
||||
|
||||
open fun setStateFromModel(savedInstanceState: Bundle?) {
|
||||
|
@ -501,26 +692,90 @@ abstract class EditActivity(private val type: Type) :
|
|||
} else DateFormat.ABSOLUTE
|
||||
binding.Date.displayFormattedTimestamp(date, dateFormat, datePrefixResId)
|
||||
binding.EnterTitle.setText(notallyModel.title)
|
||||
bindLabels()
|
||||
setColor()
|
||||
}
|
||||
|
||||
private fun bindLabels() {
|
||||
binding.LabelGroup.bindLabels(
|
||||
notallyModel.labels,
|
||||
notallyModel.textSize,
|
||||
paddingTop = true,
|
||||
colorInt,
|
||||
onClick = { label ->
|
||||
val bundle = Bundle()
|
||||
bundle.putString(EXTRA_DISPLAYED_LABEL, label)
|
||||
startActivity(
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
putExtra(EXTRA_FRAGMENT_TO_OPEN, R.id.DisplayLabel)
|
||||
putExtra(EXTRA_DISPLAYED_LABEL, label)
|
||||
putExtra(EXTRA_SKIP_START_VIEW_ON_BACK, true)
|
||||
}
|
||||
)
|
||||
},
|
||||
onLongClick = { label ->
|
||||
displayEditLabelDialog(label, baseModel) { oldLabel, newLabel ->
|
||||
notallyModel.labels.apply {
|
||||
remove(oldLabel)
|
||||
add(newLabel)
|
||||
}
|
||||
bindLabels()
|
||||
}
|
||||
},
|
||||
)
|
||||
setColor()
|
||||
}
|
||||
|
||||
private fun handleSharedNote() {
|
||||
val title = intent.getStringExtra(Intent.EXTRA_SUBJECT)
|
||||
|
||||
val string = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
|
||||
val files =
|
||||
IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
|
||||
?: IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
|
||||
?.let { listOf(it) }
|
||||
if (string != null) {
|
||||
notallyModel.body = Editable.Factory.getInstance().newEditable(string)
|
||||
}
|
||||
if (title != null) {
|
||||
notallyModel.title = title
|
||||
}
|
||||
files?.let {
|
||||
val filesByType =
|
||||
it.groupBy { uri ->
|
||||
getMimeType(uri)?.let { mimeType ->
|
||||
if (mimeType.isImageMimeType) {
|
||||
NotallyModel.FileType.IMAGE
|
||||
} else {
|
||||
NotallyModel.FileType.ANY
|
||||
}
|
||||
} ?: NotallyModel.FileType.ANY
|
||||
}
|
||||
filesByType[NotallyModel.FileType.IMAGE]?.let { images ->
|
||||
notallyModel.addImages(images.toTypedArray())
|
||||
}
|
||||
filesByType[NotallyModel.FileType.ANY]?.let { otherFiles ->
|
||||
notallyModel.addFiles(otherFiles.toTypedArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleViewNote() {
|
||||
val text =
|
||||
intent.data?.let { uri ->
|
||||
contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||
inputStream.bufferedReader().readText()
|
||||
}
|
||||
?: run {
|
||||
showToast(R.string.cant_load_file)
|
||||
null
|
||||
}
|
||||
} ?: intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
val title = intent.getStringExtra(Intent.EXTRA_SUBJECT)
|
||||
if (text != null) {
|
||||
notallyModel.body = Editable.Factory.getInstance().newEditable(text)
|
||||
}
|
||||
if (title != null) {
|
||||
notallyModel.title = title
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(24)
|
||||
|
@ -589,9 +844,38 @@ abstract class EditActivity(private val type: Type) :
|
|||
}
|
||||
|
||||
override fun changeColor() {
|
||||
showColorSelectDialog(colorInt.isLightColor()) { selectedColor ->
|
||||
notallyModel.color = selectedColor
|
||||
setColor()
|
||||
lifecycleScope.launch {
|
||||
val colors =
|
||||
withContext(Dispatchers.IO) {
|
||||
NotallyDatabase.getDatabase(this@EditActivity, observePreferences = false)
|
||||
.value
|
||||
.getBaseNoteDao()
|
||||
.getAllColors()
|
||||
}
|
||||
.toMutableList()
|
||||
if (colors.none { it == notallyModel.color }) {
|
||||
colors.add(notallyModel.color)
|
||||
}
|
||||
showColorSelectDialog(
|
||||
colors,
|
||||
notallyModel.color,
|
||||
colorInt.isLightColor(),
|
||||
{ selectedColor, oldColor ->
|
||||
if (oldColor != null) {
|
||||
baseModel.changeColor(oldColor, selectedColor)
|
||||
}
|
||||
notallyModel.color = selectedColor
|
||||
setColor()
|
||||
resetIdleTimer()
|
||||
},
|
||||
) { colorToDelete, newColor ->
|
||||
baseModel.changeColor(colorToDelete, newColor)
|
||||
if (colorToDelete == notallyModel.color) {
|
||||
notallyModel.color = newColor
|
||||
setColor()
|
||||
}
|
||||
resetIdleTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -608,12 +892,16 @@ abstract class EditActivity(private val type: Type) :
|
|||
}
|
||||
|
||||
override fun share() {
|
||||
val body =
|
||||
when (type) {
|
||||
Type.NOTE -> notallyModel.body
|
||||
Type.LIST -> notallyModel.items.toMutableList().toText()
|
||||
}
|
||||
this.shareNote(notallyModel.title, body)
|
||||
this.shareNote(notallyModel.getBaseNote())
|
||||
}
|
||||
|
||||
override fun export(mimeType: ExportMimeType) {
|
||||
exportNotes(
|
||||
mimeType,
|
||||
listOf(notallyModel.getBaseNote()),
|
||||
exportFileActivityResultLauncher,
|
||||
exportNotesActivityResultLauncher,
|
||||
)
|
||||
}
|
||||
|
||||
private fun delete() {
|
||||
|
@ -807,7 +1095,9 @@ abstract class EditActivity(private val type: Type) :
|
|||
colorInt = extractColor(notallyModel.color)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
window.statusBarColor = colorInt
|
||||
window.navigationBarColor = colorInt
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
window.navigationBarColor = colorInt
|
||||
}
|
||||
window.setLightStatusAndNavBar(colorInt.isLightColor())
|
||||
}
|
||||
binding.apply {
|
||||
|
@ -816,7 +1106,8 @@ abstract class EditActivity(private val type: Type) :
|
|||
setControlsContrastColorForAllViews(colorInt)
|
||||
}
|
||||
root.setBackgroundColor(colorInt)
|
||||
RecyclerView.setBackgroundColor(colorInt)
|
||||
MainListView.setBackgroundColor(colorInt)
|
||||
CheckedListView.setBackgroundColor(colorInt)
|
||||
Toolbar.backgroundTintList = ColorStateList.valueOf(colorInt)
|
||||
Toolbar.setControlsContrastColorForAllViews(colorInt)
|
||||
}
|
||||
|
@ -838,10 +1129,15 @@ abstract class EditActivity(private val type: Type) :
|
|||
when (type) {
|
||||
Type.NOTE -> {
|
||||
binding.AddItem.visibility = GONE
|
||||
binding.RecyclerView.visibility = GONE
|
||||
binding.MainListView.visibility = GONE
|
||||
binding.CheckedListView.visibility = GONE
|
||||
}
|
||||
Type.LIST -> {
|
||||
binding.EnterBody.visibility = GONE
|
||||
binding.CheckedListView.visibility =
|
||||
if (preferences.listItemSorting.value == ListItemSort.AUTO_SORT_BY_CHECKED)
|
||||
VISIBLE
|
||||
else GONE
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -896,5 +1192,7 @@ abstract class EditActivity(private val type: Type) :
|
|||
const val EXTRA_NOTE_ID = "notallyx.intent.extra.NOTE_ID"
|
||||
const val EXTRA_FOLDER_FROM = "notallyx.intent.extra.FOLDER_FROM"
|
||||
const val EXTRA_FOLDER_TO = "notallyx.intent.extra.FOLDER_TO"
|
||||
|
||||
val DEFAULT_EXCEPTION_HANDLER = Thread.getDefaultUncaughtExceptionHandler()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,42 +1,64 @@
|
|||
package com.philkes.notallyx.presentation.activity.note
|
||||
|
||||
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.addIconButton
|
||||
import com.philkes.notallyx.presentation.hideKeyboardOnFocusedItem
|
||||
import com.philkes.notallyx.presentation.setOnNextAction
|
||||
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.ListItemAdapter
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.ListItemVH
|
||||
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.indices
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.mapIndexed
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.toMutableList
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.ListItemSort
|
||||
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), MoreListActions {
|
||||
|
||||
private var adapter: ListItemAdapter? = null
|
||||
private lateinit var items: ListItemSortedList
|
||||
private var adapterChecked: CheckedListItemAdapter? = null
|
||||
private val items: MutableList<ListItem>
|
||||
get() = adapter!!.items
|
||||
|
||||
private var itemsChecked: SortedItemsList? = null
|
||||
private lateinit var listManager: ListManager
|
||||
|
||||
override fun finish() {
|
||||
notallyModel.setItems(items.toMutableList())
|
||||
notallyModel.setItems(items.toMutableList() + (itemsChecked?.toMutableList() ?: listOf()))
|
||||
super.finish()
|
||||
}
|
||||
|
||||
override fun updateModel() {
|
||||
super.updateModel()
|
||||
notallyModel.setItems(items.toMutableList() + (itemsChecked?.toMutableList() ?: listOf()))
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
notallyModel.setItems(items.toMutableList())
|
||||
binding.RecyclerView.focusedChild?.let { focusedChild ->
|
||||
val viewHolder = binding.RecyclerView.findContainingViewHolder(focusedChild)
|
||||
updateModel()
|
||||
binding.MainListView.focusedChild?.let { focusedChild ->
|
||||
val viewHolder = binding.MainListView.findContainingViewHolder(focusedChild)
|
||||
if (viewHolder is ListItemVH) {
|
||||
val itemPos = binding.RecyclerView.getChildAdapterPosition(focusedChild)
|
||||
val itemPos = binding.MainListView.getChildAdapterPosition(focusedChild)
|
||||
if (itemPos > -1) {
|
||||
val (selectionStart, selectionEnd) = viewHolder.getSelection()
|
||||
outState.apply {
|
||||
|
@ -51,6 +73,21 @@ class EditListActivity : EditActivity(Type.LIST), MoreListActions {
|
|||
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()
|
||||
}
|
||||
|
@ -67,52 +104,112 @@ class EditListActivity : EditActivity(Type.LIST), MoreListActions {
|
|||
super.initBottomMenu()
|
||||
binding.BottomAppBarRight.apply {
|
||||
removeAllViews()
|
||||
addIconButton(R.string.more, R.drawable.more_vert, marginStart = 0) {
|
||||
MoreListBottomSheet(this@EditListActivity, createFolderActions(), colorInt)
|
||||
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 {
|
||||
var resultPos = 0
|
||||
val resultPosCounter = AtomicInteger(0)
|
||||
val alreadyNotifiedItemPos = mutableSetOf<Int>()
|
||||
adapter?.clearHighlights()
|
||||
adapterChecked?.clearHighlights()
|
||||
val amount =
|
||||
items
|
||||
.mapIndexed { idx, item ->
|
||||
val occurrences = item.body.findAllOccurrences(search)
|
||||
occurrences.onEach { (startIdx, endIdx) ->
|
||||
adapter?.highlightText(
|
||||
ListItemAdapter.ListItemHighlight(
|
||||
idx,
|
||||
resultPos++,
|
||||
startIdx,
|
||||
endIdx,
|
||||
false,
|
||||
)
|
||||
)
|
||||
}
|
||||
if (occurrences.isNotEmpty()) {
|
||||
alreadyNotifiedItemPos.add(idx)
|
||||
}
|
||||
occurrences.size
|
||||
}
|
||||
.sum()
|
||||
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) {
|
||||
val selectedItemPos = adapter!!.selectHighlight(resultPos)
|
||||
if (selectedItemPos != -1) {
|
||||
binding.RecyclerView.post {
|
||||
binding.RecyclerView.findViewHolderForAdapterPosition(selectedItemPos)
|
||||
?.itemView
|
||||
?.let { binding.ScrollView.scrollTo(0, binding.RecyclerView.top + it.top) }
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun RecyclerView.scrollToItemPosition(position: Int) {
|
||||
post {
|
||||
findViewHolderForAdapterPosition(position)?.itemView?.let {
|
||||
binding.ScrollView.scrollTo(0, top + it.top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -135,7 +232,7 @@ class EditListActivity : EditActivity(Type.LIST), MoreListActions {
|
|||
val elevation = resources.displayMetrics.density * 2
|
||||
listManager =
|
||||
ListManager(
|
||||
binding.RecyclerView,
|
||||
binding.MainListView,
|
||||
changeHistory,
|
||||
preferences,
|
||||
inputMethodManager,
|
||||
|
@ -156,25 +253,39 @@ class EditListActivity : EditActivity(Type.LIST), MoreListActions {
|
|||
elevation,
|
||||
NotallyXPreferences.getInstance(application),
|
||||
listManager,
|
||||
false,
|
||||
binding.ScrollView,
|
||||
)
|
||||
val sortCallback =
|
||||
when (preferences.listItemSorting.value) {
|
||||
ListItemSort.AUTO_SORT_BY_CHECKED -> ListItemSortedByCheckedCallback(adapter)
|
||||
else -> ListItemNoSortCallback(adapter)
|
||||
}
|
||||
items = ListItemSortedList(sortCallback)
|
||||
if (sortCallback is ListItemSortedByCheckedCallback) {
|
||||
sortCallback.setList(items)
|
||||
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())
|
||||
}
|
||||
adapterChecked?.setList(itemsChecked!!)
|
||||
binding.CheckedListView.adapter = adapterChecked
|
||||
} else {
|
||||
adapter?.submitList(initializedItems.toMutableList())
|
||||
}
|
||||
items.init(notallyModel.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.RecyclerView.apply {
|
||||
binding.MainListView.apply {
|
||||
post {
|
||||
scrollToPosition(itemPos)
|
||||
val viewHolder = findViewHolderForLayoutPosition(itemPos)
|
||||
|
@ -196,6 +307,7 @@ class EditListActivity : EditActivity(Type.LIST), MoreListActions {
|
|||
override fun setColor() {
|
||||
super.setColor()
|
||||
adapter?.setBackgroundColor(colorInt)
|
||||
adapterChecked?.setBackgroundColor(colorInt)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -21,10 +21,12 @@ 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
|
||||
|
@ -38,7 +40,9 @@ import com.philkes.notallyx.presentation.activity.note.PickNoteActivity.Companio
|
|||
import com.philkes.notallyx.presentation.activity.note.PickNoteActivity.Companion.EXTRA_PICKED_NOTE_TYPE
|
||||
import com.philkes.notallyx.presentation.add
|
||||
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.showKeyboard
|
||||
|
@ -65,8 +69,6 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
|
|||
override fun configureUI() {
|
||||
binding.EnterTitle.setOnNextAction { binding.EnterBody.requestFocus() }
|
||||
|
||||
setupEditor()
|
||||
|
||||
if (notallyModel.isNewNote) {
|
||||
binding.EnterBody.requestFocus()
|
||||
}
|
||||
|
@ -78,6 +80,17 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
|
|||
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 {
|
||||
|
@ -172,78 +185,8 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
|
|||
|
||||
private fun setupEditor() {
|
||||
setupMovementMethod()
|
||||
|
||||
binding.EnterBody.customSelectionActionModeCallback =
|
||||
object : ActionMode.Callback {
|
||||
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?) = false
|
||||
|
||||
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?) = false
|
||||
|
||||
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
|
||||
// ActionMode implementation
|
||||
try {
|
||||
menu?.apply {
|
||||
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(StyleSpan(Typeface.BOLD))
|
||||
mode?.finish()
|
||||
}
|
||||
add(R.string.italic, 0, showAsAction = MenuItem.SHOW_AS_ACTION_NEVER) {
|
||||
binding.EnterBody.applySpan(StyleSpan(Typeface.ITALIC))
|
||||
mode?.finish()
|
||||
}
|
||||
add(
|
||||
R.string.monospace,
|
||||
0,
|
||||
showAsAction = MenuItem.SHOW_AS_ACTION_NEVER,
|
||||
) {
|
||||
binding.EnterBody.applySpan(TypefaceSpan("monospace"))
|
||||
mode?.finish()
|
||||
}
|
||||
add(
|
||||
R.string.strikethrough,
|
||||
0,
|
||||
showAsAction = MenuItem.SHOW_AS_ACTION_NEVER,
|
||||
) {
|
||||
binding.EnterBody.applySpan(StrikethroughSpan())
|
||||
mode?.finish()
|
||||
}
|
||||
add(
|
||||
R.string.clear_formatting,
|
||||
0,
|
||||
showAsAction = MenuItem.SHOW_AS_ACTION_NEVER,
|
||||
) {
|
||||
binding.EnterBody.clearFormatting()
|
||||
mode?.finish()
|
||||
}
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
exception.printStackTrace()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onDestroyActionMode(mode: ActionMode?) {
|
||||
binding.EnterBody.isActionModeOn = false
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
|
@ -255,8 +198,54 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
|
|||
// ActionMode implementation
|
||||
try {
|
||||
menu?.apply {
|
||||
add(R.string.link_note, 0, order = Menu.CATEGORY_CONTAINER + 1) {
|
||||
linkNote(pickNoteNewActivityResultLauncher)
|
||||
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.italic,
|
||||
0,
|
||||
showAsAction = MenuItem.SHOW_AS_ACTION_NEVER,
|
||||
) {
|
||||
binding.EnterBody.applySpan(StyleSpan(Typeface.ITALIC))
|
||||
mode?.finish()
|
||||
}
|
||||
add(
|
||||
R.string.monospace,
|
||||
0,
|
||||
showAsAction = MenuItem.SHOW_AS_ACTION_NEVER,
|
||||
) {
|
||||
binding.EnterBody.applySpan(TypefaceSpan("monospace"))
|
||||
mode?.finish()
|
||||
}
|
||||
add(
|
||||
R.string.strikethrough,
|
||||
0,
|
||||
showAsAction = MenuItem.SHOW_AS_ACTION_NEVER,
|
||||
) {
|
||||
binding.EnterBody.applySpan(StrikethroughSpan())
|
||||
mode?.finish()
|
||||
}
|
||||
add(
|
||||
R.string.clear_formatting,
|
||||
0,
|
||||
showAsAction = MenuItem.SHOW_AS_ACTION_NEVER,
|
||||
) {
|
||||
binding.EnterBody.clearFormatting()
|
||||
mode?.finish()
|
||||
}
|
||||
}
|
||||
|
@ -270,26 +259,69 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
|
|||
binding.EnterBody.isActionModeOn = false
|
||||
}
|
||||
}
|
||||
} else null
|
||||
|
||||
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
|
||||
|
||||
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?) = false
|
||||
|
||||
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
|
||||
// ActionMode implementation
|
||||
try {
|
||||
menu?.apply {
|
||||
add(
|
||||
R.string.link_note,
|
||||
0,
|
||||
order = Menu.CATEGORY_CONTAINER + 1,
|
||||
) {
|
||||
linkNote(pickNoteNewActivityResultLauncher)
|
||||
mode?.finish()
|
||||
}
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
exception.printStackTrace()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onDestroyActionMode(mode: ActionMode?) {
|
||||
binding.EnterBody.isActionModeOn = false
|
||||
}
|
||||
}
|
||||
} else null
|
||||
}
|
||||
binding.EnterBody.setOnSelectionChange { selStart, selEnd ->
|
||||
if (selEnd - selStart > 0) {
|
||||
if (!textFormatMenu.isEnabled) {
|
||||
initBottomTextFormattingMenu()
|
||||
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
|
||||
}
|
||||
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()
|
||||
setSelection(length())
|
||||
showKeyboard(this)
|
||||
if (canEdit) {
|
||||
setSelection(length())
|
||||
showKeyboard(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -326,7 +358,7 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
|
|||
|
||||
updateLayoutParams<LinearLayout.LayoutParams> {
|
||||
marginEnd = 0
|
||||
marginStart = 10.dp(context)
|
||||
marginStart = 10.dp
|
||||
}
|
||||
setControlsContrastColorForAllViews(extractColor)
|
||||
setBackgroundColor(0)
|
||||
|
@ -340,7 +372,7 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
|
|||
}
|
||||
requestLayout()
|
||||
val layout = BottomTextFormattingMenuBinding.inflate(layoutInflater, this, false)
|
||||
layout.RecyclerView.apply {
|
||||
layout.MainListView.apply {
|
||||
textFormattingAdapter =
|
||||
TextFormattingAdapter(this@EditNoteActivity, binding.EnterBody, colorInt)
|
||||
adapter = textFormattingAdapter
|
||||
|
@ -366,19 +398,23 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
|
|||
val movementMethod = LinkMovementMethod { span ->
|
||||
val items =
|
||||
if (span.url.isNoteUrl()) {
|
||||
arrayOf(
|
||||
getString(R.string.remove_link),
|
||||
getString(R.string.change_note),
|
||||
getString(R.string.edit),
|
||||
getString(R.string.open_note),
|
||||
)
|
||||
if (canEdit) {
|
||||
arrayOf(
|
||||
getString(R.string.open_note),
|
||||
getString(R.string.remove_link),
|
||||
getString(R.string.change_note),
|
||||
getString(R.string.edit),
|
||||
)
|
||||
} else arrayOf(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),
|
||||
)
|
||||
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(
|
||||
|
@ -390,35 +426,16 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
|
|||
)
|
||||
.setItems(items) { _, which ->
|
||||
when (which) {
|
||||
0 -> {
|
||||
binding.EnterBody.removeSpanWithHistory(
|
||||
span,
|
||||
span.url.isNoteUrl() ||
|
||||
span.url == binding.EnterBody.getSpanText(span),
|
||||
)
|
||||
}
|
||||
0 -> openLink(span)
|
||||
1 ->
|
||||
if (span.url.isNoteUrl()) {
|
||||
selectedSpan = span
|
||||
linkNote(pickNoteUpdateActivityResultLauncher)
|
||||
} else {
|
||||
copyToClipBoard(span.url)
|
||||
showToast(R.string.copied_link)
|
||||
}
|
||||
|
||||
2 -> {
|
||||
binding.EnterBody.showEditDialog(span)
|
||||
}
|
||||
|
||||
3 -> {
|
||||
span.url?.let {
|
||||
if (it.isNoteUrl()) {
|
||||
span.navigateToNote()
|
||||
} else {
|
||||
openLink(span.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
removeLink(span)
|
||||
} else copyLink(span)
|
||||
2 ->
|
||||
if (span.url.isNoteUrl()) {
|
||||
changeNoteLink(span)
|
||||
} else removeLink(span)
|
||||
3 -> editLink(span)
|
||||
}
|
||||
}
|
||||
.show()
|
||||
|
@ -426,6 +443,37 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
|
|||
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).wrapWithChooser(this)
|
||||
|
|
|
@ -51,14 +51,15 @@ open class PickNoteActivity : LockedActivity<ActivityPickNoteBinding>(), ItemLis
|
|||
maxItems.value,
|
||||
maxLines.value,
|
||||
maxTitle.value,
|
||||
labelsHiddenInOverview.value,
|
||||
labelTagsHiddenInOverview.value,
|
||||
imagesHiddenInOverview.value,
|
||||
),
|
||||
application.getExternalImagesDirectory(),
|
||||
this@PickNoteActivity,
|
||||
)
|
||||
}
|
||||
|
||||
binding.RecyclerView.apply {
|
||||
binding.MainListView.apply {
|
||||
adapter = this@PickNoteActivity.adapter
|
||||
setHasFixedSize(true)
|
||||
layoutManager =
|
||||
|
@ -71,6 +72,7 @@ open class PickNoteActivity : LockedActivity<ActivityPickNoteBinding>(), ItemLis
|
|||
|
||||
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 {
|
||||
|
@ -78,7 +80,7 @@ open class PickNoteActivity : LockedActivity<ActivityPickNoteBinding>(), ItemLis
|
|||
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 =
|
||||
|
|
|
@ -3,7 +3,6 @@ package com.philkes.notallyx.presentation.activity.note
|
|||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.viewModels
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
|
@ -17,12 +16,9 @@ import com.philkes.notallyx.presentation.setCancelButton
|
|||
import com.philkes.notallyx.presentation.showAndFocus
|
||||
import com.philkes.notallyx.presentation.showToast
|
||||
import com.philkes.notallyx.presentation.view.main.label.SelectableLabelAdapter
|
||||
import com.philkes.notallyx.presentation.viewmodel.LabelModel
|
||||
|
||||
class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
|
||||
|
||||
private val model: LabelModel by viewModels()
|
||||
|
||||
private lateinit var selectedLabels: ArrayList<String>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
@ -88,7 +84,7 @@ class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
binding.RecyclerView.apply {
|
||||
binding.MainListView.apply {
|
||||
setHasFixedSize(true)
|
||||
adapter = labelAdapter
|
||||
addItemDecoration(
|
||||
|
|
|
@ -66,15 +66,15 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
|
|||
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(EXTRA_POSITION, 0)
|
||||
binding.RecyclerView.scrollToPosition(initial)
|
||||
binding.MainListView.scrollToPosition(initial)
|
||||
|
||||
val database = NotallyDatabase.getDatabase(application)
|
||||
val id = intent.getLongExtra(EXTRA_SELECTED_BASE_NOTE, 0)
|
||||
|
@ -88,7 +88,7 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
|
|||
|
||||
val mediaRoot = application.getExternalImagesDirectory()
|
||||
val adapter = ImageAdapter(mediaRoot, images)
|
||||
binding.RecyclerView.adapter = adapter
|
||||
binding.MainListView.adapter = adapter
|
||||
setupToolbar(binding, adapter)
|
||||
}
|
||||
}
|
||||
|
@ -112,7 +112,7 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
|
|||
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() {
|
||||
|
||||
|
@ -123,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) {
|
||||
|
|
|
@ -102,7 +102,7 @@ class RemindersActivity : LockedActivity<ActivityRemindersBinding>(), ReminderLi
|
|||
|
||||
private fun setupRecyclerView() {
|
||||
reminderAdapter = ReminderAdapter(this)
|
||||
binding.RecyclerView.apply {
|
||||
binding.MainListView.apply {
|
||||
initListView(this@RemindersActivity)
|
||||
adapter = reminderAdapter
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ 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
|
||||
|
@ -99,7 +100,9 @@ class BaseNoteAdapter(
|
|||
NotesSortBy.TITLE -> BaseNoteTitleSort(this@BaseNoteAdapter, sortDirection)
|
||||
NotesSortBy.MODIFIED_DATE ->
|
||||
BaseNoteModifiedDateSort(this@BaseNoteAdapter, sortDirection)
|
||||
else -> BaseNoteCreationDateSort(this@BaseNoteAdapter, sortDirection)
|
||||
NotesSortBy.CREATION_DATE ->
|
||||
BaseNoteCreationDateSort(this@BaseNoteAdapter, sortDirection)
|
||||
NotesSortBy.COLOR -> BaseNoteColorSort(this@BaseNoteAdapter, sortDirection)
|
||||
}
|
||||
|
||||
private fun replaceSortCallback(sortCallback: SortedListAdapterCallback<Item>) {
|
||||
|
|
|
@ -6,7 +6,6 @@ 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
|
||||
|
@ -21,7 +20,6 @@ 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
|
||||
|
@ -33,7 +31,6 @@ 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.getColorFromAttr
|
||||
import com.philkes.notallyx.presentation.getQuantityString
|
||||
import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews
|
||||
import com.philkes.notallyx.presentation.view.misc.ItemListener
|
||||
|
@ -48,6 +45,7 @@ data class BaseNoteVHPreferences(
|
|||
val maxLines: Int,
|
||||
val maxTitleLines: Int,
|
||||
val hideLabels: Boolean,
|
||||
val hideImages: Boolean,
|
||||
)
|
||||
|
||||
class BaseNoteVH(
|
||||
|
@ -83,22 +81,11 @@ class BaseNoteVH(
|
|||
}
|
||||
}
|
||||
|
||||
fun updateCheck(checked: Boolean, color: Color) {
|
||||
if (binding.root.isChecked != checked) {
|
||||
if (checked) {
|
||||
binding.root.apply {
|
||||
strokeColor = context.getColorFromAttr(androidx.appcompat.R.attr.colorPrimary)
|
||||
strokeWidth = 3.dp(context)
|
||||
}
|
||||
} else {
|
||||
binding.root.apply {
|
||||
strokeColor =
|
||||
if (color == Color.DEFAULT)
|
||||
ContextCompat.getColor(context, R.color.chip_stroke)
|
||||
else 0
|
||||
strokeWidth = 1.dp(context)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
@ -108,7 +95,7 @@ class BaseNoteVH(
|
|||
|
||||
when (baseNote.type) {
|
||||
Type.NOTE -> bindNote(baseNote.body, baseNote.spans, baseNote.title.isEmpty())
|
||||
Type.LIST -> bindList(baseNote.items)
|
||||
Type.LIST -> bindList(baseNote.items, baseNote.title.isEmpty())
|
||||
}
|
||||
val (date, datePrefixResId) =
|
||||
when (sortBy) {
|
||||
|
@ -127,8 +114,7 @@ class BaseNoteVH(
|
|||
isVisible = baseNote.title.isNotEmpty()
|
||||
updatePadding(
|
||||
bottom =
|
||||
if (baseNote.hasNoContents() || shouldOnlyDisplayTitle(baseNote)) 0
|
||||
else 8.dp(context)
|
||||
if (baseNote.hasNoContents() || shouldOnlyDisplayTitle(baseNote)) 0 else 8.dp
|
||||
)
|
||||
setCompoundDrawablesWithIntrinsicBounds(
|
||||
if (baseNote.type == Type.LIST && preferences.maxItems < 1)
|
||||
|
@ -175,14 +161,15 @@ class BaseNoteVH(
|
|||
}
|
||||
}
|
||||
|
||||
private fun bindList(items: List<ListItem>) {
|
||||
private fun bindList(items: List<ListItem>, isTitleEmpty: Boolean) {
|
||||
binding.apply {
|
||||
Note.visibility = GONE
|
||||
if (items.isEmpty()) {
|
||||
LinearLayout.visibility = GONE
|
||||
} else {
|
||||
LinearLayout.visibility = VISIBLE
|
||||
val filteredList = items.take(preferences.maxItems)
|
||||
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) {
|
||||
|
@ -193,9 +180,12 @@ class BaseNoteVH(
|
|||
visibility = VISIBLE
|
||||
if (item.isChild) {
|
||||
updateLayoutParams<LinearLayout.LayoutParams> {
|
||||
marginStart = 20.dp(context)
|
||||
marginStart = 20.dp
|
||||
}
|
||||
}
|
||||
if (index == filteredList.lastIndex) {
|
||||
updatePadding(bottom = 0)
|
||||
}
|
||||
}
|
||||
} else view.visibility = GONE
|
||||
}
|
||||
|
@ -211,26 +201,17 @@ class BaseNoteVH(
|
|||
}
|
||||
}
|
||||
|
||||
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)
|
||||
setControlsContrastColorForAllViews(context.getColorFromAttr(R.attr.colorSurface))
|
||||
} else {
|
||||
strokeColor = 0
|
||||
val colorInt = context.extractColor(color)
|
||||
setCardBackgroundColor(colorInt)
|
||||
setControlsContrastColorForAllViews(colorInt)
|
||||
}
|
||||
val colorInt = context.extractColor(color)
|
||||
setCardBackgroundColor(colorInt)
|
||||
setControlsContrastColorForAllViews(colorInt)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setImages(images: List<FileAttachment>, mediaRoot: File?) {
|
||||
|
||||
binding.apply {
|
||||
if (images.isNotEmpty()) {
|
||||
if (images.isNotEmpty() && !preferences.hideImages) {
|
||||
ImageView.visibility = VISIBLE
|
||||
Message.visibility = GONE
|
||||
|
||||
|
|
|
@ -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.misc.ItemListener
|
||||
|
||||
class ColorAdapter(private val listener: ItemListener) : RecyclerView.Adapter<ColorVH>() {
|
||||
|
||||
private val colors = Color.entries.toTypedArray()
|
||||
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,9 +1,15 @@
|
|||
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.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: ItemListener) :
|
||||
|
@ -11,11 +17,40 @@ class ColorVH(private val binding: RecyclerColorBinding, listener: ItemListener)
|
|||
|
||||
init {
|
||||
binding.CardView.setOnClickListener { listener.onClick(absoluteAdapterPosition) }
|
||||
binding.CardView.setOnLongClickListener {
|
||||
listener.onLongClick(absoluteAdapterPosition)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(color: Color) {
|
||||
val value = binding.root.context.extractColor(color)
|
||||
binding.CardView.setCardBackgroundColor(value)
|
||||
binding.CardView.contentDescription = color.name
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
|
@ -5,10 +5,12 @@ import com.philkes.notallyx.data.model.BaseNote
|
|||
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)
|
||||
|
|
|
@ -5,10 +5,12 @@ import com.philkes.notallyx.data.model.BaseNote
|
|||
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)
|
||||
|
|
|
@ -5,10 +5,12 @@ import com.philkes.notallyx.data.model.BaseNote
|
|||
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)
|
||||
|
|
|
@ -7,7 +7,7 @@ import com.philkes.notallyx.data.model.Header
|
|||
import com.philkes.notallyx.data.model.Item
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection
|
||||
|
||||
abstract class BaseNoteSort(
|
||||
abstract class ItemSort(
|
||||
adapter: RecyclerView.Adapter<*>?,
|
||||
private val sortDirection: SortDirection,
|
||||
) : SortedListAdapterCallback<Item>(adapter) {
|
|
@ -1,17 +1,21 @@
|
|||
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)
|
||||
|
@ -30,33 +34,60 @@ open class EditTextWithWatcher(context: Context, attrs: AttributeSet) :
|
|||
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 super.getText()
|
||||
return getTextSafe()
|
||||
}
|
||||
|
||||
fun getTextClone(): Editable {
|
||||
return super.getText()!!.clone()
|
||||
return getTextSafe().clone()
|
||||
}
|
||||
|
||||
fun applyWithoutTextWatcher(
|
||||
callback: EditTextWithWatcher.() -> Unit
|
||||
): Pair<Editable, Editable> {
|
||||
val textBefore = super.getText()!!.clone()
|
||||
val textBefore = getTextClone()
|
||||
val editTextWatcher = textWatcher
|
||||
editTextWatcher?.let { removeTextChangedListener(it) }
|
||||
callback()
|
||||
editTextWatcher?.let { addTextChangedListener(it) }
|
||||
return Pair(textBefore, super.getText()!!.clone())
|
||||
return Pair(textBefore, getTextClone())
|
||||
}
|
||||
|
||||
fun changeText(callback: (text: Editable) -> Unit): Pair<Editable, Editable> {
|
||||
return applyWithoutTextWatcher { callback(super.getText()!!) }
|
||||
return applyWithoutTextWatcher { callback(getTextSafe()!!) }
|
||||
}
|
||||
|
||||
private fun getTextSafe() = super.getText() ?: Editable.Factory.getInstance().newEditable("")
|
||||
|
||||
fun focusAndSelect(
|
||||
start: Int = selectionStart,
|
||||
end: Int = selectionEnd,
|
||||
|
|
|
@ -24,9 +24,9 @@ class TextWithIconAdapter<T>(
|
|||
}
|
||||
val item = getItem(position)!!
|
||||
setCompoundDrawablesRelativeWithIntrinsicBounds(getIconResId(item), 0, 0, 0)
|
||||
setPaddingRelative(30.dp(context), paddingTop, paddingEnd, paddingBottom)
|
||||
setPaddingRelative(30.dp, paddingTop, paddingEnd, paddingBottom)
|
||||
text = getText(item)
|
||||
compoundDrawablePadding = 10.dp(context)
|
||||
compoundDrawablePadding = 10.dp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -49,7 +49,7 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
|
|||
ContextCompat.getDrawable(context, R.drawable.checkbox_partial)
|
||||
|
||||
init {
|
||||
compoundDrawablePadding = 4.dp(context)
|
||||
compoundDrawablePadding = 4.dp
|
||||
buttonDrawable = getCurrentDrawable()
|
||||
setOnClickListener { toggleState() }
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ fun MaterialAlertDialogBuilder.setMultiChoiceTriStateItems(
|
|||
val recyclerView =
|
||||
RecyclerView(context).apply {
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
setPadding(0, 8.dp(context), 0, 0)
|
||||
setPadding(0, 8.dp, 0, 0)
|
||||
this.adapter = adapter
|
||||
}
|
||||
setView(recyclerView)
|
||||
|
|
|
@ -8,6 +8,7 @@ import android.text.style.TypefaceSpan
|
|||
import android.text.style.URLSpan
|
||||
import androidx.annotation.ColorInt
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.presentation.createBoldSpan
|
||||
import com.philkes.notallyx.presentation.view.misc.StylableEditTextWithHistory
|
||||
|
||||
class TextFormattingAdapter(
|
||||
|
@ -35,7 +36,7 @@ class TextFormattingAdapter(
|
|||
private val bold: Toggle =
|
||||
Toggle(R.string.bold, R.drawable.format_bold, false) {
|
||||
if (!it.checked) {
|
||||
editText.applySpan(StyleSpan(Typeface.BOLD))
|
||||
editText.applySpan(createBoldSpan())
|
||||
} else {
|
||||
editText.clearFormatting(type = StylableEditTextWithHistory.TextStyleType.BOLD)
|
||||
}
|
||||
|
|
|
@ -8,10 +8,12 @@ import android.view.ViewGroup
|
|||
import android.widget.LinearLayout
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.widget.NestedScrollView
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.databinding.BottomSheetActionBinding
|
||||
import com.philkes.notallyx.presentation.dp
|
||||
import com.philkes.notallyx.presentation.getColorFromAttr
|
||||
|
@ -25,41 +27,52 @@ open class ActionBottomSheet(
|
|||
@ColorInt private val color: Int? = null,
|
||||
) : BottomSheetDialogFragment() {
|
||||
|
||||
lateinit var layout: LinearLayout
|
||||
lateinit var inflater: LayoutInflater
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View {
|
||||
val view =
|
||||
this.inflater = inflater
|
||||
val scrollView =
|
||||
NestedScrollView(requireContext()).apply {
|
||||
layoutParams =
|
||||
ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
isFillViewport = true
|
||||
}
|
||||
layout =
|
||||
LinearLayout(context).apply {
|
||||
layoutParams =
|
||||
LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(8.dp(context), 18.dp(context), 8.dp(context), 8.dp(context))
|
||||
setPadding(8.dp, 18.dp, 8.dp, 8.dp)
|
||||
}
|
||||
|
||||
scrollView.addView(layout)
|
||||
actions.forEach { action ->
|
||||
if (action.showDividerAbove) {
|
||||
val divider =
|
||||
View(context).apply {
|
||||
layoutParams =
|
||||
LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 1)
|
||||
.apply {
|
||||
setMargins(8.dp(context), 0, 8.dp(context), 8.dp(context))
|
||||
}
|
||||
.apply { setMargins(8.dp, 0, 8.dp, 8.dp) }
|
||||
setBackgroundColor(
|
||||
context.getColorFromAttr(
|
||||
com.google.android.material.R.attr.colorOnSurfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
view.addView(divider)
|
||||
layout.addView(divider)
|
||||
}
|
||||
val textView =
|
||||
BottomSheetActionBinding.inflate(inflater, view, false).root.apply {
|
||||
BottomSheetActionBinding.inflate(inflater, layout, false).root.apply {
|
||||
text = getString(action.labelResId)
|
||||
setCompoundDrawablesWithIntrinsicBounds(
|
||||
ContextCompat.getDrawable(context, action.drawableResId),
|
||||
|
@ -68,34 +81,62 @@ open class ActionBottomSheet(
|
|||
null,
|
||||
)
|
||||
setOnClickListener {
|
||||
action.callback()
|
||||
hide()
|
||||
if (action.callback(this@ActionBottomSheet)) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
view.addView(textView)
|
||||
color?.let {
|
||||
view.apply {
|
||||
setBackgroundColor(it)
|
||||
setControlsContrastColorForAllViews(it, overwriteBackground = false)
|
||||
}
|
||||
}
|
||||
layout.addView(textView)
|
||||
}
|
||||
|
||||
return view
|
||||
color?.let {
|
||||
layout.apply {
|
||||
setBackgroundColor(it)
|
||||
setControlsContrastColorForAllViews(it, overwriteBackground = false)
|
||||
}
|
||||
}
|
||||
return scrollView
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val dialog = BottomSheetDialog(requireContext(), R.style.ThemeOverlay_App_BottomSheetDialog)
|
||||
val dialog =
|
||||
BottomSheetDialog(
|
||||
requireContext(),
|
||||
com.philkes.notallyx.R.style.ThemeOverlay_App_BottomSheetDialog,
|
||||
)
|
||||
color?.let {
|
||||
dialog.window?.apply {
|
||||
navigationBarColor = it
|
||||
setLightStatusAndNavBar(it.isLightColor())
|
||||
}
|
||||
}
|
||||
dialog.setOnShowListener {
|
||||
dialog.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)?.let {
|
||||
bottomSheet ->
|
||||
BottomSheetBehavior.from(bottomSheet).apply {
|
||||
state = BottomSheetBehavior.STATE_EXPANDED
|
||||
isHideable = false
|
||||
// Disable dragging changes to allow nested scroll
|
||||
setBottomSheetCallback(
|
||||
object : BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
if (newState == BottomSheetBehavior.STATE_HIDDEN) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) {
|
||||
state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return dialog
|
||||
}
|
||||
|
||||
private fun BottomSheetDialogFragment.hide() {
|
||||
fun hide() {
|
||||
(dialog as? BottomSheetDialog)?.behavior?.state = STATE_HIDDEN
|
||||
}
|
||||
}
|
||||
|
@ -104,5 +145,10 @@ data class Action(
|
|||
val labelResId: Int,
|
||||
val drawableResId: Int,
|
||||
val showDividerAbove: Boolean = false,
|
||||
val callback: () -> Unit,
|
||||
/**
|
||||
* On click callback.
|
||||
*
|
||||
* @returns whether or not the BottomSheet should be hidden
|
||||
*/
|
||||
val callback: (actionBottomSheet: ActionBottomSheet) -> Boolean,
|
||||
)
|
||||
|
|
|
@ -13,13 +13,20 @@ class AddBottomSheet(callbacks: AddActions, @ColorInt color: Int?) :
|
|||
|
||||
fun createActions(callbacks: AddActions) =
|
||||
listOf(
|
||||
Action(R.string.add_images, R.drawable.add_images) { callbacks.addImages() },
|
||||
Action(R.string.attach_file, R.drawable.text_file) { callbacks.attachFiles() },
|
||||
Action(R.string.add_images, R.drawable.add_images) { _ ->
|
||||
callbacks.addImages()
|
||||
true
|
||||
},
|
||||
Action(R.string.attach_file, R.drawable.text_file) { _ ->
|
||||
callbacks.attachFiles()
|
||||
true
|
||||
},
|
||||
) +
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
|
||||
listOf(
|
||||
Action(R.string.record_audio, R.drawable.record_audio) {
|
||||
Action(R.string.record_audio, R.drawable.record_audio) { _ ->
|
||||
callbacks.recordAudio()
|
||||
true
|
||||
}
|
||||
)
|
||||
else listOf()
|
||||
|
|
|
@ -12,7 +12,12 @@ class AddNoteBottomSheet(callbacks: AddNoteActions, @ColorInt color: Int?) :
|
|||
|
||||
fun createActions(callbacks: AddNoteActions) =
|
||||
AddBottomSheet.createActions(callbacks) +
|
||||
listOf(Action(R.string.link_note, R.drawable.notebook) { callbacks.linkNote() })
|
||||
listOf(
|
||||
Action(R.string.link_note, R.drawable.notebook) { _ ->
|
||||
callbacks.linkNote()
|
||||
true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -20,14 +20,17 @@ class MoreListBottomSheet(
|
|||
R.string.delete_checked_items,
|
||||
R.drawable.delete_all,
|
||||
showDividerAbove = true,
|
||||
) {
|
||||
) { _ ->
|
||||
callbacks.deleteChecked()
|
||||
true
|
||||
},
|
||||
Action(R.string.check_all_items, R.drawable.checkbox_checked) {
|
||||
Action(R.string.check_all_items, R.drawable.checkbox_checked) { _ ->
|
||||
callbacks.checkAll()
|
||||
true
|
||||
},
|
||||
Action(R.string.uncheck_all_items, R.drawable.checkbox_unchecked) {
|
||||
Action(R.string.uncheck_all_items, R.drawable.checkbox_unchecked) { _ ->
|
||||
callbacks.uncheckAll()
|
||||
true
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ package com.philkes.notallyx.presentation.view.note.action
|
|||
|
||||
import androidx.annotation.ColorInt
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.databinding.BottomSheetActionBinding
|
||||
import com.philkes.notallyx.presentation.viewmodel.ExportMimeType
|
||||
|
||||
/** BottomSheet inside list-note for all common note actions. */
|
||||
class MoreNoteBottomSheet(
|
||||
|
@ -15,12 +17,38 @@ class MoreNoteBottomSheet(
|
|||
|
||||
internal fun createActions(callbacks: MoreActions, additionalActions: Collection<Action>) =
|
||||
listOf(
|
||||
Action(R.string.share, R.drawable.share) { callbacks.share() },
|
||||
Action(R.string.change_color, R.drawable.change_color) { callbacks.changeColor() },
|
||||
Action(R.string.reminders, R.drawable.notifications) {
|
||||
callbacks.changeReminders()
|
||||
Action(R.string.share, R.drawable.share) { _ ->
|
||||
callbacks.share()
|
||||
true
|
||||
},
|
||||
Action(R.string.export, R.drawable.export) { fragment ->
|
||||
fragment.layout.removeAllViews()
|
||||
ExportMimeType.entries.forEach { mimeType ->
|
||||
BottomSheetActionBinding.inflate(fragment.inflater, fragment.layout, true)
|
||||
.root
|
||||
.apply {
|
||||
text = mimeType.name
|
||||
setCompoundDrawablesWithIntrinsicBounds(null, null, null, null)
|
||||
setOnClickListener {
|
||||
callbacks.export(mimeType)
|
||||
fragment.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
},
|
||||
Action(R.string.change_color, R.drawable.change_color) { _ ->
|
||||
callbacks.changeColor()
|
||||
true
|
||||
},
|
||||
Action(R.string.reminders, R.drawable.notifications) { _ ->
|
||||
callbacks.changeReminders()
|
||||
true
|
||||
},
|
||||
Action(R.string.labels, R.drawable.label) { _ ->
|
||||
callbacks.changeLabels()
|
||||
true
|
||||
},
|
||||
Action(R.string.labels, R.drawable.label) { callbacks.changeLabels() },
|
||||
) + additionalActions
|
||||
}
|
||||
}
|
||||
|
@ -28,6 +56,8 @@ class MoreNoteBottomSheet(
|
|||
interface MoreActions {
|
||||
fun share()
|
||||
|
||||
fun export(mimeType: ExportMimeType)
|
||||
|
||||
fun changeColor()
|
||||
|
||||
fun changeReminders()
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
package com.philkes.notallyx.presentation.view.note.listitem
|
||||
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.adapter.ListItemHighlight
|
||||
|
||||
interface HighlightText {
|
||||
fun highlightText(highlight: ListItemHighlight)
|
||||
}
|
|
@ -0,0 +1,326 @@
|
|||
package com.philkes.notallyx.presentation.view.note.listitem
|
||||
|
||||
import androidx.recyclerview.widget.SortedList
|
||||
import com.philkes.notallyx.data.model.ListItem
|
||||
import com.philkes.notallyx.data.model.deepCopy
|
||||
import com.philkes.notallyx.data.model.findChild
|
||||
import com.philkes.notallyx.data.model.plus
|
||||
import com.philkes.notallyx.data.model.shouldParentBeChecked
|
||||
import com.philkes.notallyx.data.model.shouldParentBeUnchecked
|
||||
import com.philkes.notallyx.utils.filter
|
||||
import com.philkes.notallyx.utils.forEach
|
||||
import com.philkes.notallyx.utils.indices
|
||||
import com.philkes.notallyx.utils.map
|
||||
import com.philkes.notallyx.utils.mapIndexed
|
||||
|
||||
fun List<ListItem>.shiftItemOrders(orderRange: IntRange, valueToAdd: Int) {
|
||||
this.forEach { it.shiftOrder(orderRange, valueToAdd) }
|
||||
}
|
||||
|
||||
fun SortedList<ListItem>.shiftItemOrders(orderRange: IntRange, valueToAdd: Int) {
|
||||
forEach { it.shiftOrder(orderRange, valueToAdd) }
|
||||
}
|
||||
|
||||
private fun ListItem.shiftOrder(orderRange: IntRange, valueToAdd: Int) {
|
||||
if (order!! in orderRange) {
|
||||
order = order!! + valueToAdd
|
||||
}
|
||||
}
|
||||
|
||||
fun List<ListItem>.shiftItemOrdersHigher(threshold: Int, valueToAdd: Int) {
|
||||
this.forEach { it.shiftOrderHigher(threshold, valueToAdd) }
|
||||
}
|
||||
|
||||
fun SortedList<ListItem>.shiftItemOrdersHigher(threshold: Int, valueToAdd: Int) {
|
||||
this.forEach { it.shiftOrderHigher(threshold, valueToAdd) }
|
||||
}
|
||||
|
||||
private fun ListItem.shiftOrderHigher(threshold: Int, valueToAdd: Int) {
|
||||
if (order!! > threshold) {
|
||||
order = order!! + valueToAdd
|
||||
}
|
||||
}
|
||||
|
||||
fun List<ListItem>.shiftItemOrdersBetween(
|
||||
thresholdMin: Int,
|
||||
thresholdMax: Int,
|
||||
valueToAdd: Int,
|
||||
excludeParent: ListItem? = null,
|
||||
) {
|
||||
this.forEach {
|
||||
if (
|
||||
it.order!! in (thresholdMin + 1 until thresholdMax) &&
|
||||
excludeParent?.let(it::isChildOf) != true
|
||||
) {
|
||||
it.order = it.order!! + valueToAdd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun SortedList<ListItem>.shiftItemOrdersBetween(
|
||||
thresholdMin: Int,
|
||||
thresholdMax: Int,
|
||||
valueToAdd: Int,
|
||||
excludeParent: ListItem? = null,
|
||||
) {
|
||||
this.forEach {
|
||||
if (
|
||||
it.order!! in (thresholdMin + 1 until thresholdMax) &&
|
||||
excludeParent?.let(it::isChildOf) != true
|
||||
) {
|
||||
it.order = it.order!! + valueToAdd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun SortedList<ListItem>.toMutableList(): MutableList<ListItem> {
|
||||
return indices.map { this[it] }.toMutableList()
|
||||
}
|
||||
|
||||
fun List<ListItem>.cloneList(): MutableList<ListItem> {
|
||||
val clone = this.indices.map { this[it].clone() as ListItem }.toMutableList()
|
||||
clone.forEach { itemClone ->
|
||||
itemClone.children =
|
||||
itemClone.children.map { child -> clone.first { it.id == child.id } }.toMutableList()
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
fun SortedList<ListItem>.toReadableString(): String {
|
||||
return map { "$it order: ${it.order} id: ${it.id}" }.joinToString("\n")
|
||||
}
|
||||
|
||||
fun SortedList<ListItem>.findParent(childItem: ListItem): Pair<Int, ListItem>? {
|
||||
this.indices.forEach {
|
||||
if (this[it].findChild(childItem.id) != null) {
|
||||
return Pair(it, this[it])
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun List<ListItem>.findParent(childItem: ListItem): Pair<Int, ListItem>? {
|
||||
this.indices.forEach {
|
||||
if (this[it].findChild(childItem.id) != null) {
|
||||
return Pair(it, this[it])
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun List<ListItem>.findInsertIdx(item: ListItem): Int {
|
||||
return indexOfFirst { it.order!! > item.order!! }.let { if (it < 0) size else it }
|
||||
}
|
||||
|
||||
fun List<ListItem>.firstBodyOrEmptyString() = firstOrNull()?.body ?: ""
|
||||
|
||||
fun MutableList<ListItem>.removeWithChildren(item: ListItem): Pair<Int, Int> {
|
||||
val index = indexOf(item)
|
||||
removeAll(item.children + item)
|
||||
return Pair(index, item.children.size + 1)
|
||||
}
|
||||
|
||||
fun MutableList<ListItem>.addWithChildren(item: ListItem): Pair<Int, Int> {
|
||||
val insertIdx = findInsertIdx(item)
|
||||
addAll(insertIdx, item + item.children)
|
||||
return Pair(insertIdx, item.children.size + 1)
|
||||
}
|
||||
|
||||
fun SortedList<ListItem>.addWithChildren(item: ListItem) {
|
||||
addAll(item + item.children)
|
||||
}
|
||||
|
||||
fun SortedList<ListItem>.removeWithChildren(item: ListItem) {
|
||||
(item.children + item).forEach { remove(it) }
|
||||
}
|
||||
|
||||
fun SortedList<ListItem>.setItems(list: MutableList<ListItem>) {
|
||||
clear()
|
||||
val (children, parents) = list.partition { it.isChild }
|
||||
// Need to use replaceAll for auto-sorting checked items
|
||||
replaceAll(parents.toTypedArray(), false)
|
||||
addAll(children.toTypedArray(), false)
|
||||
}
|
||||
|
||||
fun List<ListItem>.splitByChecked(): Pair<List<ListItem>, List<ListItem>> = partition {
|
||||
it.checked && (!it.isChild || findParent(it)?.second?.children?.areAllChecked() == true)
|
||||
}
|
||||
|
||||
fun <R> List<R>.getOrNull(index: Int) = if (lastIndex >= index) this[index] else null
|
||||
|
||||
fun Collection<ListItem>.init(resetIds: Boolean = true): List<ListItem> {
|
||||
val initializedItems = deepCopy()
|
||||
initList(initializedItems, resetIds)
|
||||
checkBrokenList(initializedItems)
|
||||
return initializedItems
|
||||
}
|
||||
|
||||
private fun checkBrokenList(list: List<ListItem>) {
|
||||
list.forEach { listItem ->
|
||||
if (listItem.shouldParentBeChecked()) {
|
||||
listItem.checked = true
|
||||
} else if (listItem.shouldParentBeUnchecked()) {
|
||||
listItem.checked = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initList(list: List<ListItem>, resetIds: Boolean) {
|
||||
if (resetIds) {
|
||||
list.forEachIndexed { index, item -> item.id = index }
|
||||
}
|
||||
initOrders(list)
|
||||
initChildren(list)
|
||||
}
|
||||
|
||||
private fun initChildren(list: List<ListItem>) {
|
||||
list.forEach { it.children.clear() }
|
||||
var parent: ListItem? = null
|
||||
list.forEach { item ->
|
||||
if (item.isChild && parent != null) {
|
||||
parent!!.children.add(item)
|
||||
} else {
|
||||
item.isChild = false
|
||||
parent = item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Makes sure every [ListItem.order] is valid and correct */
|
||||
private fun initOrders(list: List<ListItem>): Boolean {
|
||||
var orders = list.map { it.order }.toMutableList()
|
||||
var invalidOrderFound = false
|
||||
list.forEachIndexed { idx, item ->
|
||||
if (item.order == null || orders.count { it == idx } > 1) {
|
||||
invalidOrderFound = true
|
||||
if (orders.contains(idx)) {
|
||||
shiftAllOrdersAfterItem(list, item)
|
||||
}
|
||||
item.order = idx
|
||||
orders = list.map { it.order }.toMutableList()
|
||||
}
|
||||
}
|
||||
return invalidOrderFound
|
||||
}
|
||||
|
||||
private fun shiftAllOrdersAfterItem(list: List<ListItem>, item: ListItem) {
|
||||
// Move all orders after the item to ensure no duplicate orders
|
||||
val sortedByOrders = list.sortedBy { it.order }
|
||||
val position = sortedByOrders.indexOfFirst { it.id == item.id }
|
||||
for (i in position + 1..sortedByOrders.lastIndex) {
|
||||
sortedByOrders[i].order = sortedByOrders[i].order!! + 1
|
||||
}
|
||||
}
|
||||
|
||||
fun List<ListItem>.areAllChecked(except: ListItem? = null): Boolean {
|
||||
return this.none { !it.checked && (except == null || it.id != except.id) }
|
||||
}
|
||||
|
||||
fun MutableList<ListItem>.containsId(id: Int): Boolean {
|
||||
return this.any { it.id == id }
|
||||
}
|
||||
|
||||
fun Collection<ListItem>.toReadableString(): String {
|
||||
return map { "$it order: ${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 Collection<ListItem>.printList(text: String? = null) {
|
||||
text?.let { print("--------------\n$it\n") }
|
||||
println("--------------")
|
||||
println(toReadableString())
|
||||
println("--------------")
|
||||
}
|
||||
|
||||
fun Collection<ListItem>.findParentsByChecked(checked: Boolean): List<ListItem> {
|
||||
return filter { !it.isChild && it.checked == checked }.distinct()
|
||||
}
|
||||
|
||||
fun SortedList<ListItem>.findParentsByChecked(checked: Boolean): List<ListItem> {
|
||||
return filter { !it.isChild && it.checked == checked }.distinct()
|
||||
}
|
||||
|
||||
fun SortedList<ListItem>.deleteCheckedItems() {
|
||||
mapIndexed { index, listItem -> Pair(index, listItem) }
|
||||
.filter { it.second.checked }
|
||||
.sortedBy { !it.second.isChild }
|
||||
.forEach { remove(it.second) }
|
||||
}
|
||||
|
||||
fun MutableList<ListItem>.deleteCheckedItems(): Set<Int> {
|
||||
return mapIndexed { index, listItem -> Pair(index, listItem) }
|
||||
.filter { it.second.checked }
|
||||
.sortedBy { it.second.isChild }
|
||||
.onEach { remove(it.second) }
|
||||
.map { it.first }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* Find correct parent for `childPosition` and update it's `children`.
|
||||
*
|
||||
* @return Correct parent
|
||||
*/
|
||||
fun List<ListItem>.refreshParent(childPosition: Int): ListItem? {
|
||||
val item = this[childPosition]
|
||||
findParent(item)?.let { (pos, parent) -> parent.children.removeWithChildren(item) }
|
||||
return findParentPosition(childPosition)?.let { parentPos ->
|
||||
val parent = this[parentPos]
|
||||
val children = parent.children
|
||||
val childIndex = children.findInsertIdx(item)
|
||||
children.addAll(childIndex, item + item.children)
|
||||
item.children.clear()
|
||||
parent
|
||||
}
|
||||
}
|
||||
|
||||
fun SortedList<ListItem>.removeFromParent(child: ListItem): ListItem? {
|
||||
if (!child.isChild) {
|
||||
return null
|
||||
}
|
||||
return findParent(child)?.second?.also { it.children.remove(child) }
|
||||
}
|
||||
|
||||
fun List<ListItem>.removeFromParent(child: ListItem): ListItem? {
|
||||
if (!child.isChild) {
|
||||
return null
|
||||
}
|
||||
return findParent(child)?.second?.also { it.children.remove(child) }
|
||||
}
|
||||
|
||||
fun MutableList<ListItem>.addToParent(childPosition: Int) {
|
||||
findParentPosition(childPosition)?.let { parentPos ->
|
||||
this[parentPos].children.add(childPosition - parentPos - 1, this[childPosition])
|
||||
}
|
||||
}
|
||||
|
||||
fun MutableList<ListItem>.removeChildrenBelowPositionFromParent(
|
||||
parentPosition: Int,
|
||||
thresholdPosition: Int,
|
||||
): List<ListItem> {
|
||||
val children = this[parentPosition].children
|
||||
val childrenBelow =
|
||||
children.filterIndexed { idx, _ -> parentPosition + idx + 1 > thresholdPosition - 1 }
|
||||
children.removeAll(childrenBelow)
|
||||
return childrenBelow
|
||||
}
|
|
@ -8,17 +8,19 @@ import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
|||
import com.philkes.notallyx.data.model.ListItem
|
||||
|
||||
/** ItemTouchHelper.Callback that allows dragging ListItem with its children. */
|
||||
class ListItemDragCallback(private val elevation: Float, private val listManager: ListManager) :
|
||||
class ListItemDragCallback(private val elevation: Float, internal val listManager: ListManager) :
|
||||
ItemTouchHelper.Callback() {
|
||||
|
||||
private var lastState = ItemTouchHelper.ACTION_STATE_IDLE
|
||||
private var lastIsCurrentlyActive = false
|
||||
private var childViewHolders: List<ViewHolder> = mutableListOf()
|
||||
|
||||
private var draggedItem: ListItem? = null
|
||||
private var itemsBefore: List<ListItem>? = null
|
||||
private var stateBefore: ListState? = null
|
||||
private var positionFrom: Int? = null
|
||||
private var parentBefore: ListItem? = null
|
||||
private var itemCount: Int? = null
|
||||
private var positionTo: Int? = null
|
||||
|
||||
private var newPosition: Int? = null
|
||||
|
||||
override fun isLongPressDragEnabled() = false
|
||||
|
@ -41,26 +43,21 @@ class ListItemDragCallback(private val elevation: Float, private val listManager
|
|||
|
||||
internal fun move(from: Int, to: Int): Boolean {
|
||||
if (positionFrom == null) {
|
||||
draggedItem = listManager.getItem(from).clone() as ListItem
|
||||
itemsBefore = listManager.getItems()
|
||||
positionFrom = from
|
||||
stateBefore = listManager.getState(selectedPos = from)
|
||||
val item = listManager.getItem(from)
|
||||
parentBefore = if (item.isChild) listManager.findParent(item)?.second else null
|
||||
}
|
||||
val swapped = listManager.move(from, to, false, false, isDrag = true)
|
||||
if (swapped != null) {
|
||||
if (positionFrom == null) {
|
||||
positionFrom = from
|
||||
}
|
||||
positionTo = to
|
||||
newPosition = swapped
|
||||
val (positionTo, itemCount) = listManager.move(from, to)
|
||||
if (positionTo != -1) {
|
||||
this.itemCount = itemCount
|
||||
this.positionTo = positionTo
|
||||
}
|
||||
return swapped != null
|
||||
return positionTo != -1
|
||||
}
|
||||
|
||||
override fun onSelectedChanged(viewHolder: ViewHolder?, actionState: Int) {
|
||||
if (
|
||||
lastState != actionState &&
|
||||
actionState == ItemTouchHelper.ACTION_STATE_IDLE &&
|
||||
positionTo != -1
|
||||
) {
|
||||
if (lastState != actionState && actionState == ItemTouchHelper.ACTION_STATE_IDLE) {
|
||||
onDragEnd()
|
||||
}
|
||||
lastState = actionState
|
||||
|
@ -98,8 +95,7 @@ class ListItemDragCallback(private val elevation: Float, private val listManager
|
|||
childViewHolders.forEach { animateFadeIn(it) }
|
||||
}
|
||||
|
||||
internal fun onDragStart(viewHolder: ViewHolder, recyclerView: RecyclerView) {
|
||||
Log.d(TAG, "onDragStart")
|
||||
private fun onDragStart(viewHolder: ViewHolder, recyclerView: RecyclerView) {
|
||||
reset()
|
||||
if (viewHolder.absoluteAdapterPosition == -1) {
|
||||
return
|
||||
|
@ -116,31 +112,25 @@ class ListItemDragCallback(private val elevation: Float, private val listManager
|
|||
}
|
||||
}
|
||||
|
||||
internal fun onDragEnd() {
|
||||
Log.d(TAG, "onDragEnd: from: $positionFrom to: $positionTo")
|
||||
if (positionTo != null && positionTo != -1 && stateBefore != null) {
|
||||
// The items have already been moved accordingly via move() calls
|
||||
listManager.finishMove(
|
||||
positionTo!!,
|
||||
itemCount!!,
|
||||
parentBefore,
|
||||
stateBefore!!,
|
||||
pushChange = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun reset() {
|
||||
positionFrom = null
|
||||
positionTo = null
|
||||
newPosition = null
|
||||
draggedItem = null
|
||||
itemsBefore = null
|
||||
}
|
||||
|
||||
internal fun onDragEnd() {
|
||||
Log.d(TAG, "onDragEnd: from: $positionFrom to: $positionTo")
|
||||
if (positionFrom == positionTo) {
|
||||
return
|
||||
}
|
||||
if (newPosition != null && itemsBefore != null) {
|
||||
// The items have already been moved accordingly via move() calls
|
||||
listManager.finishMove(
|
||||
positionFrom!!,
|
||||
positionTo!!,
|
||||
newPosition!!,
|
||||
itemsBefore!!,
|
||||
updateIsChild = true,
|
||||
updateChildren = true,
|
||||
pushChange = true,
|
||||
)
|
||||
}
|
||||
stateBefore = null
|
||||
}
|
||||
|
||||
private fun animateFadeOut(viewHolder: ViewHolder) {
|
||||
|
@ -152,6 +142,6 @@ class ListItemDragCallback(private val elevation: Float, private val listManager
|
|||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DragCallback"
|
||||
private const val TAG = "ListItemDragCallback"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,41 +1,41 @@
|
|||
package com.philkes.notallyx.presentation.view.note.listitem
|
||||
|
||||
import android.text.TextWatcher
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.EditText
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.philkes.notallyx.data.model.ListItem
|
||||
import com.philkes.notallyx.data.model.areAllChecked
|
||||
import com.philkes.notallyx.data.model.check
|
||||
import com.philkes.notallyx.data.model.findChild
|
||||
import com.philkes.notallyx.data.model.plus
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.ListItemSortedList
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.cloneList
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.deleteItem
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.filter
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.findById
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.findParent
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.isNotEmpty
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.lastIndex
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.moveItemRange
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.reversed
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.setChecked
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.setCheckedWithChildren
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.setIsChild
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.shiftItemOrders
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.toReadableString
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.ListItemSort
|
||||
import com.philkes.notallyx.data.model.shouldParentBeChecked
|
||||
import com.philkes.notallyx.data.model.shouldParentBeUnchecked
|
||||
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.ListItemVH
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.SortedItemsList
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.autoSortByCheckedEnabled
|
||||
import com.philkes.notallyx.utils.changehistory.ChangeCheckedForAllChange
|
||||
import com.philkes.notallyx.utils.changehistory.ChangeHistory
|
||||
import com.philkes.notallyx.utils.changehistory.DeleteCheckedChange
|
||||
import com.philkes.notallyx.utils.changehistory.EditTextState
|
||||
import com.philkes.notallyx.utils.changehistory.ListAddChange
|
||||
import com.philkes.notallyx.utils.changehistory.ListBatchChange
|
||||
import com.philkes.notallyx.utils.changehistory.ListCheckedChange
|
||||
import com.philkes.notallyx.utils.changehistory.ListDeleteChange
|
||||
import com.philkes.notallyx.utils.changehistory.ListEditTextChange
|
||||
import com.philkes.notallyx.utils.changehistory.ListIsChildChange
|
||||
import com.philkes.notallyx.utils.changehistory.ListMoveChange
|
||||
import com.philkes.notallyx.utils.lastIndex
|
||||
|
||||
data class ListState(
|
||||
val items: MutableList<ListItem>,
|
||||
val checkedItems: MutableList<ListItem>?,
|
||||
val focusedItemPos: Int? = null,
|
||||
val cursorPos: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Should be used for all changes to the items list. Notifies the [RecyclerView.Adapter] and pushes
|
||||
|
@ -49,33 +49,104 @@ class ListManager(
|
|||
private val endSearch: (() -> Unit)?,
|
||||
val refreshSearch: ((refocusView: View?) -> Unit)?,
|
||||
) {
|
||||
|
||||
lateinit var adapter: ListItemAdapter
|
||||
var checkedAdapter: CheckedListItemAdapter? = null
|
||||
private var nextItemId: Int = 0
|
||||
private lateinit var items: ListItemSortedList
|
||||
internal lateinit var adapter: RecyclerView.Adapter<ListItemVH>
|
||||
private val items: MutableList<ListItem>
|
||||
get() = adapter.items
|
||||
|
||||
private var itemsChecked: SortedItemsList? = null
|
||||
private var batchChangeBeforeState: ListState? = null
|
||||
|
||||
fun init(
|
||||
adapter: ListItemAdapter,
|
||||
itemsChecked: SortedItemsList? = null,
|
||||
adapterChecked: CheckedListItemAdapter? = null,
|
||||
) {
|
||||
this.adapter = adapter
|
||||
this.itemsChecked = itemsChecked
|
||||
this.checkedAdapter = adapterChecked
|
||||
nextItemId = this.items.size + (this.itemsChecked?.size() ?: 0)
|
||||
Log.d(TAG, "initList:\n${this.items.toReadableString()}")
|
||||
this.itemsChecked?.let { Log.d(TAG, "itemsChecked:\n${it}") }
|
||||
}
|
||||
|
||||
internal fun getState(selectedPos: Int? = null): ListState {
|
||||
val (pos, cursorPos) = recyclerView.getFocusedPositionAndCursor()
|
||||
return ListState(
|
||||
items.cloneList(),
|
||||
itemsChecked?.toMutableList()?.cloneList(),
|
||||
selectedPos ?: pos,
|
||||
cursorPos,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun setState(state: ListState) {
|
||||
adapter.submitList(state.items) {
|
||||
state.focusedItemPos?.let { itemPos -> focusItem(itemPos, state.cursorPos) }
|
||||
}
|
||||
this.itemsChecked?.setItems(state.checkedItems!!)
|
||||
}
|
||||
|
||||
private fun focusItem(itemPos: Int, cursorPos: Int?) {
|
||||
// Focus item's EditText and set cursor position
|
||||
recyclerView.post {
|
||||
if (itemPos in 0..items.size) {
|
||||
recyclerView.smoothScrollToPosition(itemPos)
|
||||
(recyclerView.findViewHolderForAdapterPosition(itemPos) as? ListItemVH?)?.let {
|
||||
viewHolder ->
|
||||
inputMethodManager?.let { inputManager ->
|
||||
val maxCursorPos = viewHolder.binding.EditText.length()
|
||||
viewHolder.focusEditText(
|
||||
selectionStart = cursorPos?.coerceIn(0, maxCursorPos) ?: maxCursorPos,
|
||||
inputMethodManager = inputManager,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun add(
|
||||
position: Int = items.size(),
|
||||
item: ListItem = defaultNewItem(position),
|
||||
position: Int = items.size,
|
||||
item: ListItem = defaultNewItem(position.coerceAtMost(items.size)),
|
||||
pushChange: Boolean = true,
|
||||
) {
|
||||
endSearch?.invoke()
|
||||
val stateBefore = getState()
|
||||
(item + item.children).forEach { setIdIfUnset(it) }
|
||||
val itemBeforeInsert = item.clone() as ListItem
|
||||
|
||||
items.beginBatchedUpdates()
|
||||
for ((idx, newItem) in (item + item.children).withIndex()) {
|
||||
addItem(position + idx, newItem)
|
||||
val insertOrder =
|
||||
if (position < 1) {
|
||||
0
|
||||
} else if (position <= items.lastIndex) {
|
||||
items[position - 1].order!! + 1
|
||||
} else {
|
||||
items.lastOrNull()?.let { it.order!! + 1 } ?: 0
|
||||
}
|
||||
shiftItemOrdersHigher(insertOrder - 1, 1 + item.children.size)
|
||||
item.order = insertOrder
|
||||
item.children.forEachIndexed { index, child -> child.order = insertOrder + 1 + index }
|
||||
|
||||
val parentPos =
|
||||
if (position <= items.lastIndex && items[position].isChild) {
|
||||
findParent(items[position])?.first
|
||||
} else null
|
||||
|
||||
val (insertPos, count) = items.addWithChildren(item)
|
||||
if (item.isChild) {
|
||||
items.addToParent(insertPos)
|
||||
} else if (parentPos != null) {
|
||||
val childrenBelow = items.removeChildrenBelowPositionFromParent(parentPos, insertPos)
|
||||
item.children.addAll(childrenBelow)
|
||||
}
|
||||
items.endBatchedUpdates()
|
||||
|
||||
adapter.notifyItemRangeInserted(insertPos, count)
|
||||
items.notifyPreviousFirstItem(insertPos, count)
|
||||
if (pushChange) {
|
||||
changeHistory.push(ListAddChange(position, item.id, itemBeforeInsert, this))
|
||||
changeHistory.push(ListAddChange(stateBefore, getState(selectedPos = insertPos), this))
|
||||
}
|
||||
val positionAfterAdd = items.findById(item.id)!!.first
|
||||
|
||||
recyclerView.post {
|
||||
val viewHolder =
|
||||
recyclerView.findViewHolderForAdapterPosition(positionAfterAdd) as ListItemVH?
|
||||
val viewHolder = recyclerView.findViewHolderForAdapterPosition(insertPos) as ListItemVH?
|
||||
if (!item.checked && viewHolder != null) {
|
||||
inputMethodManager?.let { viewHolder.focusEditText(inputMethodManager = it) }
|
||||
}
|
||||
|
@ -93,228 +164,192 @@ class ListManager(
|
|||
*/
|
||||
fun delete(
|
||||
position: Int = items.lastIndex,
|
||||
inCheckedList: Boolean = false,
|
||||
force: Boolean = true,
|
||||
childrenToDelete: List<ListItem>? = null,
|
||||
pushChange: Boolean = true,
|
||||
allowFocusChange: Boolean = true,
|
||||
): ListItem? {
|
||||
endSearch?.invoke()
|
||||
if (position < 0 || position > items.lastIndex) {
|
||||
return null
|
||||
): Boolean {
|
||||
// TODO
|
||||
// endSearch?.invoke()
|
||||
val stateBefore = getState()
|
||||
val items = this.items.toMutableList()
|
||||
var result = false
|
||||
if (position.isValidPosition(forCheckedList = inCheckedList)) {
|
||||
return false
|
||||
}
|
||||
var item: ListItem? = null
|
||||
if (force || position > 0) {
|
||||
item = items.deleteItem(position, childrenToDelete)
|
||||
val item = getItem(position, inCheckedList)
|
||||
shiftItemOrdersHigher(item.order!! - 1, 1 + item.children.size, items = items)
|
||||
if (inCheckedList) {
|
||||
itemsChecked!!.removeFromParent(item)
|
||||
itemsChecked!!.removeWithChildren(item)
|
||||
} else {
|
||||
val parent = items.removeFromParent(item)
|
||||
parent?.updateParentChecked(items)
|
||||
items.removeWithChildren(item)
|
||||
}
|
||||
result = true
|
||||
adapter.submitList(items)
|
||||
}
|
||||
if (!force && allowFocusChange) {
|
||||
if (position > 0) {
|
||||
this.moveFocusToNext(position - 2)
|
||||
} else if (items.size() > 1) {
|
||||
} else if (items.size > 1) {
|
||||
this.moveFocusToNext(position)
|
||||
}
|
||||
}
|
||||
if (item != null && pushChange) {
|
||||
changeHistory.push(ListDeleteChange(item.order!!, item, this))
|
||||
if (pushChange && result) {
|
||||
changeHistory.push(ListDeleteChange(stateBefore, getState(), this))
|
||||
}
|
||||
return item
|
||||
return result
|
||||
}
|
||||
|
||||
fun deleteById(
|
||||
itemId: Int,
|
||||
force: Boolean = true,
|
||||
childrenToDelete: List<ListItem>? = null,
|
||||
pushChange: Boolean = true,
|
||||
allowFocusChange: Boolean = true,
|
||||
): ListItem? {
|
||||
return delete(
|
||||
items.findById(itemId)!!.first,
|
||||
force,
|
||||
childrenToDelete,
|
||||
pushChange,
|
||||
allowFocusChange,
|
||||
)
|
||||
}
|
||||
|
||||
/** @return position of the moved item afterwards */
|
||||
fun move(
|
||||
positionFrom: Int,
|
||||
positionTo: Int,
|
||||
pushChange: Boolean = true,
|
||||
updateChildren: Boolean = true,
|
||||
isDrag: Boolean = false,
|
||||
): Int? {
|
||||
endSearch?.invoke()
|
||||
val itemTo = items[positionTo]
|
||||
val itemFrom = items[positionFrom]
|
||||
// val itemBeforeMove = itemFrom.clone() as ListItem
|
||||
val itemsBeforeMove = getItems()
|
||||
// Disallow move unchecked item under any checked item (if auto-sort enabled)
|
||||
if (isAutoSortByCheckedEnabled() && itemTo.checked || itemTo.isChildOf(itemFrom)) {
|
||||
return null
|
||||
/** @return position of the moved item afterwards and the moved item count. */
|
||||
fun move(positionFrom: Int, positionTo: Int): Pair<Int, Int> {
|
||||
val itemsCheckedBefore = itemsChecked?.toMutableList()?.cloneList()
|
||||
val list = items.toMutableList()
|
||||
val movedItem = list[positionFrom]
|
||||
// Do not allow to move parent into its own children
|
||||
if (
|
||||
!movedItem.isChild &&
|
||||
positionTo in (positionFrom..positionFrom + movedItem.children.size)
|
||||
) {
|
||||
return Pair(-1, -1)
|
||||
}
|
||||
val checkChildPosition = if (positionTo < positionFrom) positionTo - 1 else positionTo
|
||||
val forceIsChild =
|
||||
when {
|
||||
isDrag -> null
|
||||
positionTo == 0 && itemFrom.isChild -> false
|
||||
itemFrom.isChild -> true // if child is moved parent could change
|
||||
updateChildren && checkChildPosition.isBeforeChildItemOfOtherParent -> true
|
||||
else -> null
|
||||
|
||||
val itemCount = 1 + movedItem.children.size
|
||||
val isMoveUpwards = positionFrom < positionTo
|
||||
|
||||
val fromOrder = list[positionFrom].order!!
|
||||
val toOrder = list[positionTo].order!!
|
||||
val insertOrder = if (isMoveUpwards) toOrder - itemCount + 1 else toOrder
|
||||
val (orderRange, valueToAdd) =
|
||||
if (isMoveUpwards) {
|
||||
Pair(fromOrder + itemCount until toOrder + 1, -itemCount)
|
||||
} else {
|
||||
Pair(toOrder until fromOrder, itemCount)
|
||||
}
|
||||
shiftItemOrders(orderRange, valueToAdd, items = list)
|
||||
itemsCheckedBefore?.shiftItemOrders(orderRange, valueToAdd)
|
||||
|
||||
val newPosition =
|
||||
items.moveItemRange(
|
||||
positionFrom,
|
||||
itemFrom.itemCount,
|
||||
positionTo,
|
||||
forceIsChild = forceIsChild,
|
||||
) ?: return null
|
||||
list.removeFromParent(movedItem)
|
||||
list.removeWithChildren(movedItem)
|
||||
|
||||
finishMove(
|
||||
positionFrom,
|
||||
positionTo,
|
||||
newPosition,
|
||||
itemsBeforeMove,
|
||||
updateIsChild = false,
|
||||
updateChildren = false,
|
||||
pushChange,
|
||||
)
|
||||
return newPosition
|
||||
(movedItem + movedItem.children).forEachIndexed { index, item ->
|
||||
item.order = insertOrder + index
|
||||
}
|
||||
val (insertIdx, count) = list.addWithChildren(movedItem)
|
||||
adapter.submitList(list)
|
||||
return Pair(insertIdx, count)
|
||||
}
|
||||
|
||||
/** Finishes a drag movement by updating [ListItem.isChild] accordingly. */
|
||||
fun finishMove(
|
||||
positionFrom: Int,
|
||||
positionTo: Int,
|
||||
newPosition: Int,
|
||||
itemsBeforeMove: List<ListItem>,
|
||||
updateIsChild: Boolean,
|
||||
updateChildren: Boolean,
|
||||
count: Int,
|
||||
parentBefore: ListItem?,
|
||||
stateBefore: ListState,
|
||||
pushChange: Boolean,
|
||||
) {
|
||||
if (updateIsChild) {
|
||||
if (newPosition.isBeforeChildItemOfOtherParent) {
|
||||
items.setIsChild(newPosition, isChild = true, forceOnChildren = true)
|
||||
} else if (newPosition == 0) {
|
||||
items.setIsChild(newPosition, false)
|
||||
}
|
||||
val item = items[positionTo]
|
||||
val itemBelow = items.getOrNull(positionTo + count)
|
||||
val forceIsChild = itemBelow?.isChild == true && !item.isChild
|
||||
val positionFrom = stateBefore.items.indexOfFirst { it.id == item.id }
|
||||
var isChildChanged = false
|
||||
if (positionTo == 0) {
|
||||
item.isChild = false
|
||||
items.notifyPreviousFirstItem(0, count)
|
||||
isChildChanged = true
|
||||
} else if (forceIsChild) {
|
||||
item.isChild = true
|
||||
isChildChanged = true
|
||||
}
|
||||
val item = items[newPosition]
|
||||
if (updateChildren) {
|
||||
val forceValue = item.isChild
|
||||
items.forceItemIsChild(item, forceValue, resetBefore = true)
|
||||
items.updateItemAt(items.findById(item.id)!!.first, item)
|
||||
} else if (item.isChild && newPosition > 0) {
|
||||
items.removeChildFromParent(item)
|
||||
items.updateChildInParent(newPosition, item)
|
||||
if (positionFrom == 0) {
|
||||
adapter.notifyItemChanged(0)
|
||||
isChildChanged = true
|
||||
}
|
||||
|
||||
if (item.isChild) {
|
||||
items.refreshParent(positionTo)?.updateParentChecked()
|
||||
}
|
||||
parentBefore?.updateParentChecked()
|
||||
if (isChildChanged) {
|
||||
adapter.notifyItemChanged(positionTo)
|
||||
}
|
||||
if (pushChange) {
|
||||
changeHistory.push(ListMoveChange(positionFrom, itemsBeforeMove, getItems(), this))
|
||||
changeHistory.push(ListMoveChange(stateBefore, getState(), this))
|
||||
}
|
||||
}
|
||||
|
||||
fun setItems(items: List<ListItem>) {
|
||||
this.items.init(items)
|
||||
}
|
||||
|
||||
fun changeText(
|
||||
editText: EditText,
|
||||
listener: TextWatcher,
|
||||
position: Int,
|
||||
value: EditTextState,
|
||||
before: EditTextState? = null,
|
||||
pushChange: Boolean = true,
|
||||
) {
|
||||
fun changeText(position: Int, value: EditTextState, pushChange: Boolean = true) {
|
||||
val stateBefore = getState()
|
||||
// if(!pushChange) {
|
||||
endSearch?.invoke()
|
||||
// }
|
||||
val item = items[position]
|
||||
item.body = value.text.toString()
|
||||
if (pushChange) {
|
||||
changeHistory.push(
|
||||
ListEditTextChange(editText, position, before!!, value, listener, this)
|
||||
)
|
||||
changeHistory.push(ListEditTextChange(stateBefore, getState(), this))
|
||||
// TODO: fix focus change
|
||||
|
||||
// refreshSearch?.invoke(editText)
|
||||
}
|
||||
}
|
||||
|
||||
fun changeChecked(position: Int, checked: Boolean, pushChange: Boolean = true) {
|
||||
val before = getItems()
|
||||
val item = items[position]
|
||||
fun changeChecked(
|
||||
position: Int,
|
||||
checked: Boolean,
|
||||
inCheckedList: Boolean = false,
|
||||
pushChange: Boolean = true,
|
||||
) {
|
||||
val beforeState = getState()
|
||||
val item = getItem(position, inCheckedList)
|
||||
if (item.checked == checked) {
|
||||
return
|
||||
}
|
||||
if (item.isChild) {
|
||||
changeCheckedForChild(checked, item, pushChange, position, before)
|
||||
return
|
||||
}
|
||||
items.setCheckedWithChildren(position, checked)
|
||||
if (pushChange) {
|
||||
changeHistory.push(ListCheckedChange(before, getItems(), this))
|
||||
}
|
||||
}
|
||||
|
||||
private fun changeCheckedForChild(
|
||||
checked: Boolean,
|
||||
item: ListItem,
|
||||
pushChange: Boolean,
|
||||
position: Int,
|
||||
before: List<ListItem>,
|
||||
) {
|
||||
var actualPosition = position
|
||||
val (parentPosition, parent) = items.findParent(item)!!
|
||||
if (!checked) {
|
||||
// If a child is being unchecked and the parent was checked, the parent gets unchecked
|
||||
// too
|
||||
if (parent.checked) {
|
||||
items.setChecked(parentPosition, false, recalcChildrenPositions = true)
|
||||
actualPosition = items.findById(item.id)!!.first
|
||||
}
|
||||
}
|
||||
items.setChecked(actualPosition, checked)
|
||||
if (parent.children.areAllChecked() && !parent.checked) {
|
||||
items.setChecked(parentPosition, true, recalcChildrenPositions = true)
|
||||
changeCheckedChild(position, item, checked, inCheckedList)
|
||||
} else {
|
||||
changeCheckedParent(item, checked, changeChildren = true)
|
||||
}
|
||||
if (pushChange) {
|
||||
changeHistory.push(ListCheckedChange(before, getItems(), this))
|
||||
changeHistory.push(ListCheckedChange(beforeState, getState(), this))
|
||||
}
|
||||
}
|
||||
|
||||
fun changeCheckedForAll(checked: Boolean, pushChange: Boolean = true) {
|
||||
val parentIds = mutableListOf<Int>()
|
||||
val changedIds = mutableListOf<Int>()
|
||||
items
|
||||
.reversed() // have to start from the bottom upwards, otherwise sort order will be wrong
|
||||
.forEach { item ->
|
||||
if (!item.isChild) {
|
||||
parentIds.add(item.id)
|
||||
}
|
||||
if (item.checked != checked) {
|
||||
changedIds.add(item.id)
|
||||
}
|
||||
}
|
||||
parentIds.forEach {
|
||||
val (position, _) = items.findById(it)!!
|
||||
changeChecked(position, checked, pushChange = false)
|
||||
}
|
||||
val stateBefore = getState()
|
||||
val parents =
|
||||
items.findParentsByChecked(!checked) +
|
||||
(itemsChecked?.findParentsByChecked(!checked) ?: listOf())
|
||||
parents.forEach { parent -> changeCheckedParent(parent, checked, true) }
|
||||
if (pushChange) {
|
||||
changeHistory.push(ChangeCheckedForAllChange(checked, changedIds, this))
|
||||
changeHistory.push(ChangeCheckedForAllChange(stateBefore, getState(), this))
|
||||
}
|
||||
}
|
||||
|
||||
fun checkByIds(
|
||||
checked: Boolean,
|
||||
ids: Collection<Int>,
|
||||
recalcChildrenPositions: Boolean = false,
|
||||
): Pair<List<Int>, List<Int>> {
|
||||
return check(checked, ids.map { items.findById(it)!!.first }, recalcChildrenPositions)
|
||||
}
|
||||
|
||||
fun changeIsChild(position: Int, isChild: Boolean, pushChange: Boolean = true) {
|
||||
items.setIsChild(position, isChild)
|
||||
val stateBefore = getState()
|
||||
items.findParentPosition(position)?.let { parentPos ->
|
||||
val nearestParent = items[parentPos]
|
||||
val item = items[position]
|
||||
item.isChild = isChild
|
||||
if (isChild) {
|
||||
items.refreshParent(position)
|
||||
} else {
|
||||
nearestParent.children.apply {
|
||||
val childIndex = indexOf(item)
|
||||
val childrenBelow = filterIndexed { idx, _ -> idx > childIndex }
|
||||
removeAll(childrenBelow)
|
||||
remove(item)
|
||||
item.children = childrenBelow.toMutableList()
|
||||
}
|
||||
}
|
||||
item.updateParentChecked()
|
||||
nearestParent.updateParentChecked()
|
||||
}
|
||||
|
||||
if (pushChange) {
|
||||
changeHistory.push(ListIsChildChange(isChild, position, this))
|
||||
changeHistory.push(ListIsChildChange(stateBefore, getState(), this))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -329,70 +364,168 @@ class ListManager(
|
|||
|
||||
fun deleteCheckedItems(pushChange: Boolean = true) {
|
||||
endSearch?.invoke()
|
||||
val itemsToDelete =
|
||||
items.filter { it.checked }.map { it.clone() as ListItem }.sortedBy { it.isChild }
|
||||
items.beginBatchedUpdates()
|
||||
itemsToDelete
|
||||
.reversed() // delete children first so sorting works properly
|
||||
.forEach { items.deleteItem(it) }
|
||||
val deletedItems =
|
||||
itemsToDelete.toMutableList().filter { item ->
|
||||
// If a parent with its children was deleted, remove the children item
|
||||
// since DeleteCheckedChange uses listManager.add, which already adds the children
|
||||
// from parent.children list
|
||||
!(item.isChild &&
|
||||
itemsToDelete.any { parent -> parent.children.any { it.id == item.id } })
|
||||
}
|
||||
items.endBatchedUpdates()
|
||||
val stateBefore = getState()
|
||||
items.deleteCheckedItems().forEach { adapter.notifyItemRemoved(it) }
|
||||
itemsChecked?.deleteCheckedItems()
|
||||
if (pushChange) {
|
||||
changeHistory.push(DeleteCheckedChange(deletedItems, this))
|
||||
changeHistory.push(DeleteCheckedChange(stateBefore, getState(), this))
|
||||
}
|
||||
}
|
||||
|
||||
fun initList(items: ListItemSortedList) {
|
||||
this.items = items
|
||||
nextItemId = this.items.size()
|
||||
Log.d(TAG, "initList:\n${this.items.toReadableString()}")
|
||||
fun findParent(item: ListItem) = items.findParent(item) ?: itemsChecked?.findParent(item)
|
||||
|
||||
internal fun startBatchChange(cursorPos: Int? = null) {
|
||||
batchChangeBeforeState = getState()
|
||||
cursorPos?.let { batchChangeBeforeState = batchChangeBeforeState!!.copy(cursorPos = it) }
|
||||
}
|
||||
|
||||
internal fun getItem(position: Int): ListItem {
|
||||
return items[position]
|
||||
internal fun finishBatchChange(focusedItemPos: Int? = null) {
|
||||
batchChangeBeforeState?.let {
|
||||
val state =
|
||||
focusedItemPos?.let {
|
||||
getState().copy(focusedItemPos = focusedItemPos, cursorPos = null)
|
||||
} ?: getState()
|
||||
changeHistory.push(ListBatchChange(it, state, this))
|
||||
}
|
||||
}
|
||||
|
||||
internal fun getItems(): List<ListItem> = items.cloneList()
|
||||
internal fun getItem(position: Int, fromCheckedList: Boolean = false): ListItem {
|
||||
return if (fromCheckedList) itemsChecked!![position] else items[position]
|
||||
}
|
||||
|
||||
private fun RecyclerView.getFocusedPositionAndCursor(): Pair<Int?, Int?> {
|
||||
return focusedChild?.let { view ->
|
||||
val position = getChildAdapterPosition(view)
|
||||
if (position == RecyclerView.NO_POSITION) {
|
||||
return Pair(null, null)
|
||||
}
|
||||
val viewHolder = recyclerView.findViewHolderForAdapterPosition(position)
|
||||
val cursorPos = (viewHolder as? ListItemVH)?.binding?.EditText?.selectionStart
|
||||
return Pair(position, cursorPos)
|
||||
} ?: Pair(null, null)
|
||||
}
|
||||
|
||||
internal fun defaultNewItem(position: Int) =
|
||||
ListItem(
|
||||
"",
|
||||
false,
|
||||
items.isNotEmpty() &&
|
||||
((position < items.size() && items[position].isChild) ||
|
||||
((position < items.size && items[position].isChild) ||
|
||||
(position > 0 && items[position - 1].isChild)),
|
||||
null,
|
||||
mutableListOf(),
|
||||
nextItemId++,
|
||||
)
|
||||
|
||||
private fun check(
|
||||
private fun changeCheckedParent(
|
||||
parent: ListItem,
|
||||
checked: Boolean,
|
||||
positions: Collection<Int>,
|
||||
recalcChildrenPositions: Boolean = false,
|
||||
): Pair<List<Int>, List<Int>> {
|
||||
return items.setChecked(positions, checked, recalcChildrenPositions)
|
||||
changeChildren: Boolean,
|
||||
items: MutableList<ListItem> = this@ListManager.items,
|
||||
) {
|
||||
if (checked) {
|
||||
// A parent from unchecked is checked
|
||||
if (preferences.autoSortByCheckedEnabled) {
|
||||
checkWithAutoSort(parent, items)
|
||||
} else {
|
||||
parent.check(true, checkChildren = changeChildren)
|
||||
if (items == this@ListManager.items) {
|
||||
adapter.notifyListItemChanged(parent.id)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (preferences.autoSortByCheckedEnabled) {
|
||||
uncheckWithAutoSort(parent, uncheckChildren = changeChildren)
|
||||
} else {
|
||||
parent.check(false, checkChildren = changeChildren)
|
||||
if (items == this@ListManager.items) {
|
||||
adapter.notifyListItemChanged(parent.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addItem(position: Int, newItem: ListItem) {
|
||||
setIdIfUnset(newItem)
|
||||
items.shiftItemOrders(position until items.size(), 1)
|
||||
newItem.order = position
|
||||
val forceIsChild =
|
||||
when {
|
||||
position == 0 -> false
|
||||
(position - 1).isBeforeChildItemOfOtherParent -> true
|
||||
newItem.isChild && items.findParent(newItem) == null -> true
|
||||
else -> null
|
||||
private fun changeCheckedChild(
|
||||
position: Int,
|
||||
child: ListItem,
|
||||
checked: Boolean,
|
||||
inCheckedList: Boolean,
|
||||
) {
|
||||
if (checked) {
|
||||
child.checked = true
|
||||
adapter.notifyItemChanged(position)
|
||||
val (_, parent) = items.findParent(child)!!
|
||||
parent.updateParentChecked()
|
||||
} else {
|
||||
if (inCheckedList) {
|
||||
uncheckWithAutoSort(child)
|
||||
} else {
|
||||
child.checked = false
|
||||
adapter.notifyItemChanged(position)
|
||||
checkParent(child, false)
|
||||
}
|
||||
items.add(newItem, forceIsChild)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkWithAutoSort(
|
||||
parent: ListItem,
|
||||
items: MutableList<ListItem> = this@ListManager.items,
|
||||
) {
|
||||
val (pos, count) = items.removeWithChildren(parent)
|
||||
if (items == this@ListManager.items) {
|
||||
adapter.notifyItemRangeRemoved(pos, count)
|
||||
items.notifyPreviousFirstItem(pos, 0)
|
||||
}
|
||||
parent.check(true)
|
||||
itemsChecked!!.addWithChildren(parent)
|
||||
}
|
||||
|
||||
private fun uncheckWithAutoSort(
|
||||
item: ListItem,
|
||||
uncheckChildren: Boolean = true,
|
||||
items: MutableList<ListItem> = this@ListManager.items,
|
||||
) {
|
||||
if (item.isChild) {
|
||||
val (_, parent) = itemsChecked!!.findParent(item)!!
|
||||
itemsChecked!!.removeWithChildren(parent)
|
||||
parent.findChild(item.id)!!.checked = false
|
||||
parent.checked = false
|
||||
val (insertPos, count) = items.addWithChildren(parent)
|
||||
if (items == this@ListManager.items) {
|
||||
adapter.notifyItemRangeInserted(insertPos, count)
|
||||
items.notifyPreviousFirstItem(insertPos, count)
|
||||
}
|
||||
} else {
|
||||
itemsChecked!!.removeWithChildren(item)
|
||||
item.check(false, uncheckChildren)
|
||||
val (insertPos, count) = items.addWithChildren(item)
|
||||
if (items == this@ListManager.items) {
|
||||
adapter.notifyItemRangeInserted(insertPos, count)
|
||||
items.notifyPreviousFirstItem(insertPos, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ListItem.updateParentChecked(
|
||||
items: MutableList<ListItem> = this@ListManager.items
|
||||
) {
|
||||
if (isChild) {
|
||||
return
|
||||
}
|
||||
if (shouldParentBeChecked()) {
|
||||
changeCheckedParent(this, true, changeChildren = true, items = items)
|
||||
}
|
||||
if (shouldParentBeUnchecked()) {
|
||||
changeCheckedParent(this, false, changeChildren = false, items = items)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkParent(item: ListItem, checked: Boolean) {
|
||||
val (parentPos, parent) = items.findParent(item)!!
|
||||
if (parent.checked != checked) {
|
||||
parent.checked = checked
|
||||
adapter.notifyItemChanged(parentPos)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setIdIfUnset(newItem: ListItem) {
|
||||
|
@ -401,32 +534,36 @@ class ListManager(
|
|||
}
|
||||
}
|
||||
|
||||
private fun isAutoSortByCheckedEnabled() =
|
||||
preferences.listItemSorting.value == ListItemSort.AUTO_SORT_BY_CHECKED
|
||||
|
||||
private val Int.isBeforeChildItemOfOtherParent: Boolean
|
||||
get() {
|
||||
if (this < 0) {
|
||||
return false
|
||||
}
|
||||
val item = items[this]
|
||||
return item.isNextItemChild(this) && !items[this + item.itemCount].isChildOf(this)
|
||||
}
|
||||
|
||||
private val Int.isBeforeChildItem: Boolean
|
||||
get() {
|
||||
if (this < 0 || this > items.lastIndex - 1) {
|
||||
return false
|
||||
}
|
||||
return items[this + 1].isChild
|
||||
}
|
||||
|
||||
private fun ListItem.isNextItemChild(position: Int): Boolean {
|
||||
return (position < items.size() - itemCount) && (items[position + this.itemCount].isChild)
|
||||
/** Adds [valueToAdd] to all [ListItem.order] that are higher than [threshold] */
|
||||
private fun shiftItemOrdersHigher(
|
||||
threshold: Int,
|
||||
valueToAdd: Int,
|
||||
items: List<ListItem> = this.items,
|
||||
) {
|
||||
items.shiftItemOrdersHigher(threshold, valueToAdd)
|
||||
itemsChecked?.shiftItemOrdersHigher(threshold, valueToAdd)
|
||||
}
|
||||
|
||||
private fun ListItem.isChildOf(otherPosition: Int): Boolean {
|
||||
return isChildOf(items[otherPosition])
|
||||
/** Adds [valueToAdd] to all [ListItem.order] that are in [orderRange] */
|
||||
private fun shiftItemOrders(
|
||||
orderRange: IntRange,
|
||||
valueToAdd: Int,
|
||||
items: List<ListItem> = this.items,
|
||||
) {
|
||||
items.shiftItemOrders(orderRange, valueToAdd)
|
||||
itemsChecked?.shiftItemOrders(orderRange, valueToAdd)
|
||||
}
|
||||
|
||||
private fun MutableList<ListItem>.notifyPreviousFirstItem(position: Int, count: Int) {
|
||||
if (position == 0 && size > count) {
|
||||
// To trigger enabling isChild swiping for the item that was previously at pos 0
|
||||
adapter.notifyItemChanged(count)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.isValidPosition(forCheckedList: Boolean = false): Boolean {
|
||||
return this < 0 ||
|
||||
this > (if (forCheckedList) itemsChecked!!.lastIndex else items.lastIndex)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
package com.philkes.notallyx.presentation.view.note.listitem.adapter
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.widget.NestedScrollView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.SortedList
|
||||
import com.philkes.notallyx.data.model.ListItem
|
||||
import com.philkes.notallyx.data.model.NoteViewMode
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.HighlightText
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.ListManager
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.TextSize
|
||||
|
||||
class CheckedListItemAdapter(
|
||||
@ColorInt var backgroundColor: Int,
|
||||
private val textSize: TextSize,
|
||||
elevation: Float,
|
||||
private val preferences: NotallyXPreferences,
|
||||
private val listManager: ListManager,
|
||||
private val isCheckedListAdapter: Boolean,
|
||||
scrollView: NestedScrollView,
|
||||
) : RecyclerView.Adapter<ListItemVH>(), HighlightText {
|
||||
|
||||
private lateinit var list: SortedList<ListItem>
|
||||
|
||||
private val itemAdapterBase =
|
||||
object :
|
||||
ListItemAdapterBase(
|
||||
this,
|
||||
backgroundColor,
|
||||
textSize,
|
||||
elevation,
|
||||
preferences,
|
||||
listManager,
|
||||
isCheckedListAdapter,
|
||||
scrollView,
|
||||
) {
|
||||
override fun getItem(position: Int): ListItem = list[position]
|
||||
}
|
||||
|
||||
var viewMode: NoteViewMode = NoteViewMode.EDIT
|
||||
set(value) {
|
||||
field = value
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
internal fun setList(list: SortedList<ListItem>) {
|
||||
this.list = list
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return list.size()
|
||||
}
|
||||
|
||||
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
|
||||
itemAdapterBase.onAttachedToRecyclerView(recyclerView)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ListItemVH, position: Int) {
|
||||
itemAdapterBase.onBindViewHolder(holder, position, viewMode)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
itemAdapterBase.onCreateViewHolder(parent, viewType)
|
||||
|
||||
internal fun setBackgroundColor(@ColorInt color: Int) =
|
||||
itemAdapterBase.setBackgroundColor(color)
|
||||
|
||||
internal fun clearHighlights() = itemAdapterBase.clearHighlights()
|
||||
|
||||
override fun highlightText(highlight: ListItemHighlight) =
|
||||
itemAdapterBase.highlightText(highlight)
|
||||
|
||||
internal fun selectHighlight(pos: Int) = itemAdapterBase.selectHighlight(pos)
|
||||
}
|
|
@ -0,0 +1,104 @@
|
|||
package com.philkes.notallyx.presentation.view.note.listitem.adapter
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.widget.NestedScrollView
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.philkes.notallyx.data.model.ListItem
|
||||
import com.philkes.notallyx.data.model.NoteViewMode
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.HighlightText
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.ListManager
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.TextSize
|
||||
|
||||
class ListItemAdapter(
|
||||
@ColorInt var backgroundColor: Int,
|
||||
private val textSize: TextSize,
|
||||
elevation: Float,
|
||||
private val preferences: NotallyXPreferences,
|
||||
private val listManager: ListManager,
|
||||
private val isCheckedListAdapter: Boolean,
|
||||
scrollView: NestedScrollView,
|
||||
) : ListAdapter<ListItem, ListItemVH>(DIFF_CALLBACK), HighlightText {
|
||||
|
||||
private val itemAdapterBase =
|
||||
object :
|
||||
ListItemAdapterBase(
|
||||
this,
|
||||
backgroundColor,
|
||||
textSize,
|
||||
elevation,
|
||||
preferences,
|
||||
listManager,
|
||||
isCheckedListAdapter,
|
||||
scrollView,
|
||||
) {
|
||||
override fun getItem(position: Int): ListItem = this@ListItemAdapter.getItem(position)
|
||||
}
|
||||
|
||||
var viewMode: NoteViewMode = NoteViewMode.EDIT
|
||||
set(value) {
|
||||
field = value
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
lateinit var items: MutableList<ListItem>
|
||||
private set
|
||||
|
||||
override fun submitList(list: MutableList<ListItem>?) {
|
||||
list?.let { items = it }
|
||||
super.submitList(list)
|
||||
}
|
||||
|
||||
override fun submitList(list: MutableList<ListItem>?, commitCallback: Runnable?) {
|
||||
list?.let { items = it }
|
||||
super.submitList(list, commitCallback)
|
||||
}
|
||||
|
||||
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
|
||||
itemAdapterBase.onAttachedToRecyclerView(recyclerView)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ListItemVH, position: Int) {
|
||||
itemAdapterBase.onBindViewHolder(holder, position, viewMode)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
itemAdapterBase.onCreateViewHolder(parent, viewType)
|
||||
|
||||
internal fun setBackgroundColor(@ColorInt color: Int) =
|
||||
itemAdapterBase.setBackgroundColor(color)
|
||||
|
||||
internal fun clearHighlights() = itemAdapterBase.clearHighlights()
|
||||
|
||||
override fun highlightText(highlight: ListItemHighlight) =
|
||||
itemAdapterBase.highlightText(highlight)
|
||||
|
||||
internal fun selectHighlight(pos: Int) = itemAdapterBase.selectHighlight(pos)
|
||||
|
||||
internal fun notifyListItemChanged(id: Int) {
|
||||
val list = currentList
|
||||
val index = list.indexOfFirst { it.id == id }
|
||||
val item = list[index]
|
||||
if (item.isChild) {
|
||||
notifyItemChanged(index)
|
||||
} else {
|
||||
notifyItemRangeChanged(index, item.children.size + 1)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DIFF_CALLBACK =
|
||||
object : DiffUtil.ItemCallback<ListItem>() {
|
||||
override fun areItemsTheSame(oldItem: ListItem, newItem: ListItem) =
|
||||
oldItem.id == newItem.id
|
||||
|
||||
override fun areContentsTheSame(oldItem: ListItem, newItem: ListItem) =
|
||||
oldItem.body == newItem.body &&
|
||||
oldItem.isChild == newItem.isChild &&
|
||||
oldItem.checked == newItem.checked
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,77 +1,87 @@
|
|||
package com.philkes.notallyx.presentation.view.note.listitem
|
||||
package com.philkes.notallyx.presentation.view.note.listitem.adapter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.core.widget.NestedScrollView
|
||||
import androidx.recyclerview.widget.NestedScrollViewItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.philkes.notallyx.data.model.ListItem
|
||||
import com.philkes.notallyx.data.model.NoteViewMode
|
||||
import com.philkes.notallyx.databinding.RecyclerListItemBinding
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.sorting.ListItemSortedList
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.ListItemDragCallback
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.ListManager
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.TextSize
|
||||
|
||||
class ListItemAdapter(
|
||||
data class ListItemHighlight(
|
||||
val itemPos: Int,
|
||||
val resultPos: Int,
|
||||
val startIdx: Int,
|
||||
val endIdx: Int,
|
||||
var selected: Boolean,
|
||||
)
|
||||
|
||||
abstract class ListItemAdapterBase(
|
||||
private val adapter: RecyclerView.Adapter<*>,
|
||||
@ColorInt var backgroundColor: Int,
|
||||
private val textSize: TextSize,
|
||||
elevation: Float,
|
||||
private val preferences: NotallyXPreferences,
|
||||
private val listManager: ListManager,
|
||||
) : RecyclerView.Adapter<ListItemVH>() {
|
||||
private val isCheckedListAdapter: Boolean,
|
||||
scrollView: NestedScrollView,
|
||||
) {
|
||||
|
||||
private lateinit var list: ListItemSortedList
|
||||
private val callback = ListItemDragCallback(elevation, listManager)
|
||||
private val touchHelper = ItemTouchHelper(callback)
|
||||
|
||||
private val touchHelper = NestedScrollViewItemTouchHelper(callback, scrollView)
|
||||
private val highlights = mutableMapOf<Int, MutableList<ListItemHighlight>>()
|
||||
|
||||
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
|
||||
fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
|
||||
touchHelper.attachToRecyclerView(recyclerView)
|
||||
}
|
||||
|
||||
override fun getItemCount() = list.size()
|
||||
|
||||
override fun onBindViewHolder(holder: ListItemVH, position: Int) {
|
||||
val item = list[position]
|
||||
fun onBindViewHolder(holder: ListItemVH, position: Int, viewMode: NoteViewMode) {
|
||||
val item = getItem(position)
|
||||
holder.bind(
|
||||
backgroundColor,
|
||||
item,
|
||||
position,
|
||||
highlights.get(position),
|
||||
highlights[position],
|
||||
preferences.listItemSorting.value,
|
||||
viewMode,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListItemVH {
|
||||
abstract fun getItem(position: Int): ListItem
|
||||
|
||||
fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListItemVH {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
val binding = RecyclerListItemBinding.inflate(inflater, parent, false)
|
||||
binding.root.background = parent.background
|
||||
return ListItemVH(binding, listManager, touchHelper, textSize)
|
||||
return ListItemVH(binding, listManager, touchHelper, textSize, isCheckedListAdapter)
|
||||
}
|
||||
|
||||
internal fun setBackgroundColor(@ColorInt color: Int) {
|
||||
backgroundColor = color
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
internal fun setList(list: ListItemSortedList) {
|
||||
this.list = list
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
internal fun clearHighlights(): Set<Int> {
|
||||
val highlightedItemPos =
|
||||
highlights.entries.flatMap { (_, value) -> value.map { it.itemPos } }.toSet()
|
||||
highlights.clear()
|
||||
highlightedItemPos.forEach { adapter.notifyItemChanged(it) }
|
||||
return highlightedItemPos
|
||||
// itemPos.forEach { notifyItemChanged(it) }
|
||||
}
|
||||
|
||||
internal fun highlightText(highlight: ListItemHighlight) {
|
||||
fun highlightText(highlight: ListItemHighlight) {
|
||||
if (highlights.containsKey(highlight.itemPos)) {
|
||||
highlights[highlight.itemPos]!!.add(highlight)
|
||||
} else {
|
||||
highlights[highlight.itemPos] = mutableListOf(highlight)
|
||||
}
|
||||
notifyItemChanged(highlight.itemPos)
|
||||
adapter.notifyItemChanged(highlight.itemPos)
|
||||
}
|
||||
|
||||
internal fun selectHighlight(pos: Int): Int {
|
||||
|
@ -81,7 +91,7 @@ class ListItemAdapter(
|
|||
val isSelected = it.selected
|
||||
it.selected = it.resultPos == pos
|
||||
if (isSelected != it.selected) {
|
||||
notifyItemChanged(it.itemPos)
|
||||
adapter.notifyItemChanged(it.itemPos)
|
||||
}
|
||||
if (it.selected) {
|
||||
selectedItemPos = it.itemPos
|
||||
|
@ -90,12 +100,4 @@ class ListItemAdapter(
|
|||
}
|
||||
return selectedItemPos
|
||||
}
|
||||
|
||||
data class ListItemHighlight(
|
||||
val itemPos: Int,
|
||||
val resultPos: Int,
|
||||
val startIdx: Int,
|
||||
val endIdx: Int,
|
||||
var selected: Boolean,
|
||||
)
|
||||
}
|
|
@ -1,34 +1,46 @@
|
|||
package com.philkes.notallyx.presentation.view.note.listitem
|
||||
package com.philkes.notallyx.presentation.view.note.listitem.adapter
|
||||
|
||||
import android.graphics.Paint
|
||||
import android.util.TypedValue
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.View.GONE
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.CompoundButton.OnCheckedChangeListener
|
||||
import android.widget.EditText
|
||||
import android.widget.TextView.INVISIBLE
|
||||
import android.widget.TextView.VISIBLE
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.NO_POSITION
|
||||
import cn.leaqi.drawer.SwipeDrawer.DIRECTION_LEFT
|
||||
import cn.leaqi.drawer.SwipeDrawer.STATE_CLOSE
|
||||
import cn.leaqi.drawer.SwipeDrawer.STATE_OPEN
|
||||
import com.philkes.notallyx.data.imports.txt.extractListItems
|
||||
import com.philkes.notallyx.data.imports.txt.findListSyntaxRegex
|
||||
import com.philkes.notallyx.data.model.ListItem
|
||||
import com.philkes.notallyx.data.model.NoteViewMode
|
||||
import com.philkes.notallyx.databinding.RecyclerListItemBinding
|
||||
import com.philkes.notallyx.presentation.clone
|
||||
import com.philkes.notallyx.presentation.createListTextWatcherWithHistory
|
||||
import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews
|
||||
import com.philkes.notallyx.presentation.setOnNextAction
|
||||
import com.philkes.notallyx.presentation.view.misc.EditTextAutoClearFocus
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.ListManager
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.firstBodyOrEmptyString
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.ListItemSort
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.TextSize
|
||||
import com.philkes.notallyx.utils.changehistory.EditTextState
|
||||
import com.philkes.notallyx.utils.copyToClipBoard
|
||||
|
||||
class ListItemVH(
|
||||
val binding: RecyclerListItemBinding,
|
||||
val listManager: ListManager,
|
||||
touchHelper: ItemTouchHelper,
|
||||
textSize: TextSize,
|
||||
private val inCheckedList: Boolean,
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
private var dragHandleInitialY: Float = 0f
|
||||
|
@ -38,11 +50,6 @@ class ListItemVH(
|
|||
binding.EditText.apply {
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, body)
|
||||
|
||||
setOnNextAction {
|
||||
val position = absoluteAdapterPosition + 1
|
||||
listManager.add(position)
|
||||
}
|
||||
|
||||
textWatcher =
|
||||
createListTextWatcherWithHistory(
|
||||
listManager,
|
||||
|
@ -54,10 +61,6 @@ class ListItemVH(
|
|||
false
|
||||
}
|
||||
}
|
||||
|
||||
setOnFocusChangeListener { _, hasFocus ->
|
||||
binding.Delete.visibility = if (hasFocus) VISIBLE else INVISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
binding.DragHandle.setOnTouchListener { _, event ->
|
||||
|
@ -86,22 +89,23 @@ class ListItemVH(
|
|||
@ColorInt backgroundColor: Int,
|
||||
item: ListItem,
|
||||
position: Int,
|
||||
highlights: List<ListItemAdapter.ListItemHighlight>?,
|
||||
highlights: List<ListItemHighlight>?,
|
||||
autoSort: ListItemSort,
|
||||
viewMode: NoteViewMode,
|
||||
) {
|
||||
updateEditText(item, position)
|
||||
updateEditText(item, position, viewMode)
|
||||
|
||||
updateCheckBox(item, position)
|
||||
|
||||
updateDeleteButton(item, position)
|
||||
updateDeleteButton(item, position, viewMode)
|
||||
|
||||
updateSwipe(item.isChild, position != 0 && !item.checked)
|
||||
updateSwipe(item.isChild, viewMode == NoteViewMode.EDIT && position != 0 && !item.checked)
|
||||
binding.DragHandle.apply {
|
||||
visibility =
|
||||
if (item.checked && autoSort == ListItemSort.AUTO_SORT_BY_CHECKED) {
|
||||
INVISIBLE
|
||||
} else {
|
||||
VISIBLE
|
||||
when {
|
||||
viewMode != NoteViewMode.EDIT -> GONE
|
||||
item.checked && autoSort == ListItemSort.AUTO_SORT_BY_CHECKED -> INVISIBLE
|
||||
else -> VISIBLE
|
||||
}
|
||||
contentDescription = "Drag$position"
|
||||
}
|
||||
|
@ -123,18 +127,65 @@ class ListItemVH(
|
|||
binding.EditText.focusAndSelect(selectionStart, selectionEnd, inputMethodManager)
|
||||
}
|
||||
|
||||
private fun updateDeleteButton(item: ListItem, position: Int) {
|
||||
private fun updateDeleteButton(item: ListItem, position: Int, viewMode: NoteViewMode) {
|
||||
binding.Delete.apply {
|
||||
visibility = if (item.checked) VISIBLE else INVISIBLE
|
||||
setOnClickListener { listManager.delete(absoluteAdapterPosition) }
|
||||
visibility =
|
||||
when {
|
||||
viewMode != NoteViewMode.EDIT -> GONE
|
||||
item.checked -> VISIBLE
|
||||
else -> INVISIBLE
|
||||
}
|
||||
setOnClickListener {
|
||||
listManager.delete(absoluteAdapterPosition, inCheckedList = inCheckedList)
|
||||
}
|
||||
contentDescription = "Delete$position"
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateEditText(item: ListItem, position: Int) {
|
||||
private fun updateEditText(item: ListItem, position: Int, viewMode: NoteViewMode) {
|
||||
binding.EditText.apply {
|
||||
setText(item.body)
|
||||
isEnabled = !item.checked
|
||||
paintFlags =
|
||||
if (item.checked) {
|
||||
paintFlags or Paint.STRIKE_THRU_TEXT_FLAG
|
||||
} else {
|
||||
paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv()
|
||||
}
|
||||
alpha = if (item.checked) 0.5f else 1.0f
|
||||
contentDescription = "EditText$position"
|
||||
if (viewMode == NoteViewMode.EDIT) {
|
||||
setOnFocusChangeListener { _, hasFocus ->
|
||||
binding.Delete.visibility = if (hasFocus) VISIBLE else INVISIBLE
|
||||
}
|
||||
binding.Content.descendantFocusability = ViewGroup.FOCUS_BEFORE_DESCENDANTS
|
||||
} else {
|
||||
onFocusChangeListener = null
|
||||
binding.Content.descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS
|
||||
}
|
||||
setCanEdit(viewMode == NoteViewMode.EDIT)
|
||||
isFocusable = !item.checked
|
||||
when (viewMode) {
|
||||
NoteViewMode.EDIT -> {
|
||||
setOnClickListener(null)
|
||||
setOnLongClickListener(null)
|
||||
}
|
||||
NoteViewMode.READ_ONLY -> {
|
||||
setOnClickListener {
|
||||
if (absoluteAdapterPosition != NO_POSITION) {
|
||||
listManager.changeChecked(
|
||||
absoluteAdapterPosition,
|
||||
!item.checked,
|
||||
inCheckedList,
|
||||
)
|
||||
}
|
||||
}
|
||||
setOnLongClickListener {
|
||||
context?.copyToClipBoard(item.body)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
setOnNextAction { listManager.add(bindingAdapterPosition + 1) }
|
||||
setOnKeyListener { _, keyCode, event ->
|
||||
if (
|
||||
event.action == KeyEvent.ACTION_DOWN &&
|
||||
|
@ -144,12 +195,15 @@ class ListItemVH(
|
|||
// TODO: when there are multiple checked items above it does not jump to the
|
||||
// last
|
||||
// unchecked item but always re-adds a new item
|
||||
listManager.delete(absoluteAdapterPosition, false) != null
|
||||
listManager.delete(
|
||||
absoluteAdapterPosition,
|
||||
inCheckedList = inCheckedList,
|
||||
force = false,
|
||||
)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
contentDescription = "EditText$position"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -157,10 +211,12 @@ class ListItemVH(
|
|||
|
||||
private fun updateCheckBox(item: ListItem, position: Int) {
|
||||
if (checkBoxListener == null) {
|
||||
checkBoxListener = OnCheckedChangeListener { buttonView, isChecked ->
|
||||
buttonView!!.setOnCheckedChangeListener(null)
|
||||
listManager.changeChecked(absoluteAdapterPosition, isChecked)
|
||||
buttonView.setOnCheckedChangeListener(checkBoxListener)
|
||||
checkBoxListener = OnCheckedChangeListener { _, isChecked ->
|
||||
binding.CheckBox.setOnCheckedChangeListener(null)
|
||||
if (absoluteAdapterPosition != NO_POSITION) {
|
||||
listManager.changeChecked(absoluteAdapterPosition, isChecked, inCheckedList)
|
||||
}
|
||||
binding.CheckBox.setOnCheckedChangeListener(checkBoxListener)
|
||||
}
|
||||
}
|
||||
binding.CheckBox.apply {
|
||||
|
@ -197,20 +253,31 @@ class ListItemVH(
|
|||
.findListSyntaxRegex(checkContains = true, plainNewLineAllowed = true)
|
||||
?.let { listSyntaxRegex ->
|
||||
val items = changedText.extractListItems(listSyntaxRegex)
|
||||
if (text.trim().length > count) {
|
||||
editText.setText(text.substring(0, start) + text.substring(start + count))
|
||||
} else {
|
||||
listManager.delete(absoluteAdapterPosition, pushChange = false)
|
||||
}
|
||||
items.forEachIndexed { idx, it ->
|
||||
listManager.add(absoluteAdapterPosition + idx + 1, it, pushChange = true)
|
||||
if (items.isNotEmpty()) {
|
||||
listManager.startBatchChange(start)
|
||||
val position = absoluteAdapterPosition
|
||||
val itemHadTextBefore = text.trim().length > count
|
||||
val firstPastedItemBody = items.firstBodyOrEmptyString()
|
||||
val updatedText =
|
||||
if (itemHadTextBefore) {
|
||||
text.substring(0, start) + firstPastedItemBody
|
||||
} else firstPastedItemBody
|
||||
editText.changeText(position, updatedText)
|
||||
items.drop(1).forEachIndexed { index, it ->
|
||||
listManager.add(position + 1 + index, it, pushChange = false)
|
||||
}
|
||||
listManager.finishBatchChange(position + items.size - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return containsLines
|
||||
}
|
||||
|
||||
fun getSelection(): Pair<Int, Int> {
|
||||
return Pair(binding.EditText.selectionStart, binding.EditText.selectionEnd)
|
||||
private fun EditText.changeText(position: Int, after: CharSequence) {
|
||||
setText(after)
|
||||
val stateAfter = EditTextState(editableText.clone(), selectionStart)
|
||||
listManager.changeText(position, stateAfter, pushChange = false)
|
||||
}
|
||||
|
||||
fun getSelection() = with(binding.EditText) { Pair(selectionStart, selectionEnd) }
|
||||
}
|
|
@ -1,38 +0,0 @@
|
|||
package com.philkes.notallyx.presentation.view.note.listitem.sorting
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.SortedListAdapterCallback
|
||||
import com.philkes.notallyx.data.model.ListItem
|
||||
|
||||
/** Sort algorithm that only sorts by [ListItem.order] */
|
||||
class ListItemNoSortCallback(adapter: RecyclerView.Adapter<*>?) :
|
||||
SortedListAdapterCallback<ListItem>(adapter) {
|
||||
|
||||
override fun compare(item1: ListItem?, item2: ListItem?): Int {
|
||||
return when {
|
||||
item1 == null && item2 == null -> 0
|
||||
item1 == null && item2 != null -> -1
|
||||
item1 != null && item2 == null -> 1
|
||||
else -> {
|
||||
val orderCmp = item1!!.order!!.compareTo(item2!!.order!!)
|
||||
if (orderCmp == 0 && item1.isChildOf(item2)) {
|
||||
return -1 // happens when a parent with children is moved up, the children is
|
||||
// moved first
|
||||
}
|
||||
if (orderCmp == 0 && item2.isChildOf(item1)) {
|
||||
return 1 // happens when a parent with children is moved down, the children is
|
||||
// moved first
|
||||
}
|
||||
return orderCmp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: ListItem?, newItem: ListItem?): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
|
||||
override fun areItemsTheSame(item1: ListItem?, item2: ListItem?): Boolean {
|
||||
return item1?.id == item2?.id
|
||||
}
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
package com.philkes.notallyx.presentation.view.note.listitem.sorting
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.SortedList
|
||||
import androidx.recyclerview.widget.SortedListAdapterCallback
|
||||
import com.philkes.notallyx.data.model.ListItem
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.containsId
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.findParent
|
||||
|
||||
/**
|
||||
* Sort algorithm that only sorts by [ListItem.order]. A children is always below it's parent and
|
||||
* above parents with a lower order.
|
||||
*/
|
||||
class ListItemParentSortCallback(adapter: RecyclerView.Adapter<*>?) :
|
||||
SortedListAdapterCallback<ListItem>(adapter) {
|
||||
|
||||
private var items: SortedList<ListItem>? = null
|
||||
|
||||
internal fun setItems(items: SortedList<ListItem>) {
|
||||
this.items = items
|
||||
}
|
||||
|
||||
override fun compare(item1: ListItem?, item2: ListItem?): Int {
|
||||
return when {
|
||||
item1 == null && item2 == null -> 0
|
||||
item1 == null && item2 != null -> -1
|
||||
item1 != null && item2 == null -> 1
|
||||
item1!!.id == item2!!.id -> 0
|
||||
!item1.isChild && item2.isChild -> {
|
||||
val parent2 =
|
||||
if (item1.children.containsId(item2.id)) {
|
||||
item1
|
||||
} else {
|
||||
items!!.findParent(item2)!!.second
|
||||
}
|
||||
return when {
|
||||
item1.id == parent2.id -> compareOrder(item1, item2)
|
||||
else -> compare(item1, parent2)
|
||||
}
|
||||
}
|
||||
|
||||
item1.isChild && !item2.isChild -> {
|
||||
val parent1 =
|
||||
if (item2.children.containsId(item1.id)) {
|
||||
item2
|
||||
} else {
|
||||
items!!.findParent(item1)!!.second
|
||||
}
|
||||
when {
|
||||
item2.id == parent1.id -> compareOrder(item1, item2)
|
||||
else -> compare(parent1, item2)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
return compareOrder(item1, item2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun compareOrder(item1: ListItem, item2: ListItem): Int {
|
||||
val orderCmp = item1.order!!.compareTo(item2.order!!)
|
||||
if (orderCmp == 0 && item1.isChildOf(item2)) {
|
||||
return -1 // happens when a parent with children is moved up, the children is
|
||||
// moved first
|
||||
}
|
||||
if (orderCmp == 0 && item2.isChildOf(item1)) {
|
||||
return 1 // happens when a parent with children is moved down, the children is
|
||||
// moved first
|
||||
}
|
||||
return orderCmp
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: ListItem?, newItem: ListItem?): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
|
||||
override fun areItemsTheSame(item1: ListItem?, item2: ListItem?): Boolean {
|
||||
return item1?.id == item2?.id
|
||||
}
|
||||
}
|
|
@ -1,89 +0,0 @@
|
|||
package com.philkes.notallyx.presentation.view.note.listitem.sorting
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.SortedListAdapterCallback
|
||||
import com.philkes.notallyx.data.model.ListItem
|
||||
import com.philkes.notallyx.data.model.containsId
|
||||
|
||||
/**
|
||||
* Sort algorithm that sorts items by [ListItem.checked] and [ListItem.order]. Children are always
|
||||
* sorted below their parents.
|
||||
*/
|
||||
class ListItemSortedByCheckedCallback(adapter: RecyclerView.Adapter<*>?) :
|
||||
SortedListAdapterCallback<ListItem>(adapter) {
|
||||
|
||||
internal lateinit var items: ListItemSortedList
|
||||
|
||||
fun setList(items: ListItemSortedList) {
|
||||
this.items = items
|
||||
}
|
||||
|
||||
override fun compare(item1: ListItem?, item2: ListItem?): Int {
|
||||
return when {
|
||||
item1 == null && item2 == null -> 0
|
||||
item1 == null && item2 != null -> -1
|
||||
item1 != null && item2 == null -> 1
|
||||
item1!!.id == item2!!.id -> if (item1.checked) -1 else 1
|
||||
item1.isChild && item2.isChild -> {
|
||||
val parent1 = items.findParent(item1)!!.second
|
||||
val parent2 = items.findParent(item2)!!.second
|
||||
return when {
|
||||
parent1.id == parent2.id -> item1.order!!.compareTo(item2.order!!)
|
||||
else -> compare(parent1, parent2)
|
||||
}
|
||||
}
|
||||
|
||||
!item1.isChild && item2.isChild -> {
|
||||
val parent2 =
|
||||
if (item1.children.containsId(item2.id)) {
|
||||
item1
|
||||
} else {
|
||||
items.findParent(item2)!!.second
|
||||
}
|
||||
return when {
|
||||
item1.id == parent2.id -> compareChecked(item1, item2)
|
||||
else -> compare(item1, parent2)
|
||||
}
|
||||
}
|
||||
|
||||
item1.isChild && !item2.isChild -> {
|
||||
val parent1 =
|
||||
if (item2.children.containsId(item1.id)) {
|
||||
item2
|
||||
} else {
|
||||
items.findParent(item1)!!.second
|
||||
}
|
||||
when {
|
||||
item2.id == parent1.id -> compareChecked(item1, item2)
|
||||
else -> compare(parent1, item2)
|
||||
}
|
||||
}
|
||||
|
||||
else -> compareChecked(item1, item2)
|
||||
}
|
||||
}
|
||||
|
||||
private fun compareChecked(item1: ListItem, item2: ListItem): Int {
|
||||
return when {
|
||||
// if a parent gets checked and the child has not been checked yet the parent
|
||||
// should be sorted under the not yet checked child
|
||||
item1.isChild && !item2.isChild && item2.checked -> -1
|
||||
item1.isChild && !item2.isChild && !item2.checked -> 1
|
||||
|
||||
item1.checked == item2.checked -> item1.order!!.compareTo(item2.order!!)
|
||||
item1.checked && !item2.checked -> 1
|
||||
!item1.checked && item2.checked -> -1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: ListItem?, newItem: ListItem?): Boolean {
|
||||
val b = oldItem == newItem
|
||||
return b
|
||||
}
|
||||
|
||||
override fun areItemsTheSame(item1: ListItem?, item2: ListItem?): Boolean {
|
||||
val b = item1?.id == item2?.id
|
||||
return b
|
||||
}
|
||||
}
|
|
@ -1,191 +0,0 @@
|
|||
package com.philkes.notallyx.presentation.view.note.listitem.sorting
|
||||
|
||||
import androidx.recyclerview.widget.SortedList
|
||||
import com.philkes.notallyx.data.model.ListItem
|
||||
import com.philkes.notallyx.data.model.deepCopy
|
||||
|
||||
class ListItemSortedList(private val callback: Callback<ListItem>) :
|
||||
SortedList<ListItem>(ListItem::class.java, callback) {
|
||||
|
||||
override fun updateItemAt(index: Int, item: ListItem?) {
|
||||
updateChildStatus(item, index)
|
||||
super.updateItemAt(index, item)
|
||||
if (item?.isChild == false) {
|
||||
item.children = item.children.map { findById(it.id)!!.second }.toMutableList()
|
||||
}
|
||||
}
|
||||
|
||||
override fun add(item: ListItem?): Int {
|
||||
val position = super.add(item)
|
||||
if (item?.isChild == true) {
|
||||
updateChildInParent(position, item)
|
||||
}
|
||||
return position
|
||||
}
|
||||
|
||||
fun add(item: ListItem, isChild: Boolean?) {
|
||||
if (isChild != null) {
|
||||
forceItemIsChild(item, isChild)
|
||||
}
|
||||
add(item)
|
||||
}
|
||||
|
||||
fun forceItemIsChild(item: ListItem, newValue: Boolean, resetBefore: Boolean = false) {
|
||||
if (resetBefore) {
|
||||
if (item.isChild) {
|
||||
// In this case it was already a child and moved to other position,
|
||||
// therefore reset the child association
|
||||
removeChildFromParent(item)
|
||||
item.isChild = false
|
||||
}
|
||||
}
|
||||
if (item.isChild != newValue) {
|
||||
if (!item.isChild) {
|
||||
item.children.clear()
|
||||
} else {
|
||||
removeChildFromParent(item)
|
||||
}
|
||||
item.isChild = newValue
|
||||
}
|
||||
if (item.isChild) {
|
||||
updateChildInParent(item.order!!, item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun removeItemAt(index: Int): ListItem {
|
||||
val item = this[index]
|
||||
val removedItem = super.removeItemAt(index)
|
||||
if (item?.isChild == true) {
|
||||
removeChildFromParent(item)
|
||||
}
|
||||
return removedItem
|
||||
}
|
||||
|
||||
fun init(items: Collection<ListItem>) {
|
||||
beginBatchedUpdates()
|
||||
super.clear()
|
||||
val initializedItems = items.deepCopy()
|
||||
initList(initializedItems)
|
||||
if (callback is ListItemSortedByCheckedCallback) {
|
||||
val (children, parents) = initializedItems.partition { it.isChild }
|
||||
// Need to use replaceAll for auto-sorting checked items
|
||||
super.replaceAll(parents.toTypedArray(), false)
|
||||
super.addAll(children.toTypedArray(), false)
|
||||
} else {
|
||||
super.addAll(initializedItems.toTypedArray(), false)
|
||||
}
|
||||
endBatchedUpdates()
|
||||
}
|
||||
|
||||
fun init(vararg items: ListItem) {
|
||||
init(items.toList())
|
||||
}
|
||||
|
||||
private fun separateChildrenFromParent(item: ListItem) {
|
||||
findParent(item)?.let { (_, parent) ->
|
||||
val childIndex = parent.children.indexOfFirst { child -> child.id == item.id }
|
||||
// If a child becomes a parent it inherits its children below it
|
||||
val separatedChildren =
|
||||
if (childIndex < parent.children.lastIndex)
|
||||
parent.children.subList(childIndex + 1, parent.children.size)
|
||||
else listOf()
|
||||
item.children.clear()
|
||||
item.children.addAll(separatedChildren)
|
||||
while (parent.children.size >= childIndex + 1) {
|
||||
parent.children.removeAt(childIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateChildStatus(item: ListItem?, index: Int) {
|
||||
val wasChild = this[index].isChild
|
||||
if (item?.isChild == true) {
|
||||
updateChildInParent(index, item)
|
||||
} else if (wasChild && item?.isChild == false) {
|
||||
// Child becomes parent
|
||||
separateChildrenFromParent(item)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeChildFromParent(item: ListItem) {
|
||||
findParent(item)?.let { (_, parent) ->
|
||||
val childIndex = parent.children.indexOfFirst { child -> child.id == item.id }
|
||||
parent.children.removeAt(childIndex)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initList(list: List<ListItem>) {
|
||||
list.forEachIndexed { index, item -> item.id = index }
|
||||
initOrders(list)
|
||||
initChildren(list)
|
||||
}
|
||||
|
||||
private fun initChildren(list: List<ListItem>) {
|
||||
list.forEach { it.children.clear() }
|
||||
var parent: ListItem? = null
|
||||
list.forEach { item ->
|
||||
if (item.isChild && parent != null) {
|
||||
parent!!.children.add(item)
|
||||
} else {
|
||||
item.isChild = false
|
||||
parent = item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Makes sure every [ListItem.order] is valid and correct */
|
||||
private fun initOrders(list: List<ListItem>): Boolean {
|
||||
var orders = list.map { it.order }.toMutableList()
|
||||
var invalidOrderFound = false
|
||||
list.forEachIndexed { idx, item ->
|
||||
if (item.order == null || orders.count { it == idx } > 1) {
|
||||
invalidOrderFound = true
|
||||
if (orders.contains(idx)) {
|
||||
shiftAllOrdersAfterItem(list, item)
|
||||
}
|
||||
item.order = idx
|
||||
orders = list.map { it.order }.toMutableList()
|
||||
}
|
||||
}
|
||||
return invalidOrderFound
|
||||
}
|
||||
|
||||
private fun shiftAllOrdersAfterItem(list: List<ListItem>, item: ListItem) {
|
||||
// Move all orders after the item to ensure no duplicate orders
|
||||
val sortedByOrders = list.sortedBy { it.order }
|
||||
val position = sortedByOrders.indexOfFirst { it.id == item.id }
|
||||
for (i in position + 1..sortedByOrders.lastIndex) {
|
||||
sortedByOrders[i].order = sortedByOrders[i].order!! + 1
|
||||
}
|
||||
}
|
||||
|
||||
fun updateChildInParent(position: Int, item: ListItem) {
|
||||
val childIndex: Int?
|
||||
val parentInfo = findParent(item)
|
||||
val parent: ListItem?
|
||||
if (parentInfo == null) {
|
||||
val parentPosition = findLastIsNotChild(position - 1)!!
|
||||
childIndex = position - parentPosition - 1
|
||||
parent = this[parentPosition]
|
||||
} else {
|
||||
parent = parentInfo.second
|
||||
childIndex = parent.children.indexOfFirst { child -> child.id == item.id }
|
||||
parent.children.removeAt(childIndex)
|
||||
}
|
||||
parent!!.children.add(childIndex, item)
|
||||
parent.children.addAll(childIndex + 1, item.children)
|
||||
item.children.clear()
|
||||
}
|
||||
|
||||
/** @return position of the found item and its difference to index */
|
||||
private fun findLastIsNotChild(index: Int): Int? {
|
||||
var position = index
|
||||
while (this[position].isChild) {
|
||||
if (position < 0) {
|
||||
return null
|
||||
}
|
||||
position--
|
||||
}
|
||||
return position
|
||||
}
|
||||
}
|
|
@ -1,308 +0,0 @@
|
|||
package com.philkes.notallyx.presentation.view.note.listitem.sorting
|
||||
|
||||
import com.philkes.notallyx.data.model.ListItem
|
||||
import com.philkes.notallyx.data.model.findChild
|
||||
import com.philkes.notallyx.data.model.plus
|
||||
|
||||
fun ListItemSortedList.deleteItem(item: ListItem) {
|
||||
val itemsBySortPosition = this.toMutableList().sortedBy { it.order }
|
||||
val positionOfDeletedItem = itemsBySortPosition.indexOfFirst { it.id == item.id }
|
||||
for (i in positionOfDeletedItem + 1..itemsBySortPosition.lastIndex) {
|
||||
itemsBySortPosition[i].order = itemsBySortPosition[i].order!! - 1
|
||||
}
|
||||
val position = this.findById(item.id)!!.first
|
||||
this.removeItemAt(position)
|
||||
}
|
||||
|
||||
fun ListItemSortedList.moveItemRange(
|
||||
fromIndex: Int,
|
||||
itemCount: Int,
|
||||
toIndex: Int,
|
||||
forceIsChild: Boolean? = null,
|
||||
): Int? {
|
||||
if (fromIndex == toIndex || itemCount <= 0) return null
|
||||
|
||||
this.beginBatchedUpdates()
|
||||
|
||||
val isMoveUp = fromIndex < toIndex
|
||||
val insertPosition = if (isMoveUp) toIndex - itemCount + 1 else toIndex
|
||||
|
||||
if (isMoveUp) {
|
||||
this.shiftItemOrders(fromIndex + itemCount until toIndex + 1, -itemCount)
|
||||
} else {
|
||||
this.shiftItemOrders(toIndex until fromIndex, itemCount)
|
||||
}
|
||||
|
||||
val itemsToMove =
|
||||
(0 until itemCount)
|
||||
.map { this[fromIndex + it] }
|
||||
.mapIndexed { index, item ->
|
||||
val movedItem = item.clone() as ListItem
|
||||
movedItem.order = insertPosition + index
|
||||
movedItem
|
||||
}
|
||||
itemsToMove.forEach { listItem ->
|
||||
this.updateItemAt(this.findById(listItem.id)!!.first, listItem)
|
||||
}
|
||||
itemsToMove.forEach {
|
||||
if (forceIsChild != null) {
|
||||
val (_, item) = this.findById(it.id)!!
|
||||
this.forceItemIsChild(item, forceIsChild, resetBefore = true)
|
||||
itemsToMove.forEach { listItem ->
|
||||
this.updateItemAt(this.findById(listItem.id)!!.first, listItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
this.recalcPositions(
|
||||
itemsToMove.reversed().map { it.id }
|
||||
) // make sure children are at correct positions
|
||||
this.endBatchedUpdates()
|
||||
val newPosition = this.indexOfFirst { it.id == itemsToMove[0].id }!!
|
||||
return newPosition
|
||||
}
|
||||
|
||||
fun ListItemSortedList.deleteItem(
|
||||
position: Int,
|
||||
childrenToDelete: List<ListItem>? = null,
|
||||
): ListItem {
|
||||
this.beginBatchedUpdates()
|
||||
val item = this[position]
|
||||
val deletedItem = this[position].clone() as ListItem
|
||||
val children = childrenToDelete ?: item.children
|
||||
this.shiftItemOrders(position + children.size until this.size(), -(children.size + 1))
|
||||
(item + children).indices.forEach { this.removeItemAt(position) }
|
||||
this.endBatchedUpdates()
|
||||
return deletedItem
|
||||
}
|
||||
|
||||
fun ListItemSortedList.shiftItemOrders(positionRange: IntRange, valueToAdd: Int) {
|
||||
this.forEach {
|
||||
if (it.order!! in positionRange) {
|
||||
it.order = it.order!! + valueToAdd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ListItemSortedList.toMutableList(): MutableList<ListItem> {
|
||||
return this.indices.map { this[it] }.toMutableList()
|
||||
}
|
||||
|
||||
fun ListItemSortedList.cloneList(): MutableList<ListItem> {
|
||||
return this.indices.map { this[it].clone() as ListItem }.toMutableList()
|
||||
}
|
||||
|
||||
fun ListItemSortedList.setIsChild(
|
||||
position: Int,
|
||||
isChild: Boolean,
|
||||
forceOnChildren: Boolean = false,
|
||||
forceNotify: Boolean = false,
|
||||
) {
|
||||
if (position == 0 && isChild) {
|
||||
return
|
||||
}
|
||||
if (forceOnChildren) {
|
||||
this.setIsChild((position..position + this[position].children.size).toList(), isChild)
|
||||
} else {
|
||||
val item = this[position].clone() as ListItem
|
||||
val valueChanged = item.isChild != isChild
|
||||
if (valueChanged || forceNotify) {
|
||||
item.isChild = isChild
|
||||
this.updateItemAt(position, item)
|
||||
if (!item.isChild) {
|
||||
this.recalcPositions(item.children.reversed().map { it.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ListItemSortedList.setIsChild(positions: List<Int>, isChild: Boolean) {
|
||||
val changedPositions = mutableListOf<Int>()
|
||||
val items = this.cloneList()
|
||||
positions.forEach {
|
||||
val item = items[it]
|
||||
if (item.isChild != isChild) {
|
||||
changedPositions.add(it)
|
||||
item.isChild = isChild
|
||||
}
|
||||
}
|
||||
updatePositions(changedPositions, items)
|
||||
}
|
||||
|
||||
fun ListItemSortedList.setChecked(
|
||||
position: Int,
|
||||
checked: Boolean,
|
||||
recalcChildrenPositions: Boolean = false,
|
||||
): Int {
|
||||
val item = this[position].clone() as ListItem
|
||||
if (item.checked != checked) {
|
||||
item.checked = checked
|
||||
}
|
||||
// this.beginBatchedUpdates() // TODO: less notifies?
|
||||
val (_, changedPositionsAfterSort) = this.setChecked(listOf(position), checked, false)
|
||||
if (recalcChildrenPositions) {
|
||||
val children = if (checked) item.children.reversed() else item.children
|
||||
// children.com.philkes.notallyx.recyclerview.forEach { child ->
|
||||
//
|
||||
// this.recalculatePositionOfItemAt(this.com.philkes.notallyx.recyclerview.findById(child.id)!!.first)
|
||||
// }
|
||||
recalcPositions(children.map { it.id })
|
||||
}
|
||||
// this.endBatchedUpdates()
|
||||
return changedPositionsAfterSort[0]
|
||||
}
|
||||
|
||||
fun ListItemSortedList.setChecked(
|
||||
positions: Collection<Int>,
|
||||
checked: Boolean,
|
||||
recalcChildrenPositions: Boolean = false,
|
||||
): Pair<List<Int>, List<Int>> {
|
||||
val changedPositions = mutableListOf<Int>()
|
||||
val items = this.cloneList()
|
||||
positions.forEach {
|
||||
val item = items[it]
|
||||
if (item.checked != checked) {
|
||||
changedPositions.add(it)
|
||||
item.checked = checked
|
||||
}
|
||||
}
|
||||
val changedPositionsAfterSort =
|
||||
updatePositions(changedPositions, items, recalcChildrenPositions)
|
||||
return Pair(changedPositions, changedPositionsAfterSort)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks item at position and its children
|
||||
*
|
||||
* @return The position of the checked item afterwards
|
||||
*/
|
||||
fun ListItemSortedList.setCheckedWithChildren(position: Int, checked: Boolean): Int {
|
||||
val parent = this[position]
|
||||
val positionsWithChildren =
|
||||
(position..position + parent.children.size)
|
||||
.reversed() // children have to be checked first for correct sorting
|
||||
.toList()
|
||||
|
||||
val (_, changedPositionsAfterSort) = this.setChecked(positionsWithChildren, checked, true)
|
||||
return changedPositionsAfterSort.reversed()[0]
|
||||
}
|
||||
|
||||
fun ListItemSortedList.findById(id: Int): Pair<Int, ListItem>? {
|
||||
val position = this.indexOfFirst { it.id == id } ?: return null
|
||||
return Pair(position, this[position])
|
||||
}
|
||||
|
||||
fun ListItemSortedList.toReadableString(): String {
|
||||
return map { "$it order: ${it.order} id: ${it.id}" }.joinToString("\n")
|
||||
}
|
||||
|
||||
fun ListItemSortedList.findParent(childItem: ListItem): Pair<Int, ListItem>? {
|
||||
this.indices.forEach {
|
||||
if (this[it].findChild(childItem.id) != null) {
|
||||
return Pair(it, this[it])
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun ListItemSortedList.reversed(): List<ListItem> {
|
||||
return toMutableList().reversed()
|
||||
}
|
||||
|
||||
private fun ListItemSortedList.updatePositions(
|
||||
changedPositions: MutableList<Int>,
|
||||
updatedItems: MutableList<ListItem>,
|
||||
recalcChildrenPositions: Boolean = false,
|
||||
): List<Int> {
|
||||
this.beginBatchedUpdates()
|
||||
val idsOfChildren = mutableSetOf<Int>()
|
||||
changedPositions.forEach {
|
||||
val updatedItem = updatedItems[it]
|
||||
val newPosition = this.indexOfFirst { item -> item.id == updatedItem.id }!!
|
||||
if (!updatedItem.isChild) {
|
||||
idsOfChildren.addAll(
|
||||
updatedItem.children
|
||||
.reversed() // start recalculations from the lowest child upwards
|
||||
.map { item -> item.id }
|
||||
)
|
||||
}
|
||||
this.updateItemAt(newPosition, updatedItem)
|
||||
}
|
||||
if (recalcChildrenPositions) {
|
||||
// idsOfChildren.com.philkes.notallyx.recyclerview.forEach { childId ->
|
||||
//
|
||||
// this.recalculatePositionOfItemAt(this.com.philkes.notallyx.recyclerview.findById(childId)!!.first)
|
||||
// }
|
||||
recalcPositions(idsOfChildren)
|
||||
}
|
||||
|
||||
val changedPositionsAfterSort =
|
||||
changedPositions
|
||||
.map { pos -> this.indexOfFirst { item -> item.id == updatedItems[pos].id }!! }
|
||||
.toList()
|
||||
this.endBatchedUpdates()
|
||||
return changedPositionsAfterSort
|
||||
}
|
||||
|
||||
fun ListItemSortedList.recalcPositions(itemIds: Collection<Int> = this.map { it.id }) {
|
||||
itemIds.forEach { id -> this.recalculatePositionOfItemAt(this.findById(id)!!.first) }
|
||||
}
|
||||
|
||||
fun <R> ListItemSortedList.map(transform: (ListItem) -> R): List<R> {
|
||||
return (0 until this.size()).map { transform.invoke(this[it]) }
|
||||
}
|
||||
|
||||
fun <R> ListItemSortedList.mapIndexed(transform: (Int, ListItem) -> R): List<R> {
|
||||
return (0 until this.size()).mapIndexed { idx, it -> transform.invoke(idx, this[it]) }
|
||||
}
|
||||
|
||||
fun ListItemSortedList.forEach(function: (item: ListItem) -> Unit) {
|
||||
return (0 until this.size()).forEach { function.invoke(this[it]) }
|
||||
}
|
||||
|
||||
fun ListItemSortedList.forEachIndexed(function: (idx: Int, item: ListItem) -> Unit) {
|
||||
for (i in 0 until this.size()) {
|
||||
function.invoke(i, this[i])
|
||||
}
|
||||
}
|
||||
|
||||
fun ListItemSortedList.filter(function: (item: ListItem) -> Boolean): List<ListItem> {
|
||||
val list = mutableListOf<ListItem>()
|
||||
for (i in 0 until this.size()) {
|
||||
if (function.invoke(this[i])) {
|
||||
list.add(this[i])
|
||||
}
|
||||
}
|
||||
return list.toList()
|
||||
}
|
||||
|
||||
fun ListItemSortedList.find(function: (item: ListItem) -> Boolean): ListItem? {
|
||||
for (i in 0 until this.size()) {
|
||||
if (function.invoke(this[i])) {
|
||||
return this[i]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun ListItemSortedList.indexOfFirst(function: (item: ListItem) -> Boolean): Int? {
|
||||
for (i in 0 until this.size()) {
|
||||
if (function.invoke(this[i])) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
val ListItemSortedList.lastIndex: Int
|
||||
get() = this.size() - 1
|
||||
|
||||
val ListItemSortedList.indices: IntRange
|
||||
get() = (0 until this.size())
|
||||
|
||||
fun ListItemSortedList.isNotEmpty(): Boolean {
|
||||
return size() > 0
|
||||
}
|
||||
|
||||
fun ListItemSortedList.isEmpty(): Boolean {
|
||||
return size() == 0
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package com.philkes.notallyx.presentation.view.note.listitem.sorting
|
||||
|
||||
import androidx.recyclerview.widget.SortedList
|
||||
import com.philkes.notallyx.data.model.ListItem
|
||||
|
||||
class SortedItemsList(val callback: ListItemParentSortCallback) :
|
||||
SortedList<ListItem>(ListItem::class.java, callback) {
|
||||
|
||||
init {
|
||||
this.callback.setItems(this)
|
||||
}
|
||||
}
|
|
@ -4,13 +4,15 @@ import android.app.Application
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.map
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.room.withTransaction
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
|
@ -27,7 +29,6 @@ import com.philkes.notallyx.data.imports.NotesImporter
|
|||
import com.philkes.notallyx.data.model.Attachment
|
||||
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.Content
|
||||
import com.philkes.notallyx.data.model.Converters
|
||||
import com.philkes.notallyx.data.model.FileAttachment
|
||||
|
@ -37,14 +38,20 @@ import com.philkes.notallyx.data.model.Item
|
|||
import com.philkes.notallyx.data.model.Label
|
||||
import com.philkes.notallyx.data.model.SearchResult
|
||||
import com.philkes.notallyx.data.model.toNoteIdReminders
|
||||
import com.philkes.notallyx.presentation.activity.main.fragment.settings.SettingsFragment.Companion.EXTRA_SHOW_IMPORT_BACKUPS_FOLDER
|
||||
import com.philkes.notallyx.presentation.getQuantityString
|
||||
import com.philkes.notallyx.presentation.restartApplication
|
||||
import com.philkes.notallyx.presentation.setCancelButton
|
||||
import com.philkes.notallyx.presentation.showToast
|
||||
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
|
||||
import com.philkes.notallyx.presentation.view.misc.Progress
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.BasePreference
|
||||
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.NotallyXPreferences.Companion.START_VIEW_DEFAULT
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.START_VIEW_UNLABELED
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.Theme
|
||||
import com.philkes.notallyx.utils.ActionMode
|
||||
import com.philkes.notallyx.utils.Cache
|
||||
import com.philkes.notallyx.utils.MIME_TYPE_JSON
|
||||
|
@ -63,7 +70,11 @@ import com.philkes.notallyx.utils.getBackupDir
|
|||
import com.philkes.notallyx.utils.getExternalImagesDirectory
|
||||
import com.philkes.notallyx.utils.log
|
||||
import com.philkes.notallyx.utils.scheduleNoteReminders
|
||||
import com.philkes.notallyx.utils.security.decryptDatabase
|
||||
import com.philkes.notallyx.utils.security.encryptDatabase
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import javax.crypto.Cipher
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -91,11 +102,13 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
|
|||
|
||||
val folder = NotNullLiveData(Folder.NOTES)
|
||||
|
||||
var currentLabel: String? = CURRENT_LABEL_EMPTY
|
||||
|
||||
var keyword = String()
|
||||
set(value) {
|
||||
if (field != value || searchResults?.value?.isEmpty() == true) {
|
||||
field = value
|
||||
searchResults!!.fetch(keyword, folder.value)
|
||||
searchResults!!.fetch(keyword, folder.value, currentLabel)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -103,6 +116,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
|
|||
|
||||
private val pinned = Header(app.getString(R.string.pinned))
|
||||
private val others = Header(app.getString(R.string.others))
|
||||
private val archived = Header(app.getString(R.string.archived))
|
||||
|
||||
val preferences = NotallyXPreferences.getInstance(app)
|
||||
|
||||
|
@ -114,9 +128,14 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
|
|||
|
||||
val actionMode = ActionMode()
|
||||
|
||||
internal var showRefreshBackupsFolderAfterThemeChange = false
|
||||
private var labelsHiddenObserver: Observer<Set<String>>? = null
|
||||
|
||||
init {
|
||||
NotallyDatabase.getDatabase(app).observeForever(::init)
|
||||
folder.observeForever { newFolder -> searchResults!!.fetch(keyword, newFolder) }
|
||||
folder.observeForever { newFolder ->
|
||||
searchResults!!.fetch(keyword, newFolder, currentLabel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun init(database: NotallyDatabase) {
|
||||
|
@ -126,6 +145,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
|
|||
commonDao = database.getCommonDao()
|
||||
|
||||
labels = labelDao.getAll()
|
||||
// colors = baseNoteDao.getAllColorsAsync()
|
||||
reminders = baseNoteDao.getAllRemindersAsync()
|
||||
|
||||
allNotes?.removeObserver(allNotesObserver!!)
|
||||
|
@ -133,11 +153,12 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
|
|||
allNotes = baseNoteDao.getAllAsync()
|
||||
allNotes!!.observeForever(allNotesObserver!!)
|
||||
|
||||
if (baseNotes == null) {
|
||||
baseNotes = Content(baseNoteDao.getFrom(Folder.NOTES), ::transform)
|
||||
} else {
|
||||
baseNotes!!.setObserver(baseNoteDao.getFrom(Folder.NOTES))
|
||||
labelsHiddenObserver?.let { preferences.labelsHidden.removeObserver(it) }
|
||||
labelsHiddenObserver = Observer { labelsHidden ->
|
||||
baseNotes = null
|
||||
initBaseNotes(labelsHidden)
|
||||
}
|
||||
preferences.labelsHidden.observeForever(labelsHiddenObserver!!)
|
||||
|
||||
if (deletedNotes == null) {
|
||||
deletedNotes = Content(baseNoteDao.getFrom(Folder.DELETED), ::transform)
|
||||
|
@ -171,6 +192,18 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
|
|||
}
|
||||
}
|
||||
|
||||
private fun initBaseNotes(labelsHidden: Set<String>) {
|
||||
val overviewNotes =
|
||||
baseNoteDao.getFrom(Folder.NOTES).map { list ->
|
||||
list.filter { baseNote -> baseNote.labels.none { labelsHidden.contains(it) } }
|
||||
}
|
||||
if (baseNotes == null) {
|
||||
baseNotes = Content(overviewNotes, ::transform)
|
||||
} else {
|
||||
baseNotes!!.setObserver(overviewNotes)
|
||||
}
|
||||
}
|
||||
|
||||
fun getNotesByLabel(label: String): Content {
|
||||
if (labelCache[label] == null) {
|
||||
labelCache[label] = Content(baseNoteDao.getBaseNotesByLabel(label), ::transform)
|
||||
|
@ -178,7 +211,11 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
|
|||
return requireNotNull(labelCache[label])
|
||||
}
|
||||
|
||||
private fun transform(list: List<BaseNote>) = transform(list, pinned, others)
|
||||
fun getNotesWithoutLabel(): Content {
|
||||
return Content(baseNoteDao.getBaseNotesWithoutLabel(Folder.NOTES), ::transform)
|
||||
}
|
||||
|
||||
private fun transform(list: List<BaseNote>) = transform(list, pinned, others, archived)
|
||||
|
||||
fun disableBackups() {
|
||||
val value = preferences.backupsFolder.value
|
||||
|
@ -204,39 +241,67 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
|
|||
}
|
||||
savePreference(preferences.backupsFolder, newBackupsFolder)
|
||||
}
|
||||
showRefreshBackupsFolderAfterThemeChange = false
|
||||
}
|
||||
|
||||
fun enableDataInPublic() {
|
||||
fun enableDataInPublic(callback: (() -> Unit)? = null) {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val database = NotallyDatabase.getDatabase(app, observePreferences = false).value
|
||||
database.checkpoint()
|
||||
NotallyDatabase.getInternalDatabaseFile(app)
|
||||
.copyTo(NotallyDatabase.getExternalDatabaseFile(app), overwrite = true)
|
||||
val directory = NotallyDatabase.getExternalDatabaseFile(app).parentFile
|
||||
NotallyDatabase.getInternalDatabaseFiles(app).forEach {
|
||||
it.copyTo(File(directory, it.name), overwrite = true)
|
||||
}
|
||||
// database.close()
|
||||
}
|
||||
savePreference(preferences.dataInPublicFolder, true)
|
||||
callback?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
fun disableDataInPublic() {
|
||||
fun disableDataInPublic(callback: (() -> Unit)? = null) {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val database = NotallyDatabase.getDatabase(app, observePreferences = false).value
|
||||
database.checkpoint()
|
||||
NotallyDatabase.getExternalDatabaseFile(app)
|
||||
.copyTo(NotallyDatabase.getInternalDatabaseFile(app), overwrite = true)
|
||||
NotallyDatabase.getExternalDatabaseFiles(app).forEach {
|
||||
val directory = NotallyDatabase.getInternalDatabaseFile(app).parentFile
|
||||
val oldFiles = NotallyDatabase.getExternalDatabaseFiles(app)
|
||||
oldFiles.forEach { it.copyTo(File(directory, it.name), overwrite = true) }
|
||||
// database.close()
|
||||
oldFiles.forEach {
|
||||
if (it.exists()) {
|
||||
it.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
savePreference(preferences.dataInPublicFolder, false)
|
||||
callback?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
fun enableBiometricLock(cipher: Cipher) {
|
||||
savePreference(preferences.iv, cipher.iv)
|
||||
val passphrase = preferences.databaseEncryptionKey.init(cipher)
|
||||
encryptDatabase(app, passphrase)
|
||||
savePreference(preferences.fallbackDatabaseEncryptionKey, passphrase)
|
||||
savePreference(preferences.biometricLock, BiometricLock.ENABLED)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
fun disableBiometricLock(cipher: Cipher? = null, callback: (() -> Unit)? = null) {
|
||||
val encryptedPassphrase = preferences.databaseEncryptionKey.value
|
||||
val passphrase =
|
||||
cipher?.doFinal(encryptedPassphrase)
|
||||
?: preferences.fallbackDatabaseEncryptionKey.value!!
|
||||
database.close()
|
||||
decryptDatabase(app, passphrase)
|
||||
savePreference(preferences.biometricLock, BiometricLock.DISABLED)
|
||||
callback?.invoke()
|
||||
}
|
||||
|
||||
fun <T> savePreference(preference: BasePreference<T>, value: T) {
|
||||
executeAsync { preference.save(value) }
|
||||
viewModelScope.launch(Dispatchers.IO) { preference.save(value) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -270,7 +335,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
|
|||
fun importZipBackup(uri: Uri, password: String) {
|
||||
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
app.log(TAG, throwable = throwable)
|
||||
app.showToast(R.string.invalid_backup)
|
||||
app.showToast("${app.getString(R.string.invalid_backup)}: ${throwable.message}")
|
||||
}
|
||||
|
||||
val backupDir = app.getBackupDir()
|
||||
|
@ -282,7 +347,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
|
|||
fun importXmlBackup(uri: Uri) {
|
||||
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
app.log(TAG, throwable = throwable)
|
||||
app.showToast(R.string.invalid_backup)
|
||||
app.showToast("${app.getString(R.string.invalid_backup)}: ${throwable.message}")
|
||||
}
|
||||
|
||||
viewModelScope.launch(exceptionHandler) {
|
||||
|
@ -299,19 +364,16 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
|
|||
}
|
||||
|
||||
fun importFromOtherApp(uri: Uri, importSource: ImportSource) {
|
||||
val database = NotallyDatabase.getDatabase(app).value
|
||||
|
||||
val database = NotallyDatabase.getDatabase(app, observePreferences = false).value
|
||||
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
Toast.makeText(
|
||||
app,
|
||||
if (throwable is ImportException) {
|
||||
throwable.textResId
|
||||
} else R.string.invalid_backup,
|
||||
Toast.LENGTH_LONG,
|
||||
)
|
||||
.show()
|
||||
app.log(TAG, throwable = throwable)
|
||||
if (throwable is ImportException) {
|
||||
app.showToast(throwable.textResId)
|
||||
} else {
|
||||
app.showToast("${app.getString(R.string.invalid_backup)}: ${throwable.message}")
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch(exceptionHandler) {
|
||||
val importedNotes =
|
||||
withContext(Dispatchers.IO) {
|
||||
|
@ -333,7 +395,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
|
|||
}
|
||||
}
|
||||
|
||||
fun exportSelectedNotesToFolder(folderUri: Uri) {
|
||||
fun exportNotesToFolder(folderUri: Uri, notes: Collection<BaseNote>) {
|
||||
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
app.log(TAG, throwable = throwable)
|
||||
actionMode.close(true)
|
||||
|
@ -341,7 +403,6 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
|
|||
app.showToast(R.string.something_went_wrong)
|
||||
}
|
||||
viewModelScope.launch(exceptionHandler) {
|
||||
val notes = actionMode.selectedNotes.values
|
||||
val counter = AtomicInteger(0)
|
||||
for (note in notes) {
|
||||
exportProgress.postValue(Progress(total = notes.size))
|
||||
|
@ -378,16 +439,24 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
|
|||
}
|
||||
}
|
||||
|
||||
fun exportSelectedNotesToFolder(folderUri: Uri) {
|
||||
exportNotesToFolder(folderUri, actionMode.selectedNotes.values)
|
||||
}
|
||||
|
||||
fun pinBaseNotes(pinned: Boolean) {
|
||||
val id = actionMode.selectedIds.toLongArray()
|
||||
actionMode.close(true)
|
||||
executeAsync { baseNoteDao.updatePinned(id, pinned) }
|
||||
viewModelScope.launch(Dispatchers.IO) { baseNoteDao.updatePinned(id, pinned) }
|
||||
}
|
||||
|
||||
fun colorBaseNote(color: Color) {
|
||||
fun colorBaseNote(color: String) {
|
||||
val ids = actionMode.selectedIds.toLongArray()
|
||||
actionMode.close(true)
|
||||
executeAsync { baseNoteDao.updateColor(ids, color) }
|
||||
viewModelScope.launch(Dispatchers.IO) { baseNoteDao.updateColor(ids, color) }
|
||||
}
|
||||
|
||||
fun changeColor(oldColor: String, newColor: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) { baseNoteDao.updateColor(oldColor, newColor) }
|
||||
}
|
||||
|
||||
fun moveBaseNotes(folder: Folder): LongArray {
|
||||
|
@ -398,7 +467,9 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
|
|||
}
|
||||
|
||||
fun moveBaseNotes(ids: LongArray, folder: Folder) {
|
||||
executeAsync {
|
||||
viewModelScope.launch(
|
||||
Dispatchers.IO
|
||||
) { // Only reminders of notes in NOTES folder are active
|
||||
baseNoteDao.move(ids, folder)
|
||||
val notes = baseNoteDao.getByIds(ids).toNoteIdReminders()
|
||||
// Only reminders of notes in NOTES folder are active
|
||||
|
@ -411,7 +482,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
|
|||
|
||||
fun updateBaseNoteLabels(labels: List<String>, id: Long) {
|
||||
actionMode.close(true)
|
||||
executeAsync { baseNoteDao.updateLabels(id, labels) }
|
||||
viewModelScope.launch(Dispatchers.IO) { baseNoteDao.updateLabels(id, labels) }
|
||||
}
|
||||
|
||||
fun deleteSelectedBaseNotes() {
|
||||
|
@ -427,6 +498,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
|
|||
app.cancelNoteReminders(noteReminders)
|
||||
deleteBaseNotes(ids)
|
||||
withContext(Dispatchers.IO) { labelDao.deleteAll() }
|
||||
savePreference(preferences.startView, START_VIEW_DEFAULT)
|
||||
app.showToast(R.string.cleared_data)
|
||||
}
|
||||
}
|
||||
|
@ -477,13 +549,16 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
|
|||
suspend fun getAllLabels() = withContext(Dispatchers.IO) { labelDao.getArrayOfAll() }
|
||||
|
||||
fun deleteLabel(value: String) {
|
||||
executeAsync { commonDao.deleteLabel(value) }
|
||||
val labelsHiddenPreference = preferences.labelsHiddenInNavigation
|
||||
viewModelScope.launch(Dispatchers.IO) { commonDao.deleteLabel(value) }
|
||||
val labelsHiddenPreference = preferences.labelsHidden
|
||||
val labelsHidden = labelsHiddenPreference.value.toMutableSet()
|
||||
if (labelsHidden.contains(value)) {
|
||||
labelsHidden.remove(value)
|
||||
savePreference(labelsHiddenPreference, labelsHidden)
|
||||
}
|
||||
if (preferences.startView.value == value) {
|
||||
savePreference(preferences.startView, START_VIEW_DEFAULT)
|
||||
}
|
||||
}
|
||||
|
||||
fun insertLabel(label: Label, onComplete: (success: Boolean) -> Unit) =
|
||||
|
@ -491,7 +566,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
|
|||
|
||||
fun updateLabel(oldValue: String, newValue: String, onComplete: (success: Boolean) -> Unit) {
|
||||
executeAsyncWithCallback({ commonDao.updateLabel(oldValue, newValue) }, onComplete)
|
||||
val labelsHiddenPreference = preferences.labelsHiddenInNavigation
|
||||
val labelsHiddenPreference = preferences.labelsHidden
|
||||
val labelsHidden = labelsHiddenPreference.value.toMutableSet()
|
||||
if (labelsHidden.contains(oldValue)) {
|
||||
labelsHidden.remove(oldValue)
|
||||
|
@ -500,91 +575,200 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
|
|||
}
|
||||
}
|
||||
|
||||
fun closeDatabase() {
|
||||
database.close()
|
||||
}
|
||||
|
||||
private fun executeAsync(function: suspend () -> Unit) {
|
||||
viewModelScope.launch(Dispatchers.IO) { function() }
|
||||
}
|
||||
|
||||
fun resetPreferences() {
|
||||
fun resetPreferences(callback: (restartRequired: Boolean) -> Unit) {
|
||||
val backupsFolder = preferences.backupsFolder.value
|
||||
val publicFolder = preferences.dataInPublicFolder.value
|
||||
val isThemeDefault = preferences.theme.value == Theme.FOLLOW_SYSTEM
|
||||
val finishCallback = { callback(!isThemeDefault) }
|
||||
if (preferences.biometricLock.value == BiometricLock.ENABLED) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
disableBiometricLock {
|
||||
finishResetPreferencesAfterBiometric(
|
||||
publicFolder,
|
||||
backupsFolder,
|
||||
finishCallback,
|
||||
)
|
||||
}
|
||||
} else finishResetPreferencesAfterBiometric(publicFolder, backupsFolder, finishCallback)
|
||||
} else finishResetPreferencesAfterBiometric(publicFolder, backupsFolder, finishCallback)
|
||||
}
|
||||
|
||||
private fun finishResetPreferencesAfterBiometric(
|
||||
publicFolder: Boolean,
|
||||
backupsFolder: String,
|
||||
callback: (() -> Unit),
|
||||
) {
|
||||
if (publicFolder) {
|
||||
refreshDataInPublicFolder(false) { finishResetPreferences(backupsFolder, callback) }
|
||||
} else finishResetPreferences(backupsFolder, callback)
|
||||
}
|
||||
|
||||
private fun finishResetPreferences(backupsFolder: String, callback: () -> Unit) {
|
||||
preferences.reset()
|
||||
refreshDataInPublicFolder()
|
||||
if (backupsFolder != EMPTY_PATH) {
|
||||
clearPersistedUriPermissions(backupsFolder)
|
||||
}
|
||||
callback()
|
||||
app.restartApplication(R.id.Settings)
|
||||
}
|
||||
|
||||
fun importPreferences(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
askForUriPermissions: (uri: Uri) -> Unit,
|
||||
): Boolean {
|
||||
onSuccess: () -> Unit,
|
||||
onFailure: () -> Unit,
|
||||
) {
|
||||
val oldBackupsFolder = preferences.backupsFolder.value
|
||||
val dataInPublicFolderBefore = preferences.dataInPublicFolder.value
|
||||
val themeBefore = preferences.theme.value
|
||||
val useDynamicColorsBefore = preferences.useDynamicColors.value
|
||||
val oldStartView = preferences.startView.value
|
||||
|
||||
val success = preferences.import(context, uri)
|
||||
refreshDataInPublicFolder()
|
||||
val backupFolder = preferences.backupsFolder.getFreshValue()
|
||||
if (oldBackupsFolder != backupFolder) {
|
||||
refreshBackupsFolder(context, backupFolder, askForUriPermissions)
|
||||
}
|
||||
return success
|
||||
|
||||
val dataInPublicFolder = preferences.dataInPublicFolder.getFreshValue()
|
||||
if (dataInPublicFolderBefore != dataInPublicFolder) {
|
||||
refreshDataInPublicFolder(dataInPublicFolder) {
|
||||
preferences.dataInPublicFolder.refresh()
|
||||
finishImportPreferences(
|
||||
oldBackupsFolder,
|
||||
themeBefore,
|
||||
useDynamicColorsBefore,
|
||||
oldStartView,
|
||||
context,
|
||||
askForUriPermissions,
|
||||
) {
|
||||
if (success) {
|
||||
onSuccess()
|
||||
} else onFailure()
|
||||
}
|
||||
}
|
||||
} else
|
||||
finishImportPreferences(
|
||||
oldBackupsFolder,
|
||||
themeBefore,
|
||||
useDynamicColorsBefore,
|
||||
oldStartView,
|
||||
context,
|
||||
askForUriPermissions,
|
||||
) {
|
||||
if (success) {
|
||||
onSuccess()
|
||||
} else onFailure()
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshBackupsFolder(
|
||||
private fun finishImportPreferences(
|
||||
oldBackupsFolder: String,
|
||||
themeBefore: Theme,
|
||||
useDynamicColorsBefore: Boolean,
|
||||
oldStartView: String,
|
||||
context: Context,
|
||||
backupFolder: String,
|
||||
askForUriPermissions: (uri: Uri) -> Unit,
|
||||
callback: () -> Unit,
|
||||
) {
|
||||
val backupFolder = preferences.backupsFolder.getFreshValue()
|
||||
val hasUseDynamicColorsChange =
|
||||
useDynamicColorsBefore != preferences.useDynamicColors.getFreshValue()
|
||||
if (oldBackupsFolder != backupFolder) {
|
||||
showRefreshBackupsFolderAfterThemeChange = true
|
||||
if (themeBefore == preferences.theme.getFreshValue() && !hasUseDynamicColorsChange) {
|
||||
refreshBackupsFolder(context, backupFolder, askForUriPermissions)
|
||||
}
|
||||
} else {
|
||||
showRefreshBackupsFolderAfterThemeChange = false
|
||||
}
|
||||
val startView = preferences.startView.getFreshValue()
|
||||
if (oldStartView != startView) {
|
||||
refreshStartView(startView, oldStartView)
|
||||
}
|
||||
preferences.theme.refresh()
|
||||
callback()
|
||||
if (showRefreshBackupsFolderAfterThemeChange) {
|
||||
app.restartApplication(R.id.Settings, EXTRA_SHOW_IMPORT_BACKUPS_FOLDER to true)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshBackupsFolder(
|
||||
context: Context,
|
||||
backupFolder: String = preferences.backupsFolder.value,
|
||||
askForUriPermissions: (uri: Uri) -> Unit,
|
||||
) {
|
||||
try {
|
||||
val backupFolderUri = backupFolder.toUri()
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setMessage(R.string.auto_backups_folder_rechoose)
|
||||
.setCancelButton()
|
||||
.setCancelButton { _, _ -> showRefreshBackupsFolderAfterThemeChange = false }
|
||||
.setOnDismissListener { showRefreshBackupsFolderAfterThemeChange = false }
|
||||
.setPositiveButton(R.string.choose_folder) { _, _ ->
|
||||
askForUriPermissions(backupFolderUri)
|
||||
}
|
||||
.show()
|
||||
} catch (e: Exception) {
|
||||
showRefreshBackupsFolderAfterThemeChange = false
|
||||
disableBackups()
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshDataInPublicFolder() {
|
||||
val dataInPublicFolderBefore = preferences.dataInPublicFolder.value
|
||||
val dataInPublicFolderAfter = preferences.dataInPublicFolder.getFreshValue()
|
||||
if (dataInPublicFolderBefore != dataInPublicFolderAfter) {
|
||||
if (dataInPublicFolderAfter) {
|
||||
enableDataInPublic()
|
||||
} else {
|
||||
disableDataInPublic()
|
||||
private fun refreshDataInPublicFolder(dataInPublicFolder: Boolean, callback: () -> Unit) {
|
||||
if (dataInPublicFolder) {
|
||||
enableDataInPublic(callback)
|
||||
} else {
|
||||
disableDataInPublic(callback)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshStartView(startView: String, oldStartView: String) {
|
||||
if (startView in setOf(START_VIEW_DEFAULT, START_VIEW_UNLABELED)) {
|
||||
savePreference(preferences.startView, startView)
|
||||
} else {
|
||||
viewModelScope.launch {
|
||||
val startViewLabelExists =
|
||||
withContext(Dispatchers.IO) { labelDao.exists(startView) }
|
||||
savePreference(
|
||||
preferences.startView,
|
||||
if (startViewLabelExists) startView else oldStartView,
|
||||
)
|
||||
}
|
||||
}
|
||||
preferences.dataInPublicFolder.refresh()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BaseNoteModel"
|
||||
|
||||
fun transform(list: List<BaseNote>, pinned: Header, others: Header): List<Item> {
|
||||
const val CURRENT_LABEL_EMPTY = ""
|
||||
val CURRENT_LABEL_NONE: String? = null
|
||||
|
||||
fun transform(
|
||||
list: List<BaseNote>,
|
||||
pinned: Header,
|
||||
others: Header,
|
||||
archived: Header,
|
||||
): List<Item> {
|
||||
if (list.isEmpty()) {
|
||||
return list
|
||||
} else {
|
||||
val firstNote = list[0]
|
||||
return if (firstNote.pinned) {
|
||||
val newList = ArrayList<Item>(list.size + 2)
|
||||
newList.add(pinned)
|
||||
|
||||
val firstUnpinnedNote = list.indexOfFirst { baseNote -> !baseNote.pinned }
|
||||
list.forEachIndexed { index, baseNote ->
|
||||
if (index == firstUnpinnedNote) {
|
||||
newList.add(others)
|
||||
}
|
||||
newList.add(baseNote)
|
||||
val firstPinnedNote = list.indexOfFirst { baseNote -> baseNote.pinned }
|
||||
val firstUnpinnedNote =
|
||||
list.indexOfFirst { baseNote ->
|
||||
!baseNote.pinned && baseNote.folder != Folder.ARCHIVED
|
||||
}
|
||||
newList
|
||||
} else list
|
||||
val mutableList: MutableList<Item> = list.toMutableList()
|
||||
if (firstPinnedNote != -1) {
|
||||
mutableList.add(firstPinnedNote, pinned)
|
||||
if (firstUnpinnedNote != -1) {
|
||||
mutableList.add(firstUnpinnedNote + 1, others)
|
||||
}
|
||||
}
|
||||
val firstArchivedNote =
|
||||
mutableList.indexOfFirst { item ->
|
||||
item is BaseNote && item.folder == Folder.ARCHIVED
|
||||
}
|
||||
if (firstArchivedNote != -1) {
|
||||
mutableList.add(firstArchivedNote, archived)
|
||||
}
|
||||
return mutableList
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
package com.philkes.notallyx.presentation.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.philkes.notallyx.data.NotallyDatabase
|
||||
import com.philkes.notallyx.data.dao.LabelDao
|
||||
import com.philkes.notallyx.data.model.Label
|
||||
|
||||
class LabelModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private lateinit var labelDao: LabelDao
|
||||
lateinit var labels: LiveData<List<String>>
|
||||
|
||||
init {
|
||||
NotallyDatabase.getDatabase(app).observeForever {
|
||||
labelDao = it.getLabelDao()
|
||||
labels = labelDao.getAll()
|
||||
}
|
||||
}
|
||||
|
||||
fun insertLabel(label: Label, onComplete: (success: Boolean) -> Unit) =
|
||||
executeAsyncWithCallback({ labelDao.insert(label) }, onComplete)
|
||||
}
|
|
@ -19,12 +19,14 @@ import com.philkes.notallyx.R
|
|||
import com.philkes.notallyx.data.NotallyDatabase
|
||||
import com.philkes.notallyx.data.dao.BaseNoteDao
|
||||
import com.philkes.notallyx.data.dao.NoteIdReminder
|
||||
import com.philkes.notallyx.data.imports.txt.extractListItems
|
||||
import com.philkes.notallyx.data.imports.txt.findListSyntaxRegex
|
||||
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.Reminder
|
||||
import com.philkes.notallyx.data.model.SpanRepresentation
|
||||
import com.philkes.notallyx.data.model.Type
|
||||
|
@ -41,7 +43,7 @@ import com.philkes.notallyx.presentation.widget.WidgetProvider
|
|||
import com.philkes.notallyx.utils.Cache
|
||||
import com.philkes.notallyx.utils.Event
|
||||
import com.philkes.notallyx.utils.FileError
|
||||
import com.philkes.notallyx.utils.backup.checkAutoSave
|
||||
import com.philkes.notallyx.utils.backup.checkBackupOnSave
|
||||
import com.philkes.notallyx.utils.backup.importAudio
|
||||
import com.philkes.notallyx.utils.backup.importFile
|
||||
import com.philkes.notallyx.utils.cancelNoteReminders
|
||||
|
@ -73,7 +75,7 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
|
|||
|
||||
var id = 0L
|
||||
var folder = Folder.NOTES
|
||||
var color = Color.DEFAULT
|
||||
var color = BaseNote.COLOR_DEFAULT
|
||||
|
||||
var title = String()
|
||||
var pinned = false
|
||||
|
@ -85,10 +87,13 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
|
|||
var body: Editable = SpannableStringBuilder()
|
||||
|
||||
val items = ArrayList<ListItem>()
|
||||
|
||||
val images = NotNullLiveData<List<FileAttachment>>(emptyList())
|
||||
val files = NotNullLiveData<List<FileAttachment>>(emptyList())
|
||||
val audios = NotNullLiveData<List<Audio>>(emptyList())
|
||||
|
||||
val reminders = NotNullLiveData<List<Reminder>>(emptyList())
|
||||
val viewMode = NotNullLiveData(NoteViewMode.EDIT)
|
||||
|
||||
val addingFiles = MutableLiveData<Progress>()
|
||||
val eventBus = MutableLiveData<Event<List<FileError>>>()
|
||||
|
@ -97,7 +102,7 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
|
|||
var audioRoot = app.getExternalAudioDirectory()
|
||||
var filesRoot = app.getExternalFilesDirectory()
|
||||
|
||||
private lateinit var originalNote: BaseNote
|
||||
var originalNote: BaseNote? = null
|
||||
|
||||
init {
|
||||
database.observeForever { baseNoteDao = it.getBaseNoteDao() }
|
||||
|
@ -247,6 +252,7 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
|
|||
files.value = baseNote.files
|
||||
audios.value = baseNote.audios
|
||||
reminders.value = baseNote.reminders
|
||||
viewMode.value = baseNote.viewMode
|
||||
} else {
|
||||
originalNote = createBaseNote()
|
||||
app.showToast(R.string.cant_find_note)
|
||||
|
@ -269,7 +275,7 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
|
|||
withContext(Dispatchers.IO) { app.deleteAttachments(attachments) }
|
||||
}
|
||||
if (checkAutoSave) {
|
||||
app.checkAutoSave(preferences, forceFullBackup = true)
|
||||
app.checkBackupOnSave(preferences, forceFullBackup = true)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -278,19 +284,26 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
|
|||
this.items.addAll(items)
|
||||
}
|
||||
|
||||
suspend fun saveNote(): Long {
|
||||
suspend fun saveNote(checkBackupOnSave: Boolean = true): Long {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val note = getBaseNote()
|
||||
val id = baseNoteDao.insert(note)
|
||||
app.checkAutoSave(
|
||||
preferences,
|
||||
note = note,
|
||||
forceFullBackup = originalNote.attachmentsDifferFrom(note),
|
||||
)
|
||||
if (checkBackupOnSave) {
|
||||
checkBackupOnSave(note)
|
||||
}
|
||||
originalNote = note.deepCopy()
|
||||
return@withContext id
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun checkBackupOnSave(note: BaseNote = getBaseNote()) {
|
||||
app.checkBackupOnSave(
|
||||
preferences,
|
||||
note = note,
|
||||
forceFullBackup = originalNote?.attachmentsDifferFrom(note) == true,
|
||||
)
|
||||
}
|
||||
|
||||
fun isEmpty(): Boolean {
|
||||
return title.isEmpty() &&
|
||||
body.isEmpty() &&
|
||||
|
@ -316,7 +329,7 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
|
|||
withContext(Dispatchers.IO) { baseNoteDao.updateAudios(id, audios.value) }
|
||||
}
|
||||
|
||||
private fun getBaseNote(): BaseNote {
|
||||
fun getBaseNote(): BaseNote {
|
||||
val spans = getFilteredSpans(body)
|
||||
val body = this.body.toString()
|
||||
val nonEmptyItems = this.items.filter { item -> item.body.isNotEmpty() }
|
||||
|
@ -337,6 +350,7 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
|
|||
files.value,
|
||||
audios.value,
|
||||
reminders.value,
|
||||
viewMode.value,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -435,6 +449,34 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
|
|||
withContext(Dispatchers.IO) { baseNoteDao.updateReminders(id, updatedReminders) }
|
||||
}
|
||||
|
||||
suspend fun convertTo(noteType: Type) {
|
||||
when (noteType) {
|
||||
Type.NOTE -> {
|
||||
body = SpannableStringBuilder(items.joinToString(separator = "\n") { it.body })
|
||||
type = Type.NOTE
|
||||
setItems(ArrayList())
|
||||
}
|
||||
Type.LIST -> {
|
||||
val text = body.toString()
|
||||
val listSyntaxRegex =
|
||||
text.findListSyntaxRegex(checkContains = true, plainNewLineAllowed = true)
|
||||
if (listSyntaxRegex != null) {
|
||||
setItems(text.extractListItems(listSyntaxRegex))
|
||||
} else {
|
||||
setItems(
|
||||
text.lines().mapIndexed { idx, itemText ->
|
||||
ListItem(itemText, false, false, idx, mutableListOf())
|
||||
}
|
||||
)
|
||||
}
|
||||
type = Type.LIST
|
||||
body = SpannableStringBuilder()
|
||||
}
|
||||
}
|
||||
Cache.list = ArrayList()
|
||||
saveNote(checkBackupOnSave = false)
|
||||
}
|
||||
|
||||
enum class FileType {
|
||||
IMAGE,
|
||||
ANY,
|
||||
|
|
|
@ -29,6 +29,7 @@ class NotallyXPreferences private constructor(private val context: Context) {
|
|||
}
|
||||
|
||||
val theme = createEnumPreference(preferences, "theme", Theme.FOLLOW_SYSTEM, R.string.theme)
|
||||
val useDynamicColors = BooleanPreference("useDynamicColors", preferences, false)
|
||||
val textSize =
|
||||
createEnumPreference(preferences, "textSize", TextSize.MEDIUM, R.string.text_size)
|
||||
val dateFormat =
|
||||
|
@ -38,12 +39,14 @@ class NotallyXPreferences private constructor(private val context: Context) {
|
|||
|
||||
val notesView = createEnumPreference(preferences, "view", NotesView.LIST, R.string.view)
|
||||
val notesSorting = NotesSortPreference(preferences)
|
||||
val startView =
|
||||
StringPreference("startView", preferences, START_VIEW_DEFAULT, R.string.start_view)
|
||||
val listItemSorting =
|
||||
createEnumPreference(
|
||||
preferences,
|
||||
"checkedListItemSorting",
|
||||
ListItemSort.NO_AUTO_SORT,
|
||||
R.string.checked_list_item_sorting,
|
||||
"listItemSorting",
|
||||
ListItemSort.AUTO_SORT_BY_CHECKED,
|
||||
R.string.list_item_auto_sort,
|
||||
)
|
||||
|
||||
val maxItems =
|
||||
|
@ -73,21 +76,27 @@ class NotallyXPreferences private constructor(private val context: Context) {
|
|||
10,
|
||||
R.string.max_lines_to_display_title,
|
||||
)
|
||||
val labelsHiddenInNavigation =
|
||||
StringSetPreference("labelsHiddenInNavigation", preferences, setOf())
|
||||
val labelsHiddenInOverview =
|
||||
val labelsHidden = StringSetPreference("labelsHiddenInNavigation", preferences, setOf())
|
||||
val labelTagsHiddenInOverview =
|
||||
BooleanPreference(
|
||||
"labelsHiddenInOverview",
|
||||
preferences,
|
||||
false,
|
||||
R.string.labels_hidden_in_overview_title,
|
||||
)
|
||||
val imagesHiddenInOverview =
|
||||
BooleanPreference(
|
||||
"imagesHiddenInOverview",
|
||||
preferences,
|
||||
false,
|
||||
R.string.images_hidden_in_overview_title,
|
||||
)
|
||||
val maxLabels =
|
||||
IntPreference(
|
||||
"maxLabelsInNavigation",
|
||||
preferences,
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
20,
|
||||
R.string.max_labels_to_display,
|
||||
)
|
||||
|
@ -109,6 +118,16 @@ class NotallyXPreferences private constructor(private val context: Context) {
|
|||
)
|
||||
}
|
||||
|
||||
val autoSaveAfterIdleTime =
|
||||
IntPreference(
|
||||
"autoSaveAfterIdleTime",
|
||||
preferences,
|
||||
5,
|
||||
-1,
|
||||
20,
|
||||
R.string.auto_save_after_idle_time,
|
||||
)
|
||||
|
||||
val biometricLock =
|
||||
createEnumPreference(
|
||||
preferences,
|
||||
|
@ -123,6 +142,8 @@ class NotallyXPreferences private constructor(private val context: Context) {
|
|||
val fallbackDatabaseEncryptionKey by lazy {
|
||||
ByteArrayPreference("fallback_database_encryption_key", encryptedPreferences, ByteArray(0))
|
||||
}
|
||||
val secureFlag =
|
||||
BooleanPreference("secureFlag", preferences, false, R.string.disallow_screenshots)
|
||||
|
||||
val dataInPublicFolder =
|
||||
BooleanPreference("dataOnExternalStorage", preferences, false, R.string.data_in_public)
|
||||
|
@ -191,15 +212,17 @@ class NotallyXPreferences private constructor(private val context: Context) {
|
|||
context.importPreferences(uri, preferences.edit()).also { reload() }
|
||||
|
||||
fun reset() {
|
||||
preferences.edit().clear().apply()
|
||||
preferences.edit().clear().commit()
|
||||
encryptedPreferences.edit().clear().apply()
|
||||
backupsFolder.refresh()
|
||||
dataInPublicFolder.refresh()
|
||||
theme.refresh()
|
||||
reload()
|
||||
startView.refresh()
|
||||
}
|
||||
|
||||
private fun reload() {
|
||||
setOf(
|
||||
theme,
|
||||
textSize,
|
||||
dateFormat,
|
||||
applyDateFormatInNoteView,
|
||||
|
@ -209,12 +232,15 @@ class NotallyXPreferences private constructor(private val context: Context) {
|
|||
maxItems,
|
||||
maxLines,
|
||||
maxTitle,
|
||||
labelsHiddenInNavigation,
|
||||
labelsHiddenInOverview,
|
||||
secureFlag,
|
||||
labelsHidden,
|
||||
labelTagsHiddenInOverview,
|
||||
maxLabels,
|
||||
periodicBackups,
|
||||
backupPassword,
|
||||
biometricLock,
|
||||
backupOnSave,
|
||||
autoSaveAfterIdleTime,
|
||||
imagesHiddenInOverview,
|
||||
)
|
||||
.forEach { it.refresh() }
|
||||
}
|
||||
|
@ -222,6 +248,8 @@ class NotallyXPreferences private constructor(private val context: Context) {
|
|||
companion object {
|
||||
private const val TAG = "NotallyXPreferences"
|
||||
const val EMPTY_PATH = "emptyPath"
|
||||
const val START_VIEW_DEFAULT = ""
|
||||
const val START_VIEW_UNLABELED = "com.philkes.notallyx.startview.UNLABELED"
|
||||
|
||||
@Volatile private var instance: NotallyXPreferences? = null
|
||||
|
||||
|
@ -235,3 +263,6 @@ class NotallyXPreferences private constructor(private val context: Context) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
val NotallyXPreferences.autoSortByCheckedEnabled
|
||||
get() = listItemSorting.value == ListItemSort.AUTO_SORT_BY_CHECKED
|
||||
|
|
|
@ -49,7 +49,8 @@ enum class SortDirection(val textResId: Int, val iconResId: Int) {
|
|||
enum class NotesSortBy(val textResId: Int, val iconResId: Int, val value: String) {
|
||||
TITLE(R.string.title, R.drawable.sort_by_alpha, "autoSortByTitle"),
|
||||
CREATION_DATE(R.string.creation_date, R.drawable.calendar_add_on, "autoSortByCreationDate"),
|
||||
MODIFIED_DATE(R.string.modified_date, R.drawable.edit_calendar, "autoSortByModifiedDate");
|
||||
MODIFIED_DATE(R.string.modified_date, R.drawable.edit_calendar, "autoSortByModifiedDate"),
|
||||
COLOR(R.string.color, R.drawable.change_color, "autoSortByColor");
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: String): NotesSortBy? {
|
||||
|
|
|
@ -5,6 +5,7 @@ import android.content.SharedPreferences
|
|||
import android.os.Build
|
||||
import androidx.core.content.edit
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import com.philkes.notallyx.R
|
||||
|
@ -66,6 +67,14 @@ abstract class BasePreference<T>(
|
|||
return getData().merge(other.getData())
|
||||
}
|
||||
|
||||
fun <C> merge(other: LiveData<C>): MediatorLiveData<Pair<T, C?>> {
|
||||
return getData().merge(other)
|
||||
}
|
||||
|
||||
fun <C> merge(other: NotNullLiveData<C>): MediatorLiveData<Pair<T, C>> {
|
||||
return getData().merge(other)
|
||||
}
|
||||
|
||||
fun observeForever(observer: Observer<T>) {
|
||||
getData().observeForever(observer)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package com.philkes.notallyx.presentation.widget
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Paint
|
||||
import android.os.Build
|
||||
import android.util.TypedValue
|
||||
|
@ -11,9 +11,13 @@ import com.philkes.notallyx.NotallyXApplication
|
|||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.NotallyDatabase
|
||||
import com.philkes.notallyx.data.model.BaseNote
|
||||
import com.philkes.notallyx.data.model.ListItem
|
||||
import com.philkes.notallyx.data.model.Type
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
|
||||
import com.philkes.notallyx.presentation.widget.WidgetProvider.Companion.getSelectNoteIntent
|
||||
import com.philkes.notallyx.presentation.widget.WidgetProvider.Companion.extractWidgetColors
|
||||
import com.philkes.notallyx.presentation.widget.WidgetProvider.Companion.getWidgetCheckedChangeIntent
|
||||
import com.philkes.notallyx.presentation.widget.WidgetProvider.Companion.getWidgetOpenNoteIntent
|
||||
import com.philkes.notallyx.presentation.widget.WidgetProvider.Companion.getWidgetSelectNoteIntent
|
||||
|
||||
class WidgetFactory(
|
||||
private val app: NotallyXApplication,
|
||||
|
@ -75,10 +79,12 @@ class WidgetFactory(
|
|||
setViewVisibility(R.id.Note, View.VISIBLE)
|
||||
} else setViewVisibility(R.id.Note, View.GONE)
|
||||
|
||||
val intent = Intent(WidgetProvider.ACTION_OPEN_NOTE)
|
||||
setOnClickFillInIntent(R.id.LinearLayout, intent)
|
||||
setOnClickFillInIntent(R.id.ChangeNote, getWidgetSelectNoteIntent(widgetId))
|
||||
setOnClickFillInIntent(R.id.LinearLayout, getWidgetOpenNoteIntent(note.type, note.id))
|
||||
|
||||
setOnClickFillInIntent(R.id.ChangeNote, getSelectNoteIntent(widgetId))
|
||||
val (_, controlsColor) = app.extractWidgetColors(note.color, preferences)
|
||||
setTextViewsTextColor(listOf(R.id.Title, R.id.Note), controlsColor)
|
||||
setImageViewColor(R.id.ChangeNote, controlsColor)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -90,11 +96,14 @@ class WidgetFactory(
|
|||
preferences.textSize.value.displayTitleSize,
|
||||
)
|
||||
setTextViewText(R.id.Title, list.title)
|
||||
setOnClickFillInIntent(R.id.ChangeNote, getWidgetSelectNoteIntent(widgetId))
|
||||
val openNoteWidgetIntent = getWidgetOpenNoteIntent(list.type, list.id)
|
||||
setOnClickFillInIntent(R.id.LinearLayout, openNoteWidgetIntent)
|
||||
setOnClickFillInIntent(R.id.Title, openNoteWidgetIntent)
|
||||
|
||||
val intent = Intent(WidgetProvider.ACTION_OPEN_LIST)
|
||||
setOnClickFillInIntent(R.id.LinearLayout, intent)
|
||||
|
||||
setOnClickFillInIntent(R.id.ChangeNote, getSelectNoteIntent(widgetId))
|
||||
val (_, controlsColor) = app.extractWidgetColors(list.color, preferences)
|
||||
setTextViewsTextColor(listOf(R.id.Title), controlsColor)
|
||||
setImageViewColor(R.id.ChangeNote, controlsColor)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -102,57 +111,68 @@ class WidgetFactory(
|
|||
val item = list.items[index]
|
||||
val view =
|
||||
if (item.isChild) {
|
||||
// Since RemoteViews.view.setViewLayoutMargin is only available with API Level >= 31
|
||||
// use other layout that uses marginStart to indent child
|
||||
RemoteViews(app.packageName, R.layout.widget_list_child_item)
|
||||
} else {
|
||||
RemoteViews(app.packageName, R.layout.widget_list_item)
|
||||
}
|
||||
return view.apply {
|
||||
setTextViewTextSize(
|
||||
R.id.CheckBox,
|
||||
TypedValue.COMPLEX_UNIT_SP,
|
||||
preferences.textSize.value.displayBodySize,
|
||||
)
|
||||
setTextViewText(R.id.CheckBox, item.body)
|
||||
setInt(
|
||||
R.id.CheckBox,
|
||||
"setPaintFlags",
|
||||
if (item.checked) {
|
||||
Paint.STRIKE_THRU_TEXT_FLAG or Paint.ANTI_ALIAS_FLAG
|
||||
} else {
|
||||
Paint.ANTI_ALIAS_FLAG
|
||||
},
|
||||
)
|
||||
val (_, controlsColor) = app.extractWidgetColors(list.color, preferences)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
setListItemTextView(item, R.id.CheckBox, controlsColor)
|
||||
setCompoundButtonChecked(R.id.CheckBox, item.checked)
|
||||
val intent = Intent(WidgetProvider.ACTION_CHECKED_CHANGED)
|
||||
intent.putExtra(WidgetProvider.EXTRA_POSITION, index)
|
||||
val response = RemoteViews.RemoteResponse.fromFillInIntent(intent)
|
||||
setOnCheckedChangeResponse(R.id.CheckBox, response)
|
||||
val checkIntent = getWidgetCheckedChangeIntent(list.id, index)
|
||||
setOnCheckedChangeResponse(
|
||||
R.id.CheckBox,
|
||||
RemoteViews.RemoteResponse.fromFillInIntent(checkIntent),
|
||||
)
|
||||
setColorStateList(
|
||||
R.id.CheckBox,
|
||||
"setButtonTintList",
|
||||
ColorStateList.valueOf(controlsColor),
|
||||
)
|
||||
} else {
|
||||
val intent = Intent(WidgetProvider.ACTION_OPEN_LIST)
|
||||
if (item.checked) {
|
||||
setTextViewCompoundDrawablesRelative(
|
||||
R.id.CheckBox,
|
||||
R.drawable.checkbox_fill,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
} else
|
||||
setTextViewCompoundDrawablesRelative(
|
||||
R.id.CheckBox,
|
||||
R.drawable.checkbox_outline,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
setOnClickFillInIntent(R.id.CheckBox, intent)
|
||||
setListItemTextView(item, R.id.CheckBoxText, controlsColor)
|
||||
setImageViewResource(
|
||||
R.id.CheckBox,
|
||||
if (item.checked) R.drawable.checkbox_fill else R.drawable.checkbox_outline,
|
||||
)
|
||||
setOnClickFillInIntent(
|
||||
R.id.LinearLayout,
|
||||
getWidgetCheckedChangeIntent(list.id, index),
|
||||
)
|
||||
setImageViewColor(R.id.CheckBox, controlsColor)
|
||||
}
|
||||
setTextViewsTextColor(listOf(R.id.Title), controlsColor)
|
||||
}
|
||||
}
|
||||
|
||||
private fun RemoteViews.setListItemTextView(item: ListItem, textViewId: Int, fontColor: Int) {
|
||||
setTextViewTextSize(
|
||||
textViewId,
|
||||
TypedValue.COMPLEX_UNIT_SP,
|
||||
preferences.textSize.value.displayBodySize,
|
||||
)
|
||||
setTextViewText(textViewId, item.body)
|
||||
setInt(
|
||||
textViewId,
|
||||
"setPaintFlags",
|
||||
if (item.checked) {
|
||||
Paint.STRIKE_THRU_TEXT_FLAG or Paint.ANTI_ALIAS_FLAG
|
||||
} else {
|
||||
Paint.ANTI_ALIAS_FLAG
|
||||
},
|
||||
)
|
||||
setInt(textViewId, "setTextColor", fontColor)
|
||||
}
|
||||
|
||||
private fun RemoteViews.setTextViewsTextColor(viewIds: List<Int>, color: Int) {
|
||||
viewIds.forEach { viewId -> setInt(viewId, "setTextColor", color) }
|
||||
}
|
||||
|
||||
private fun RemoteViews.setImageViewColor(viewId: Int, color: Int) {
|
||||
setInt(viewId, "setColorFilter", color)
|
||||
}
|
||||
|
||||
override fun getViewTypeCount() = 3
|
||||
|
||||
override fun hasStableIds(): Boolean {
|
||||
|
|
|
@ -5,41 +5,48 @@ import android.app.PendingIntent
|
|||
import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.widget.RemoteViews
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.philkes.notallyx.NotallyXApplication
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.NotallyDatabase
|
||||
import com.philkes.notallyx.data.dao.BaseNoteDao
|
||||
import com.philkes.notallyx.data.model.BaseNote
|
||||
import com.philkes.notallyx.data.model.Type
|
||||
import com.philkes.notallyx.data.model.findChildrenPositions
|
||||
import com.philkes.notallyx.data.model.findParentPosition
|
||||
import com.philkes.notallyx.presentation.activity.ConfigureWidgetActivity
|
||||
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.extractColor
|
||||
import com.philkes.notallyx.presentation.getContrastFontColor
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.findChildrenPositions
|
||||
import com.philkes.notallyx.presentation.view.note.listitem.findParentPosition
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
|
||||
import com.philkes.notallyx.presentation.viewmodel.preference.Theme
|
||||
import com.philkes.notallyx.utils.embedIntentExtras
|
||||
import com.philkes.notallyx.utils.getOpenNotePendingIntent
|
||||
import com.philkes.notallyx.utils.isSystemInDarkMode
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class WidgetProvider : AppWidgetProvider() {
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
super.onReceive(context, intent)
|
||||
|
||||
when (intent.action) {
|
||||
ACTION_NOTES_MODIFIED -> {
|
||||
val app = context.applicationContext as NotallyXApplication
|
||||
val noteIds = intent.getLongArrayExtra(EXTRA_MODIFIED_NOTES)
|
||||
if (noteIds != null) {
|
||||
updateWidgets(context, noteIds)
|
||||
updateWidgets(context, noteIds, locked = app.locked.value)
|
||||
}
|
||||
}
|
||||
ACTION_OPEN_NOTE -> openActivity(context, intent, EditNoteActivity::class.java)
|
||||
|
@ -53,10 +60,10 @@ class WidgetProvider : AppWidgetProvider() {
|
|||
private fun checkChanged(intent: Intent, context: Context) {
|
||||
val noteId = intent.getLongExtra(EXTRA_SELECTED_BASE_NOTE, 0)
|
||||
val position = intent.getIntExtra(EXTRA_POSITION, 0)
|
||||
val checked =
|
||||
var checked =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
intent.getBooleanExtra(RemoteViews.EXTRA_CHECKED, false)
|
||||
} else false
|
||||
} else null
|
||||
val database =
|
||||
NotallyDatabase.getDatabase(
|
||||
context.applicationContext as Application,
|
||||
|
@ -70,14 +77,18 @@ class WidgetProvider : AppWidgetProvider() {
|
|||
val baseNoteDao = database.getBaseNoteDao()
|
||||
val note = baseNoteDao.get(noteId)!!
|
||||
val item = note.items[position]
|
||||
if (checked == null) {
|
||||
checked = !item.checked
|
||||
}
|
||||
if (item.isChild) {
|
||||
changeChildChecked(note, position, checked, baseNoteDao, noteId)
|
||||
changeChildChecked(note, position, checked!!, baseNoteDao, noteId)
|
||||
} else {
|
||||
val childrenPositions = note.items.findChildrenPositions(position)
|
||||
baseNoteDao.updateChecked(noteId, childrenPositions + position, checked)
|
||||
baseNoteDao.updateChecked(noteId, childrenPositions + position, checked!!)
|
||||
}
|
||||
} finally {
|
||||
updateWidgets(context, longArrayOf(noteId))
|
||||
val app = context.applicationContext as NotallyXApplication
|
||||
updateWidgets(context, longArrayOf(noteId), locked = app.locked.value)
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
|
@ -127,28 +138,19 @@ class WidgetProvider : AppWidgetProvider() {
|
|||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetIds: IntArray,
|
||||
) {
|
||||
val app = context.applicationContext as Application
|
||||
val app = context.applicationContext as NotallyXApplication
|
||||
val preferences = NotallyXPreferences.getInstance(app)
|
||||
|
||||
appWidgetIds.forEach { id ->
|
||||
val noteId = preferences.getWidgetData(id)
|
||||
val noteType = preferences.getWidgetNoteType(id)
|
||||
updateWidget(context, appWidgetManager, id, noteId, noteType)
|
||||
val noteType = preferences.getWidgetNoteType(id) ?: return
|
||||
updateWidget(app, appWidgetManager, id, noteId, noteType, locked = app.locked.value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.getOpenNoteIntent(noteId: Long): PendingIntent {
|
||||
val intent = Intent(this, WidgetProvider::class.java)
|
||||
intent.putExtra(EXTRA_SELECTED_BASE_NOTE, noteId)
|
||||
intent.embedIntentExtras()
|
||||
val flags =
|
||||
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT or Intent.FILL_IN_ACTION
|
||||
return PendingIntent.getBroadcast(this, 0, intent, flags)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun updateWidgets(context: Context, noteIds: LongArray? = null, locked: Boolean = false) {
|
||||
fun updateWidgets(context: Context, noteIds: LongArray? = null, locked: Boolean) {
|
||||
val app = context.applicationContext as Application
|
||||
val preferences = NotallyXPreferences.getInstance(app)
|
||||
|
||||
|
@ -157,7 +159,7 @@ class WidgetProvider : AppWidgetProvider() {
|
|||
|
||||
updatableWidgets.forEach { (id, noteId) ->
|
||||
updateWidget(
|
||||
context,
|
||||
app,
|
||||
manager,
|
||||
id,
|
||||
noteId,
|
||||
|
@ -168,7 +170,7 @@ class WidgetProvider : AppWidgetProvider() {
|
|||
}
|
||||
|
||||
fun updateWidget(
|
||||
context: Context,
|
||||
context: ContextWrapper,
|
||||
manager: AppWidgetManager,
|
||||
id: Int,
|
||||
noteId: Long,
|
||||
|
@ -182,40 +184,144 @@ class WidgetProvider : AppWidgetProvider() {
|
|||
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id)
|
||||
intent.embedIntentExtras()
|
||||
|
||||
val view =
|
||||
if (!locked) {
|
||||
RemoteViews(context.packageName, R.layout.widget).apply {
|
||||
setRemoteAdapter(R.id.ListView, intent)
|
||||
setEmptyView(R.id.ListView, R.id.Empty)
|
||||
setOnClickFillInIntent(R.id.Empty, getSelectNoteIntent(id))
|
||||
setPendingIntentTemplate(
|
||||
R.id.ListView,
|
||||
noteType?.let { context.getOpenNotePendingIntent(noteId, it) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
RemoteViews(context.packageName, R.layout.widget_locked).apply {
|
||||
noteType?.let {
|
||||
val lockedPendingIntent =
|
||||
context.getOpenNotePendingIntent(noteId, noteType)
|
||||
setOnClickPendingIntent(R.id.Layout, lockedPendingIntent)
|
||||
setOnClickPendingIntent(R.id.Text, lockedPendingIntent)
|
||||
MainScope().launch {
|
||||
val database = NotallyDatabase.getDatabase(context).value
|
||||
val color =
|
||||
withContext(Dispatchers.IO) { database.getBaseNoteDao().getColorOfNote(noteId) }
|
||||
if (color == null) {
|
||||
val app = context.applicationContext as Application
|
||||
val preferences = NotallyXPreferences.getInstance(app)
|
||||
preferences.deleteWidget(id)
|
||||
val view =
|
||||
RemoteViews(context.packageName, R.layout.widget).apply {
|
||||
setRemoteAdapter(R.id.ListView, intent)
|
||||
setEmptyView(R.id.ListView, R.id.Empty)
|
||||
setOnClickPendingIntent(
|
||||
R.id.Empty,
|
||||
Intent(context, WidgetProvider::class.java)
|
||||
.apply {
|
||||
action = ACTION_SELECT_NOTE
|
||||
data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
|
||||
}
|
||||
.asPendingIntent(context),
|
||||
)
|
||||
setPendingIntentTemplate(
|
||||
R.id.ListView,
|
||||
Intent(context, WidgetProvider::class.java).asPendingIntent(context),
|
||||
)
|
||||
}
|
||||
setTextViewCompoundDrawablesRelative(
|
||||
R.id.Text,
|
||||
0,
|
||||
R.drawable.lock_big,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
manager.updateAppWidget(id, view)
|
||||
manager.notifyAppWidgetViewDataChanged(id, R.id.ListView)
|
||||
return@launch
|
||||
}
|
||||
if (!locked) {
|
||||
val view =
|
||||
RemoteViews(context.packageName, R.layout.widget).apply {
|
||||
setRemoteAdapter(R.id.ListView, intent)
|
||||
setEmptyView(R.id.ListView, R.id.Empty)
|
||||
setOnClickPendingIntent(
|
||||
R.id.Empty,
|
||||
Intent(context, WidgetProvider::class.java)
|
||||
.apply {
|
||||
action = ACTION_SELECT_NOTE
|
||||
data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
|
||||
}
|
||||
.asPendingIntent(context),
|
||||
)
|
||||
setPendingIntentTemplate(
|
||||
R.id.ListView,
|
||||
Intent(context, WidgetProvider::class.java).asPendingIntent(context),
|
||||
)
|
||||
|
||||
val preferences = NotallyXPreferences.getInstance(context)
|
||||
val (backgroundColor, _) =
|
||||
context.extractWidgetColors(color, preferences)
|
||||
noteType?.let {
|
||||
setOnClickPendingIntent(
|
||||
R.id.Layout,
|
||||
Intent(context, WidgetProvider::class.java)
|
||||
.setOpenNoteIntent(noteType, noteId)
|
||||
.asPendingIntent(context),
|
||||
)
|
||||
}
|
||||
setInt(R.id.Layout, "setBackgroundColor", backgroundColor)
|
||||
}
|
||||
manager.updateAppWidget(id, view)
|
||||
manager.notifyAppWidgetViewDataChanged(id, R.id.ListView)
|
||||
} else {
|
||||
val view =
|
||||
RemoteViews(context.packageName, R.layout.widget_locked).apply {
|
||||
noteType?.let {
|
||||
val lockedPendingIntent =
|
||||
context.getOpenNotePendingIntent(noteId, noteType)
|
||||
setOnClickPendingIntent(R.id.Layout, lockedPendingIntent)
|
||||
setOnClickPendingIntent(R.id.Text, lockedPendingIntent)
|
||||
}
|
||||
setTextViewCompoundDrawablesRelative(
|
||||
R.id.Text,
|
||||
0,
|
||||
R.drawable.lock_big,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
}
|
||||
manager.updateAppWidget(id, view)
|
||||
}
|
||||
manager.updateAppWidget(id, view)
|
||||
if (!locked) {
|
||||
manager.notifyAppWidgetViewDataChanged(id, R.id.ListView)
|
||||
}
|
||||
}
|
||||
|
||||
fun getWidgetOpenNoteIntent(noteType: Type, noteId: Long): Intent {
|
||||
return Intent().setOpenNoteIntent(noteType, noteId)
|
||||
}
|
||||
|
||||
fun getWidgetCheckedChangeIntent(listNoteId: Long, position: Int): Intent {
|
||||
return Intent().apply {
|
||||
action = ACTION_CHECKED_CHANGED
|
||||
putExtra(EXTRA_POSITION, position)
|
||||
putExtra(EXTRA_SELECTED_BASE_NOTE, listNoteId)
|
||||
data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
|
||||
}
|
||||
}
|
||||
|
||||
private fun Intent.setOpenNoteIntent(noteType: Type, noteId: Long) = apply {
|
||||
action =
|
||||
when (noteType) {
|
||||
Type.LIST -> ACTION_OPEN_LIST
|
||||
Type.NOTE -> ACTION_OPEN_NOTE
|
||||
}
|
||||
putExtra(EXTRA_SELECTED_BASE_NOTE, noteId)
|
||||
data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
|
||||
}
|
||||
|
||||
private fun Intent.asPendingIntent(context: Context): PendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
this,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
|
||||
)
|
||||
|
||||
fun Context.extractWidgetColors(
|
||||
color: String,
|
||||
preferences: NotallyXPreferences,
|
||||
): Pair<Int, Int> {
|
||||
val backgroundColor =
|
||||
if (color == BaseNote.COLOR_DEFAULT) {
|
||||
val id =
|
||||
when (preferences.theme.value) {
|
||||
Theme.DARK -> R.color.md_theme_surface_dark
|
||||
Theme.LIGHT -> R.color.md_theme_surface
|
||||
Theme.FOLLOW_SYSTEM -> {
|
||||
if (isSystemInDarkMode()) R.color.md_theme_surface_dark
|
||||
else R.color.md_theme_surface
|
||||
}
|
||||
}
|
||||
ContextCompat.getColor(this, id)
|
||||
} else extractColor(color)
|
||||
return Pair(backgroundColor, getContrastFontColor(backgroundColor))
|
||||
}
|
||||
|
||||
private fun openActivity(context: Context, originalIntent: Intent, clazz: Class<*>) {
|
||||
val id = originalIntent.getLongExtra(EXTRA_SELECTED_BASE_NOTE, 0)
|
||||
val widgetId = originalIntent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, 0)
|
||||
|
@ -237,19 +343,18 @@ class WidgetProvider : AppWidgetProvider() {
|
|||
return intent
|
||||
}
|
||||
|
||||
fun sendBroadcast(context: Context, ids: LongArray) {
|
||||
val intent = Intent(context, WidgetProvider::class.java)
|
||||
intent.action = ACTION_NOTES_MODIFIED
|
||||
intent.putExtra(EXTRA_MODIFIED_NOTES, ids)
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
fun sendBroadcast(context: Context, ids: LongArray) =
|
||||
Intent(context, WidgetProvider::class.java).apply {
|
||||
action = ACTION_NOTES_MODIFIED
|
||||
putExtra(EXTRA_MODIFIED_NOTES, ids)
|
||||
context.sendBroadcast(this)
|
||||
}
|
||||
|
||||
fun getSelectNoteIntent(id: Int): Intent {
|
||||
return Intent(ACTION_SELECT_NOTE).apply {
|
||||
fun getWidgetSelectNoteIntent(id: Int) =
|
||||
Intent(ACTION_SELECT_NOTE).apply {
|
||||
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id)
|
||||
data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
|
||||
}
|
||||
}
|
||||
|
||||
private const val EXTRA_MODIFIED_NOTES = "com.philkes.notallyx.EXTRA_MODIFIED_NOTES"
|
||||
private const val ACTION_NOTES_MODIFIED = "com.philkes.notallyx.ACTION_NOTE_MODIFIED"
|
||||
|
|
|
@ -7,6 +7,7 @@ import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
|
|||
class ActionMode {
|
||||
|
||||
val enabled = NotNullLiveData(false)
|
||||
val loading = NotNullLiveData(false)
|
||||
val count = NotNullLiveData(0)
|
||||
val selectedNotes = HashMap<Long, BaseNote>()
|
||||
val selectedIds = selectedNotes.keys
|
||||
|
@ -43,8 +44,17 @@ class ActionMode {
|
|||
}
|
||||
}
|
||||
|
||||
fun updateSelected(availableItemIds: List<Long>?) {
|
||||
selectedNotes.keys
|
||||
.filter { availableItemIds?.contains(it) == false }
|
||||
.forEach { selectedNotes.remove(it) }
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun isEnabled() = enabled.value
|
||||
|
||||
// We assume selectedNotes.size is 1
|
||||
fun getFirstNote() = selectedNotes.values.first()
|
||||
|
||||
fun isEmpty() = selectedNotes.values.isEmpty()
|
||||
}
|
||||
|
|
|
@ -80,7 +80,7 @@ private fun Context.createReminderAlarmIntent(noteId: Long, reminderId: Long): P
|
|||
intent.putExtra(ReminderReceiver.EXTRA_NOTE_ID, noteId)
|
||||
return PendingIntent.getBroadcast(
|
||||
this,
|
||||
0,
|
||||
(noteId.toString() + reminderId.toString()).toInt(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
|
||||
)
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package com.philkes.notallyx.utils
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.KeyguardManager
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
|
@ -12,10 +11,11 @@ import android.content.ContentResolver
|
|||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.hardware.biometrics.BiometricManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
|
@ -32,16 +32,19 @@ import com.philkes.notallyx.BuildConfig
|
|||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.data.model.BaseNote
|
||||
import com.philkes.notallyx.data.model.Type
|
||||
import com.philkes.notallyx.data.model.toText
|
||||
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.showToast
|
||||
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.io.PrintWriter
|
||||
import java.lang.UnsupportedOperationException
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
|
@ -95,7 +98,7 @@ fun ClipboardManager.getLatestText(): CharSequence? {
|
|||
return primaryClip?.let { if (it.itemCount > 0) it.getItemAt(0)!!.text else null }
|
||||
}
|
||||
|
||||
fun Activity.copyToClipBoard(text: CharSequence) {
|
||||
fun Context.copyToClipBoard(text: CharSequence) {
|
||||
ContextCompat.getSystemService(this, ClipboardManager::class.java)?.let {
|
||||
val clip = ClipData.newPlainText("label", text)
|
||||
it.setPrimaryClip(clip)
|
||||
|
@ -120,30 +123,15 @@ fun Context.getFileName(uri: Uri): String? =
|
|||
}
|
||||
|
||||
fun Context.canAuthenticateWithBiometrics(): Int {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
val keyguardManager = ContextCompat.getSystemService(this, KeyguardManager::class.java)
|
||||
val packageManager: PackageManager = this.packageManager
|
||||
if (!packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)) {
|
||||
return BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE
|
||||
}
|
||||
if (keyguardManager?.isKeyguardSecure == false) {
|
||||
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()
|
||||
val biometricManager = androidx.biometric.BiometricManager.from(this)
|
||||
val authenticators =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||
androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
} else {
|
||||
val biometricManager: BiometricManager =
|
||||
this.getSystemService(BiometricManager::class.java)
|
||||
return biometricManager.canAuthenticate(
|
||||
BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
)
|
||||
androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
|
||||
}
|
||||
}
|
||||
return BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE
|
||||
return biometricManager.canAuthenticate(authenticators)
|
||||
}
|
||||
|
||||
fun Context.getUriForFile(file: File): Uri =
|
||||
|
@ -151,6 +139,8 @@ fun Context.getUriForFile(file: File): Uri =
|
|||
|
||||
private val LOG_DATE_FORMATTER = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH)
|
||||
|
||||
fun Context.getMimeType(uri: Uri) = contentResolver.getType(uri)
|
||||
|
||||
fun ContextWrapper.log(
|
||||
tag: String,
|
||||
msg: String? = null,
|
||||
|
@ -165,8 +155,7 @@ fun ContextWrapper.log(
|
|||
fun ContextWrapper.getLastExceptionLog(): String? {
|
||||
val logFile = getLogFile()
|
||||
if (logFile.exists()) {
|
||||
val logContents = logFile.readText().substringAfterLast("[Start]")
|
||||
return URLEncoder.encode(logContents, StandardCharsets.UTF_8.toString())
|
||||
return logFile.readText().substringAfterLast("[Start]")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
@ -182,8 +171,10 @@ fun Context.logToFile(
|
|||
stackTrace: String? = null,
|
||||
) {
|
||||
msg?.let {
|
||||
if (throwable != null || stackTrace != null) {
|
||||
Log.e(tag, it)
|
||||
if (throwable != null) {
|
||||
Log.e(tag, it, throwable)
|
||||
} else if (stackTrace != null) {
|
||||
Log.e(tag, "$it: $stackTrace")
|
||||
} else {
|
||||
Log.i(tag, it)
|
||||
}
|
||||
|
@ -194,16 +185,24 @@ fun Context.logToFile(
|
|||
val logFile =
|
||||
folder.findFile(fileName).let {
|
||||
if (it == null || !it.exists()) {
|
||||
folder.createFile("text/plain", fileName)
|
||||
folder.createFile("text/plain", fileName.removeSuffix(".txt"))
|
||||
} else if (it.isLargerThanKb(MAX_LOGS_FILE_SIZE_KB)) {
|
||||
it.delete()
|
||||
folder.createFile("text/plain", fileName)
|
||||
folder.createFile("text/plain", fileName.removeSuffix(".txt"))
|
||||
} else it
|
||||
}
|
||||
|
||||
logFile?.let { file ->
|
||||
val contentResolver = contentResolver
|
||||
val outputStream = contentResolver.openOutputStream(file.uri, "wa")
|
||||
val (outputStream, logFileContents) =
|
||||
try {
|
||||
Pair(contentResolver.openOutputStream(file.uri, "wa"), null)
|
||||
} catch (e: UnsupportedOperationException) {
|
||||
Pair(
|
||||
contentResolver.openOutputStream(file.uri, "w"),
|
||||
contentResolver.readFileContents(file.uri),
|
||||
)
|
||||
}
|
||||
|
||||
outputStream?.use { output ->
|
||||
val writer = PrintWriter(OutputStreamWriter(output, Charsets.UTF_8))
|
||||
|
@ -211,6 +210,7 @@ fun Context.logToFile(
|
|||
val formatter = DateFormat.getDateTimeInstance()
|
||||
val time = formatter.format(System.currentTimeMillis())
|
||||
|
||||
logFileContents?.let { writer.println(it) }
|
||||
if (throwable != null || stackTrace != null) {
|
||||
writer.println("[Start]")
|
||||
}
|
||||
|
@ -240,6 +240,17 @@ fun Fragment.reportBug(stackTrace: String?) {
|
|||
}
|
||||
}
|
||||
|
||||
fun Fragment.getExtraBooleanFromBundleOrIntent(
|
||||
bundle: Bundle?,
|
||||
key: String,
|
||||
defaultValue: Boolean,
|
||||
): Boolean {
|
||||
return bundle.getExtraBooleanOrDefault(
|
||||
key,
|
||||
activity?.intent?.getBooleanExtra(key, defaultValue) ?: defaultValue,
|
||||
)
|
||||
}
|
||||
|
||||
fun Context.reportBug(stackTrace: String?) {
|
||||
catchNoBrowserInstalled { startActivity(createReportBugIntent(stackTrace)) }
|
||||
}
|
||||
|
@ -257,26 +268,53 @@ fun Context.createReportBugIntent(
|
|||
title: String? = null,
|
||||
body: String? = null,
|
||||
): Intent {
|
||||
fun String?.asQueryParam(paramName: String): String {
|
||||
return this?.let { "&$paramName=${URLEncoder.encode(this, "UTF-8")}" } ?: ""
|
||||
}
|
||||
return Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
Uri.parse(
|
||||
"https://github.com/PhilKes/NotallyX/issues/new?labels=bug&projects=&template=bug_report.yml${title?.let { "&title=$it" }}${body?.let { "&what-happened=$it" }}&version=${BuildConfig.VERSION_NAME}&android-version=${Build.VERSION.SDK_INT}${stackTrace?.let { "&logs=$stackTrace" } ?: ""}"
|
||||
"https://github.com/PhilKes/NotallyX/issues/new?labels=bug&projects=&template=bug_report.yml${
|
||||
title.asQueryParam("title")
|
||||
}&version=${BuildConfig.VERSION_NAME}&android-version=${Build.VERSION.SDK_INT}${
|
||||
stackTrace.asQueryParam("logs")
|
||||
}${
|
||||
body.asQueryParam("what-happened")
|
||||
}"
|
||||
.take(2000)
|
||||
),
|
||||
)
|
||||
.wrapWithChooser(this)
|
||||
}
|
||||
|
||||
fun Context.shareNote(title: String, body: CharSequence) {
|
||||
val text = body.truncate(150_000)
|
||||
fun ContextWrapper.shareNote(note: BaseNote) {
|
||||
val body =
|
||||
when (note.type) {
|
||||
Type.NOTE -> note.body
|
||||
Type.LIST -> note.items.toMutableList().toText()
|
||||
}
|
||||
val filesUris =
|
||||
note.images
|
||||
.map { File(getExternalImagesDirectory(), it.localName) }
|
||||
.map { getUriForFile(it) }
|
||||
shareNote(note.title, body, filesUris)
|
||||
}
|
||||
|
||||
private fun Context.shareNote(title: String, body: CharSequence, imageUris: List<Uri>) {
|
||||
val text = body.truncate(150_000)
|
||||
val intent =
|
||||
Intent(Intent.ACTION_SEND)
|
||||
Intent(if (imageUris.size > 1) Intent.ACTION_SEND_MULTIPLE else Intent.ACTION_SEND)
|
||||
.apply {
|
||||
type = "text/plain"
|
||||
type = if (imageUris.isEmpty()) "text/*" else "image/*"
|
||||
putExtra(Intent.EXTRA_TEXT, text.toString())
|
||||
putExtra(Intent.EXTRA_TITLE, title)
|
||||
putExtra(Intent.EXTRA_SUBJECT, title)
|
||||
if (imageUris.size > 1) {
|
||||
putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(imageUris))
|
||||
} else if (imageUris.isNotEmpty()) {
|
||||
putExtra(Intent.EXTRA_STREAM, imageUris.first())
|
||||
}
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
.wrapWithChooser(this)
|
||||
startActivity(intent)
|
||||
|
@ -309,11 +347,16 @@ fun Context.getOpenNotePendingIntent(noteId: Long, noteType: Type): PendingInten
|
|||
return PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
getOpenNoteIntent(noteId, noteType, addPendingFlags = true),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
getOpenNoteIntent(noteId, noteType, addPendingFlags = false),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
fun Context.isSystemInDarkMode(): Boolean {
|
||||
val currentNightMode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
|
||||
return currentNightMode == Configuration.UI_MODE_NIGHT_YES
|
||||
}
|
||||
|
||||
fun ContentResolver.determineMimeTypeAndExtension(uri: Uri, proposedMimeType: String?) =
|
||||
if (proposedMimeType != null && proposedMimeType.contains("/")) {
|
||||
Pair(proposedMimeType, ".${uri.lastPathSegment?.substringAfterLast(".")}")
|
||||
|
@ -374,3 +417,21 @@ fun Uri.toReadablePath(): String {
|
|||
.replaceFirst("/tree/primary:", "Internal Storage/")
|
||||
.replaceFirst("/tree/.*:".toRegex(), "External Storage/")
|
||||
}
|
||||
|
||||
fun Activity.resetApplication() {
|
||||
val resetApplicationIntent = packageManager.getLaunchIntentForPackage(packageName)
|
||||
resetApplicationIntent?.setFlags(
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
)
|
||||
startActivity(resetApplicationIntent)
|
||||
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
|
||||
}
|
||||
|
||||
fun Bundle?.getExtraBooleanOrDefault(key: String, defaultValue: Boolean): Boolean {
|
||||
return this?.getBoolean(key, defaultValue) ?: defaultValue
|
||||
}
|
||||
|
||||
fun ContentResolver.readFileContents(uri: Uri) =
|
||||
openInputStream(uri)?.use { inputStream ->
|
||||
BufferedReader(InputStreamReader(inputStream)).use { reader -> reader.readText() }
|
||||
} ?: ""
|
||||
|
|
|
@ -0,0 +1,251 @@
|
|||
package com.philkes.notallyx.utils
|
||||
|
||||
import android.app.Activity
|
||||
import android.view.View
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
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.ColorString
|
||||
import com.philkes.notallyx.databinding.DialogColorBinding
|
||||
import com.philkes.notallyx.databinding.DialogColorPickerBinding
|
||||
import com.philkes.notallyx.presentation.createTextView
|
||||
import com.philkes.notallyx.presentation.dp
|
||||
import com.philkes.notallyx.presentation.extractColor
|
||||
import com.philkes.notallyx.presentation.setLightStatusAndNavBar
|
||||
import com.philkes.notallyx.presentation.showAndFocus
|
||||
import com.philkes.notallyx.presentation.view.main.ColorAdapter
|
||||
import com.philkes.notallyx.presentation.view.misc.ItemListener
|
||||
import com.skydoves.colorpickerview.ColorEnvelope
|
||||
import com.skydoves.colorpickerview.listeners.ColorEnvelopeListener
|
||||
|
||||
fun Activity.showColorSelectDialog(
|
||||
colors: List<String>,
|
||||
currentColor: String?,
|
||||
setNavigationbarLight: Boolean?,
|
||||
callback: (selectedColor: String, oldColor: String?) -> Unit,
|
||||
deleteCallback: (colorToDelete: String, newColor: String) -> Unit,
|
||||
) {
|
||||
val actualColors =
|
||||
colors.toMutableList().apply {
|
||||
remove(BaseNote.COLOR_DEFAULT)
|
||||
remove(BaseNote.COLOR_NEW)
|
||||
add(0, BaseNote.COLOR_DEFAULT)
|
||||
add(0, BaseNote.COLOR_NEW)
|
||||
}
|
||||
val dialog = MaterialAlertDialogBuilder(this).setTitle(R.string.change_color).create()
|
||||
val colorAdapter =
|
||||
ColorAdapter(
|
||||
actualColors,
|
||||
currentColor,
|
||||
object : ItemListener {
|
||||
override fun onClick(position: Int) {
|
||||
dialog.dismiss()
|
||||
val selectedColor = actualColors[position]
|
||||
if (selectedColor == BaseNote.COLOR_NEW) {
|
||||
showEditColorDialog(
|
||||
actualColors,
|
||||
null,
|
||||
setNavigationbarLight,
|
||||
callback,
|
||||
deleteCallback,
|
||||
)
|
||||
} else callback(selectedColor, null)
|
||||
}
|
||||
|
||||
override fun onLongClick(position: Int) {
|
||||
val oldColor = actualColors[position]
|
||||
if (oldColor == BaseNote.COLOR_DEFAULT || oldColor == BaseNote.COLOR_NEW) {
|
||||
return
|
||||
}
|
||||
dialog.dismiss()
|
||||
showEditColorDialog(
|
||||
actualColors,
|
||||
oldColor,
|
||||
setNavigationbarLight,
|
||||
callback,
|
||||
deleteCallback,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
DialogColorBinding.inflate(layoutInflater).apply {
|
||||
MainListView.adapter = colorAdapter
|
||||
dialog.setView(root)
|
||||
dialog.setOnShowListener {
|
||||
setNavigationbarLight?.let { dialog.window?.setLightStatusAndNavBar(it) }
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Activity.showEditColorDialog(
|
||||
colors: List<String>,
|
||||
oldColor: String?,
|
||||
setNavigationbarLight: Boolean?,
|
||||
callback: (selectedColor: String, oldColor: String?) -> Unit,
|
||||
deleteCallback: (colorToDelete: String, newColor: String) -> Unit,
|
||||
) {
|
||||
val selectedColor = oldColor?.let { extractColor(it) } ?: extractColor(BaseNote.COLOR_DEFAULT)
|
||||
var editTextChangedByUser = false
|
||||
val binding =
|
||||
DialogColorPickerBinding.inflate(layoutInflater).apply {
|
||||
BrightnessSlideBar.setSelectorDrawableRes(
|
||||
com.skydoves.colorpickerview.R.drawable.colorpickerview_wheel
|
||||
)
|
||||
ColorPicker.apply {
|
||||
BrightnessSlideBar.attachColorPickerView(ColorPicker)
|
||||
attachBrightnessSlider(BrightnessSlideBar)
|
||||
setInitialColor(selectedColor)
|
||||
ColorPicker.postDelayed({ ColorPicker.selectByHsvColor(selectedColor) }, 100)
|
||||
ColorCode.doAfterTextChanged { text ->
|
||||
val isValueChangedByUser = ColorCode.hasFocus()
|
||||
val hexCode = text.toString()
|
||||
if (isValueChangedByUser && hexCode.length == 6) {
|
||||
try {
|
||||
val color = this@showEditColorDialog.extractColor("#$hexCode")
|
||||
editTextChangedByUser = true
|
||||
ColorPicker.selectByHsvColor(color)
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
}
|
||||
CopyCode.setOnClickListener { _ ->
|
||||
this@showEditColorDialog.copyToClipBoard(ColorCode.text)
|
||||
}
|
||||
}
|
||||
Restore.setOnClickListener { ColorPicker.selectByHsvColor(selectedColor) }
|
||||
|
||||
ExistingColors.apply {
|
||||
val existingColors = Color.allColorStrings()
|
||||
val colorAdapter =
|
||||
ColorAdapter(
|
||||
existingColors,
|
||||
null,
|
||||
object : ItemListener {
|
||||
override fun onClick(position: Int) {
|
||||
ColorPicker.selectByHsvColor(
|
||||
this@showEditColorDialog.extractColor(existingColors[position])
|
||||
)
|
||||
}
|
||||
|
||||
override fun onLongClick(position: Int) {}
|
||||
},
|
||||
)
|
||||
adapter = colorAdapter
|
||||
}
|
||||
}
|
||||
MaterialAlertDialogBuilder(this).apply {
|
||||
setTitle(if (oldColor != null) R.string.edit_color else R.string.new_color)
|
||||
setView(binding.root)
|
||||
setPositiveButton(R.string.save) { _, _ ->
|
||||
val newColor = binding.ColorPicker.colorEnvelope.toColorString()
|
||||
if (newColor == oldColor) {
|
||||
callback(oldColor, null)
|
||||
} else {
|
||||
callback(newColor, oldColor)
|
||||
}
|
||||
}
|
||||
setNegativeButton(R.string.back) { _, _ ->
|
||||
showColorSelectDialog(colors, oldColor, setNavigationbarLight, callback, deleteCallback)
|
||||
}
|
||||
oldColor?.let {
|
||||
setNeutralButton(R.string.delete) { _, _ ->
|
||||
showDeleteColorDialog(
|
||||
colors,
|
||||
oldColor,
|
||||
setNavigationbarLight,
|
||||
callback,
|
||||
deleteCallback,
|
||||
)
|
||||
}
|
||||
}
|
||||
showAndFocus(
|
||||
allowFullSize = true,
|
||||
onShowListener = {
|
||||
setNavigationbarLight?.let {
|
||||
window?.apply { setLightStatusAndNavBar(it, binding.root) }
|
||||
}
|
||||
},
|
||||
applyToPositiveButton = { positiveButton ->
|
||||
binding.apply {
|
||||
BrightnessSlideBar.setSelectorDrawableRes(
|
||||
com.skydoves.colorpickerview.R.drawable.colorpickerview_wheel
|
||||
)
|
||||
ColorPicker.setColorListener(
|
||||
ColorEnvelopeListener { color, _ ->
|
||||
TileView.setPaintColor(color.color)
|
||||
val colorString = color.toColorString()
|
||||
val isSaveEnabled = colorString == oldColor || colorString !in colors
|
||||
positiveButton.isEnabled = isSaveEnabled
|
||||
ColorExistsText.visibility =
|
||||
if (isSaveEnabled) View.INVISIBLE else View.VISIBLE
|
||||
if (!editTextChangedByUser) {
|
||||
ColorCode.setText(color.hexCode.argbToRgbString())
|
||||
} else editTextChangedByUser = false
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Activity.showDeleteColorDialog(
|
||||
colors: List<String>,
|
||||
oldColor: String,
|
||||
setNavigationbarLight: Boolean?,
|
||||
callback: (selectedColor: String, oldColor: String?) -> Unit,
|
||||
deleteCallback: (colorToDelete: String, newColor: String) -> Unit,
|
||||
) {
|
||||
val dialog =
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setCustomTitle(createTextView(R.string.delete_color_message))
|
||||
.setNeutralButton(R.string.back) { _, _ ->
|
||||
showEditColorDialog(
|
||||
colors,
|
||||
oldColor,
|
||||
setNavigationbarLight,
|
||||
callback,
|
||||
deleteCallback,
|
||||
)
|
||||
}
|
||||
.create()
|
||||
val selectableColors = colors.filter { it != BaseNote.COLOR_NEW && it != oldColor }
|
||||
val colorAdapter =
|
||||
ColorAdapter(
|
||||
selectableColors,
|
||||
null,
|
||||
object : ItemListener {
|
||||
override fun onClick(position: Int) {
|
||||
dialog.dismiss()
|
||||
val selectedColor = selectableColors[position]
|
||||
deleteCallback(oldColor, selectedColor)
|
||||
}
|
||||
|
||||
override fun onLongClick(position: Int) {}
|
||||
},
|
||||
)
|
||||
DialogColorBinding.inflate(layoutInflater).apply {
|
||||
MainListView.apply {
|
||||
updatePadding(left = 2.dp, right = 2.dp)
|
||||
(layoutManager as? GridLayoutManager)?.let { it.spanCount = 6 }
|
||||
adapter = colorAdapter
|
||||
}
|
||||
Message.isVisible = false
|
||||
dialog.setView(root)
|
||||
dialog.setOnShowListener {
|
||||
setNavigationbarLight?.let { window?.apply { setLightStatusAndNavBar(it, root) } }
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ColorEnvelope.toColorString(): ColorString {
|
||||
return "#${hexCode.argbToRgbString()}"
|
||||
}
|
||||
|
||||
private fun ColorString.argbToRgbString(): ColorString = substring(2)
|
|
@ -1,59 +1,35 @@
|
|||
package com.philkes.notallyx.utils
|
||||
|
||||
import android.os.Bundle
|
||||
import android.widget.Button
|
||||
import android.widget.ImageView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import cat.ereza.customactivityoncrash.CustomActivityOnCrash
|
||||
import com.philkes.notallyx.R
|
||||
import com.philkes.notallyx.presentation.dp
|
||||
import com.philkes.notallyx.databinding.ActivityErrorBinding
|
||||
|
||||
/**
|
||||
* Activity used when the app is about to crash. Implicitly used by cat.ereza:customactivityoncrash.
|
||||
* Activity used when the app is about to crash. Implicitly used by
|
||||
* `cat.ereza:customactivityoncrash`.
|
||||
*/
|
||||
class ErrorActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(
|
||||
cat.ereza.customactivityoncrash.R.layout.customactivityoncrash_default_error_activity
|
||||
)
|
||||
findViewById<ImageView>(
|
||||
cat.ereza.customactivityoncrash.R.id.customactivityoncrash_error_activity_image
|
||||
)
|
||||
.apply {
|
||||
minimumWidth = 100.dp(this@ErrorActivity)
|
||||
minimumHeight = 100.dp(this@ErrorActivity)
|
||||
setImageResource(R.drawable.error)
|
||||
}
|
||||
findViewById<Button>(
|
||||
cat.ereza.customactivityoncrash.R.id
|
||||
.customactivityoncrash_error_activity_restart_button
|
||||
)
|
||||
.apply {
|
||||
setText(
|
||||
cat.ereza.customactivityoncrash.R.string
|
||||
.customactivityoncrash_error_activity_restart_app
|
||||
val binding = ActivityErrorBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
binding.apply {
|
||||
RestartButton.setOnClickListener {
|
||||
CustomActivityOnCrash.restartApplication(
|
||||
this@ErrorActivity,
|
||||
CustomActivityOnCrash.getConfigFromIntent(intent)!!,
|
||||
)
|
||||
setOnClickListener {
|
||||
CustomActivityOnCrash.restartApplication(
|
||||
this@ErrorActivity,
|
||||
CustomActivityOnCrash.getConfigFromIntent(intent)!!,
|
||||
)
|
||||
}
|
||||
}
|
||||
val stackTrace = CustomActivityOnCrash.getStackTraceFromIntent(intent)
|
||||
stackTrace?.let { application.log(TAG, stackTrace = it) }
|
||||
findViewById<Button>(
|
||||
cat.ereza.customactivityoncrash.R.id
|
||||
.customactivityoncrash_error_activity_more_info_button
|
||||
)
|
||||
.apply {
|
||||
setText(R.string.report_bug)
|
||||
setOnClickListener {
|
||||
reportBug(CustomActivityOnCrash.getStackTraceFromIntent(intent))
|
||||
}
|
||||
|
||||
val stackTrace = CustomActivityOnCrash.getStackTraceFromIntent(intent)
|
||||
stackTrace?.let {
|
||||
application.log(TAG, stackTrace = it)
|
||||
Exception.text = stackTrace.lines().firstOrNull() ?: ""
|
||||
}
|
||||
ReportButton.setOnClickListener { reportBug(stackTrace) }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -103,6 +103,14 @@ fun File.toRelativePathFrom(baseFolderName: String): String {
|
|||
return relativePath.trimStart(File.separatorChar)
|
||||
}
|
||||
|
||||
fun File.recreateDir(): File {
|
||||
if (exists()) {
|
||||
deleteRecursively()
|
||||
}
|
||||
mkdirs()
|
||||
return this
|
||||
}
|
||||
|
||||
fun ContextWrapper.deleteAttachments(
|
||||
attachments: Collection<Attachment>,
|
||||
ids: LongArray? = null,
|
||||
|
@ -141,7 +149,7 @@ fun Context.getExportedPath() = getEmptyFolder("exported")
|
|||
|
||||
fun ContextWrapper.getLogsDir() = File(filesDir, "logs").also { it.mkdir() }
|
||||
|
||||
const val APP_LOG_FILE_NAME = "Log.v1.txt"
|
||||
const val APP_LOG_FILE_NAME = "notallyx-logs.txt"
|
||||
|
||||
fun ContextWrapper.getLogFile(): File {
|
||||
return File(getLogsDir(), APP_LOG_FILE_NAME)
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
package com.philkes.notallyx.utils
|
||||
|
||||
import androidx.recyclerview.widget.SortedList
|
||||
|
||||
fun <R, C> SortedList<R>.map(transform: (R) -> C): List<C> {
|
||||
return (0 until this.size()).map { transform.invoke(this[it]) }
|
||||
}
|
||||
|
||||
fun <R, C> SortedList<R>.mapIndexed(transform: (Int, R) -> C): List<C> {
|
||||
return (0 until this.size()).mapIndexed { idx, it -> transform.invoke(idx, this[it]) }
|
||||
}
|
||||
|
||||
fun <R> SortedList<R>.forEach(function: (item: R) -> Unit) {
|
||||
return (0 until this.size()).forEach { function.invoke(this[it]) }
|
||||
}
|
||||
|
||||
fun <R> SortedList<R>.forEachIndexed(function: (idx: Int, item: R) -> Unit) {
|
||||
for (i in 0 until this.size()) {
|
||||
function.invoke(i, this[i])
|
||||
}
|
||||
}
|
||||
|
||||
fun <R> SortedList<R>.filter(function: (item: R) -> Boolean): List<R> {
|
||||
val list = mutableListOf<R>()
|
||||
for (i in 0 until this.size()) {
|
||||
if (function.invoke(this[i] as R)) {
|
||||
list.add(this[i] as R)
|
||||
}
|
||||
}
|
||||
return list.toList()
|
||||
}
|
||||
|
||||
fun <R> SortedList<R>.find(function: (item: R) -> Boolean): R? {
|
||||
for (i in 0 until this.size()) {
|
||||
if (function.invoke(this[i])) {
|
||||
return this[i]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun <R> SortedList<R>.indexOfFirst(function: (item: R) -> Boolean): Int? {
|
||||
for (i in 0 until this.size()) {
|
||||
if (function.invoke(this[i])) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
val SortedList<*>.lastIndex: Int
|
||||
get() = this.size() - 1
|
||||
|
||||
val SortedList<*>.indices: IntRange
|
||||
get() = (0 until this.size())
|
||||
|
||||
fun SortedList<*>.isNotEmpty(): Boolean {
|
||||
return size() > 0
|
||||
}
|
||||
|
||||
fun SortedList<*>.isEmpty(): Boolean {
|
||||
return size() == 0
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
package com.philkes.notallyx.utils.backup
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
|
||||
|
@ -8,6 +9,6 @@ class AutoBackupWorker(private val context: Context, params: WorkerParameters) :
|
|||
Worker(context, params) {
|
||||
|
||||
override fun doWork(): Result {
|
||||
return context.createBackup()
|
||||
return (context.applicationContext as ContextWrapper).createBackup()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,11 +13,14 @@ import android.os.Build
|
|||
import android.print.PdfPrintListener
|
||||
import android.print.printPdf
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ListenableWorker.Result
|
||||
|
@ -33,7 +36,9 @@ import com.philkes.notallyx.data.model.FileAttachment
|
|||
import com.philkes.notallyx.data.model.toHtml
|
||||
import com.philkes.notallyx.data.model.toJson
|
||||
import com.philkes.notallyx.data.model.toTxt
|
||||
import com.philkes.notallyx.presentation.activity.LockedActivity
|
||||
import com.philkes.notallyx.presentation.activity.main.MainActivity
|
||||
import com.philkes.notallyx.presentation.view.misc.MenuDialog
|
||||
import com.philkes.notallyx.presentation.view.misc.Progress
|
||||
import com.philkes.notallyx.presentation.viewmodel.BackupFile
|
||||
import com.philkes.notallyx.presentation.viewmodel.ExportMimeType
|
||||
|
@ -51,12 +56,16 @@ import com.philkes.notallyx.utils.getExportedPath
|
|||
import com.philkes.notallyx.utils.getExternalAudioDirectory
|
||||
import com.philkes.notallyx.utils.getExternalFilesDirectory
|
||||
import com.philkes.notallyx.utils.getExternalImagesDirectory
|
||||
import com.philkes.notallyx.utils.getUriForFile
|
||||
import com.philkes.notallyx.utils.listZipFiles
|
||||
import com.philkes.notallyx.utils.log
|
||||
import com.philkes.notallyx.utils.logToFile
|
||||
import com.philkes.notallyx.utils.nameWithoutExtension
|
||||
import com.philkes.notallyx.utils.recreateDir
|
||||
import com.philkes.notallyx.utils.removeTrailingParentheses
|
||||
import com.philkes.notallyx.utils.security.decryptDatabase
|
||||
import com.philkes.notallyx.utils.security.getInitializedCipherForDecryption
|
||||
import com.philkes.notallyx.utils.wrapWithChooser
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.IOException
|
||||
|
@ -69,6 +78,7 @@ import java.util.Locale
|
|||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.lingala.zip4j.ZipFile
|
||||
import net.lingala.zip4j.exception.ZipException
|
||||
|
@ -88,7 +98,7 @@ const val OUTPUT_DATA_EXCEPTION = "exception"
|
|||
private const val ON_SAVE_BACKUP_FILE = "NotallyX_AutoBackup.zip"
|
||||
private const val PERIODIC_BACKUP_FILE_PREFIX = "NotallyX_Backup_"
|
||||
|
||||
fun Context.createBackup(): Result {
|
||||
fun ContextWrapper.createBackup(): Result {
|
||||
val app = applicationContext as Application
|
||||
val preferences = NotallyXPreferences.getInstance(app)
|
||||
val (_, maxBackups) = preferences.periodicBackups.value
|
||||
|
@ -96,7 +106,11 @@ fun Context.createBackup(): Result {
|
|||
|
||||
if (path != EMPTY_PATH) {
|
||||
val uri = Uri.parse(path)
|
||||
val folder = requireNotNull(DocumentFile.fromTreeUri(app, uri))
|
||||
val folder =
|
||||
requireBackupFolder(
|
||||
path,
|
||||
"Periodic Backup failed, because auto-backup path '$path' is invalid",
|
||||
) ?: return Result.success()
|
||||
fun log(msg: String? = null, throwable: Throwable? = null, stackTrace: String? = null) {
|
||||
logToFile(
|
||||
TAG,
|
||||
|
@ -107,79 +121,60 @@ fun Context.createBackup(): Result {
|
|||
stackTrace = stackTrace,
|
||||
)
|
||||
}
|
||||
|
||||
if (folder.exists()) {
|
||||
try {
|
||||
val formatter = SimpleDateFormat("yyyyMMdd-HHmmssSSS", Locale.ENGLISH)
|
||||
val backupFilePrefix = PERIODIC_BACKUP_FILE_PREFIX
|
||||
val name = "$backupFilePrefix${formatter.format(System.currentTimeMillis())}"
|
||||
log(msg = "Creating '$uri/$name.zip'...")
|
||||
try {
|
||||
val zipUri = requireNotNull(folder.createFile(MIME_TYPE_ZIP, name)).uri
|
||||
val exportedNotes =
|
||||
app.exportAsZip(zipUri, password = preferences.backupPassword.value)
|
||||
log(msg = "Exported $exportedNotes notes")
|
||||
val backupFiles = folder.listZipFiles(backupFilePrefix)
|
||||
log(msg = "Found ${backupFiles.size} backups")
|
||||
val backupsToBeDeleted = backupFiles.drop(maxBackups)
|
||||
if (backupsToBeDeleted.isNotEmpty()) {
|
||||
log(
|
||||
msg =
|
||||
"Deleting ${backupsToBeDeleted.size} oldest backups (maxBackups: $maxBackups): ${backupsToBeDeleted.joinToString { "'${it.name.toString()}'" }}"
|
||||
)
|
||||
}
|
||||
backupsToBeDeleted.forEach {
|
||||
if (it.exists()) {
|
||||
it.delete()
|
||||
}
|
||||
}
|
||||
log(msg = "Finished backup to '$zipUri'")
|
||||
preferences.periodicBackupLastExecution.save(Date().time)
|
||||
return Result.success(
|
||||
Data.Builder().putString(OUTPUT_DATA_BACKUP_URI, zipUri.path!!).build()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
log(msg = "Failed creating backup to '$uri/$name'", throwable = e)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
if (
|
||||
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
postErrorNotification(e)
|
||||
}
|
||||
} else {
|
||||
postErrorNotification(e)
|
||||
}
|
||||
return Result.success(
|
||||
Data.Builder().putString(OUTPUT_DATA_EXCEPTION, e.message).build()
|
||||
val name = "$backupFilePrefix${formatter.format(System.currentTimeMillis())}.zip"
|
||||
log(msg = "Creating '$uri/$name'...")
|
||||
val zipUri = requireNotNull(folder.createFile(MIME_TYPE_ZIP, name)).uri
|
||||
val exportedNotes = app.exportAsZip(zipUri, password = preferences.backupPassword.value)
|
||||
log(msg = "Exported $exportedNotes notes")
|
||||
val backupFiles = folder.listZipFiles(backupFilePrefix)
|
||||
log(msg = "Found ${backupFiles.size} backups")
|
||||
val backupsToBeDeleted = backupFiles.drop(maxBackups)
|
||||
if (backupsToBeDeleted.isNotEmpty()) {
|
||||
log(
|
||||
msg =
|
||||
"Deleting ${backupsToBeDeleted.size} oldest backups (maxBackups: $maxBackups): ${backupsToBeDeleted.joinToString { "'${it.name.toString()}'" }}"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
log(msg = "Folder '${folder.uri}' does not exist, therefore skipping auto-backup")
|
||||
backupsToBeDeleted.forEach {
|
||||
if (it.exists()) {
|
||||
it.delete()
|
||||
}
|
||||
}
|
||||
log(msg = "Finished backup to '$zipUri'")
|
||||
preferences.periodicBackupLastExecution.save(Date().time)
|
||||
return Result.success(
|
||||
Data.Builder().putString(OUTPUT_DATA_BACKUP_URI, zipUri.path!!).build()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
log(msg = "Failed creating backup to '$path'", throwable = e)
|
||||
tryPostErrorNotification(e)
|
||||
return Result.success(
|
||||
Data.Builder().putString(OUTPUT_DATA_EXCEPTION, e.message).build()
|
||||
)
|
||||
}
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
fun ContextWrapper.autoBackupOnSave(backupPath: String, password: String, savedNote: BaseNote?) {
|
||||
val backupFolder =
|
||||
try {
|
||||
DocumentFile.fromTreeUri(this, backupPath.toUri())!!
|
||||
} catch (e: Exception) {
|
||||
log(
|
||||
TAG,
|
||||
msg =
|
||||
"Auto backup on note save (${savedNote?.let {"id: '${savedNote.id}, title: '${savedNote.title}'" }}) failed, because auto-backup path is invalid",
|
||||
throwable = e,
|
||||
)
|
||||
return
|
||||
}
|
||||
val folder =
|
||||
requireBackupFolder(
|
||||
backupPath,
|
||||
"Auto backup on note save (${savedNote?.let { "id: '${savedNote.id}, title: '${savedNote.title}'" }}) failed, because auto-backup path '$backupPath' is invalid",
|
||||
) ?: return
|
||||
fun log(msg: String? = null, throwable: Throwable? = null) {
|
||||
logToFile(TAG, folder, NOTALLYX_BACKUP_LOGS_FILE, msg = msg, throwable = throwable)
|
||||
}
|
||||
try {
|
||||
var backupFile = backupFolder.findFile(ON_SAVE_BACKUP_FILE)
|
||||
var backupFile = folder.findFile(ON_SAVE_BACKUP_FILE)
|
||||
if (savedNote == null || backupFile == null || !backupFile.exists()) {
|
||||
backupFile = backupFolder.createFile(MIME_TYPE_ZIP, ON_SAVE_BACKUP_FILE)
|
||||
backupFile = folder.createFile(MIME_TYPE_ZIP, ON_SAVE_BACKUP_FILE)
|
||||
exportAsZip(backupFile!!.uri, password = password)
|
||||
} else {
|
||||
NotallyDatabase.getDatabase(this, observePreferences = false).value.checkpoint()
|
||||
val (_, file) = copyDatabase()
|
||||
val files =
|
||||
with(savedNote) {
|
||||
images.map {
|
||||
|
@ -197,26 +192,45 @@ fun ContextWrapper.autoBackupOnSave(backupPath: String, password: String, savedN
|
|||
audios.map {
|
||||
BackupFile(SUBFOLDER_AUDIOS, File(getExternalAudioDirectory(), it.name))
|
||||
} +
|
||||
BackupFile(
|
||||
null,
|
||||
NotallyDatabase.getCurrentDatabaseFile(this@autoBackupOnSave),
|
||||
)
|
||||
BackupFile(null, file)
|
||||
}
|
||||
exportToZip(backupFile.uri, files, password)
|
||||
try {
|
||||
exportToZip(backupFile.uri, files, password)
|
||||
} catch (e: ZipException) {
|
||||
log(
|
||||
msg =
|
||||
"Re-creating full backup since existing auto backup ZIP is corrupt: ${e.message}"
|
||||
)
|
||||
backupFile.delete()
|
||||
autoBackupOnSave(backupPath, password, savedNote)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logToFile(
|
||||
TAG,
|
||||
backupFolder,
|
||||
NOTALLYX_BACKUP_LOGS_FILE,
|
||||
msg =
|
||||
"Auto backup on note save (${savedNote?.let {"id: '${savedNote.id}, title: '${savedNote.title}'" }}) failed",
|
||||
throwable = e,
|
||||
log(
|
||||
"Auto backup on note save (${savedNote?.let { "id: '${savedNote.id}, title: '${savedNote.title}'" }}) failed",
|
||||
e,
|
||||
)
|
||||
tryPostErrorNotification(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun ContextWrapper.checkAutoSave(
|
||||
private fun ContextWrapper.requireBackupFolder(path: String, msg: String): DocumentFile? {
|
||||
return try {
|
||||
val folder = DocumentFile.fromTreeUri(this, path.toUri())!!
|
||||
if (!folder.exists()) {
|
||||
log(TAG, msg = msg)
|
||||
tryPostErrorNotification(IllegalArgumentException("Folder '$path' does not exist"))
|
||||
return null
|
||||
}
|
||||
folder
|
||||
} catch (e: Exception) {
|
||||
log(TAG, msg = msg, throwable = e)
|
||||
tryPostErrorNotification(IllegalArgumentException("Folder '$path' does not exist", e))
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun ContextWrapper.checkBackupOnSave(
|
||||
preferences: NotallyXPreferences,
|
||||
note: BaseNote? = null,
|
||||
forceFullBackup: Boolean = false,
|
||||
|
@ -227,7 +241,9 @@ fun ContextWrapper.checkAutoSave(
|
|||
if (forceFullBackup) {
|
||||
deleteModifiedNoteBackup(backupPath)
|
||||
}
|
||||
autoBackupOnSave(backupPath, preferences.backupPassword.value, note)
|
||||
withContext(Dispatchers.IO) {
|
||||
autoBackupOnSave(backupPath, preferences.backupPassword.value, note)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -249,78 +265,81 @@ fun ContextWrapper.exportAsZip(
|
|||
backupProgress: MutableLiveData<Progress>? = null,
|
||||
): Int {
|
||||
backupProgress?.postValue(Progress(indeterminate = true))
|
||||
|
||||
val tempFile = File.createTempFile("export", "tmp", cacheDir)
|
||||
val zipFile =
|
||||
ZipFile(tempFile, if (password != PASSWORD_EMPTY) password.toCharArray() else null)
|
||||
val zipParameters =
|
||||
ZipParameters().apply {
|
||||
isEncryptFiles = password != PASSWORD_EMPTY
|
||||
if (!compress) {
|
||||
compressionLevel = CompressionLevel.NO_COMPRESSION
|
||||
try {
|
||||
val zipFile =
|
||||
ZipFile(tempFile, if (password != PASSWORD_EMPTY) password.toCharArray() else null)
|
||||
val zipParameters =
|
||||
ZipParameters().apply {
|
||||
isEncryptFiles = password != PASSWORD_EMPTY
|
||||
if (!compress) {
|
||||
compressionLevel = CompressionLevel.NO_COMPRESSION
|
||||
}
|
||||
encryptionMethod = EncryptionMethod.AES
|
||||
}
|
||||
encryptionMethod = EncryptionMethod.AES
|
||||
}
|
||||
|
||||
val (databaseOriginal, databaseCopy) = copyDatabase()
|
||||
zipFile.addFile(databaseCopy, zipParameters.copy(DATABASE_NAME))
|
||||
databaseCopy.delete()
|
||||
val (databaseOriginal, databaseCopy) = copyDatabase()
|
||||
zipFile.addFile(databaseCopy, zipParameters.copy(DATABASE_NAME))
|
||||
databaseCopy.delete()
|
||||
|
||||
val imageRoot = getExternalImagesDirectory()
|
||||
val fileRoot = getExternalFilesDirectory()
|
||||
val audioRoot = getExternalAudioDirectory()
|
||||
val imageRoot = getExternalImagesDirectory()
|
||||
val fileRoot = getExternalFilesDirectory()
|
||||
val audioRoot = getExternalAudioDirectory()
|
||||
|
||||
val totalNotes = databaseOriginal.getBaseNoteDao().count()
|
||||
val images = databaseOriginal.getBaseNoteDao().getAllImages().toFileAttachments()
|
||||
val files = databaseOriginal.getBaseNoteDao().getAllFiles().toFileAttachments()
|
||||
val audios = databaseOriginal.getBaseNoteDao().getAllAudios()
|
||||
val totalAttachments = images.count() + files.count() + audios.size
|
||||
backupProgress?.postValue(Progress(0, totalAttachments))
|
||||
val totalNotes = databaseOriginal.getBaseNoteDao().count()
|
||||
val images = databaseOriginal.getBaseNoteDao().getAllImages().toFileAttachments()
|
||||
val files = databaseOriginal.getBaseNoteDao().getAllFiles().toFileAttachments()
|
||||
val audios = databaseOriginal.getBaseNoteDao().getAllAudios()
|
||||
val totalAttachments = images.count() + files.count() + audios.size
|
||||
backupProgress?.postValue(Progress(0, totalAttachments))
|
||||
|
||||
val counter = AtomicInteger(0)
|
||||
images.export(
|
||||
zipFile,
|
||||
zipParameters,
|
||||
imageRoot,
|
||||
SUBFOLDER_IMAGES,
|
||||
this,
|
||||
backupProgress,
|
||||
totalAttachments,
|
||||
counter,
|
||||
)
|
||||
files.export(
|
||||
zipFile,
|
||||
zipParameters,
|
||||
fileRoot,
|
||||
SUBFOLDER_FILES,
|
||||
this,
|
||||
backupProgress,
|
||||
totalAttachments,
|
||||
counter,
|
||||
)
|
||||
audios
|
||||
.asSequence()
|
||||
.flatMap { string -> Converters.jsonToAudios(string) }
|
||||
.forEach { audio ->
|
||||
try {
|
||||
backupFile(zipFile, zipParameters, audioRoot, SUBFOLDER_AUDIOS, audio.name)
|
||||
} catch (exception: Exception) {
|
||||
log(TAG, throwable = exception)
|
||||
} finally {
|
||||
backupProgress?.postValue(Progress(counter.incrementAndGet(), totalAttachments))
|
||||
val counter = AtomicInteger(0)
|
||||
images.export(
|
||||
zipFile,
|
||||
zipParameters,
|
||||
imageRoot,
|
||||
SUBFOLDER_IMAGES,
|
||||
this,
|
||||
backupProgress,
|
||||
totalAttachments,
|
||||
counter,
|
||||
)
|
||||
files.export(
|
||||
zipFile,
|
||||
zipParameters,
|
||||
fileRoot,
|
||||
SUBFOLDER_FILES,
|
||||
this,
|
||||
backupProgress,
|
||||
totalAttachments,
|
||||
counter,
|
||||
)
|
||||
audios
|
||||
.asSequence()
|
||||
.flatMap { string -> Converters.jsonToAudios(string) }
|
||||
.forEach { audio ->
|
||||
try {
|
||||
backupFile(zipFile, zipParameters, audioRoot, SUBFOLDER_AUDIOS, audio.name)
|
||||
} catch (exception: Exception) {
|
||||
log(TAG, throwable = exception)
|
||||
} finally {
|
||||
backupProgress?.postValue(Progress(counter.incrementAndGet(), totalAttachments))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
zipFile.close()
|
||||
contentResolver.openOutputStream(fileUri)?.use { outputStream ->
|
||||
FileInputStream(zipFile.file).use { inputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
outputStream.flush()
|
||||
zipFile.close()
|
||||
contentResolver.openOutputStream(fileUri)?.use { outputStream ->
|
||||
FileInputStream(zipFile.file).use { inputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
outputStream.flush()
|
||||
}
|
||||
zipFile.file.delete()
|
||||
}
|
||||
zipFile.file.delete()
|
||||
backupProgress?.postValue(Progress(inProgress = false))
|
||||
return totalNotes
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
backupProgress?.postValue(Progress(inProgress = false))
|
||||
return totalNotes
|
||||
}
|
||||
|
||||
fun Context.exportToZip(
|
||||
|
@ -328,29 +347,31 @@ fun Context.exportToZip(
|
|||
files: List<BackupFile>,
|
||||
password: String = PASSWORD_EMPTY,
|
||||
): Boolean {
|
||||
val tempDir = File(cacheDir, "tempZip").apply { mkdirs() }
|
||||
val zipInputStream = contentResolver.openInputStream(zipUri) ?: return false
|
||||
extractZipToDirectory(zipInputStream, tempDir, password)
|
||||
files.forEach { file ->
|
||||
val targetFile = File(tempDir, "${file.first?.let { "$it/" } ?: ""}${file.second.name}")
|
||||
file.second.copyTo(targetFile, overwrite = true)
|
||||
val tempDir = File(cacheDir, "export").recreateDir()
|
||||
try {
|
||||
val zipInputStream = contentResolver.openInputStream(zipUri) ?: return false
|
||||
extractZipToDirectory(zipInputStream, tempDir, password)
|
||||
files.forEach { file ->
|
||||
val targetFile = File(tempDir, "${file.first?.let { "$it/" } ?: ""}${file.second.name}")
|
||||
file.second.copyTo(targetFile, overwrite = true)
|
||||
}
|
||||
val zipOutputStream = contentResolver.openOutputStream(zipUri, "w") ?: return false
|
||||
createZipFromDirectory(tempDir, zipOutputStream, password)
|
||||
} finally {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
val zipOutputStream = contentResolver.openOutputStream(zipUri, "w") ?: return false
|
||||
createZipFromDirectory(tempDir, zipOutputStream, password)
|
||||
tempDir.deleteRecursively()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun extractZipToDirectory(zipInputStream: InputStream, outputDir: File, password: String) {
|
||||
val tempZipFile = File.createTempFile("extractedZip", null, outputDir)
|
||||
try {
|
||||
val tempZipFile = File.createTempFile("tempZip", ".zip", outputDir)
|
||||
tempZipFile.outputStream().use { zipOutputStream -> zipInputStream.copyTo(zipOutputStream) }
|
||||
val zipFile =
|
||||
ZipFile(tempZipFile, if (password != PASSWORD_EMPTY) password.toCharArray() else null)
|
||||
zipFile.extractAll(outputDir.absolutePath)
|
||||
} finally {
|
||||
tempZipFile.delete()
|
||||
} catch (e: ZipException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -360,10 +381,9 @@ private fun createZipFromDirectory(
|
|||
password: String = PASSWORD_EMPTY,
|
||||
compress: Boolean = false,
|
||||
) {
|
||||
val tempZipFile = File.createTempFile("tempZip", ".zip")
|
||||
try {
|
||||
val tempZipFile = File.createTempFile("tempZip", ".zip")
|
||||
tempZipFile.deleteOnExit()
|
||||
|
||||
val zipFile =
|
||||
ZipFile(tempZipFile, if (password != PASSWORD_EMPTY) password.toCharArray() else null)
|
||||
val zipParameters =
|
||||
|
@ -377,9 +397,8 @@ private fun createZipFromDirectory(
|
|||
}
|
||||
zipFile.addFolder(sourceDir, zipParameters)
|
||||
tempZipFile.inputStream().use { inputStream -> inputStream.copyTo(zipOutputStream) }
|
||||
} finally {
|
||||
tempZipFile.delete()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -387,6 +406,7 @@ fun ContextWrapper.copyDatabase(): Pair<NotallyDatabase, File> {
|
|||
val database = NotallyDatabase.getDatabase(this, observePreferences = false).value
|
||||
database.checkpoint()
|
||||
val preferences = NotallyXPreferences.getInstance(this)
|
||||
val databaseFile = NotallyDatabase.getCurrentDatabaseFile(this)
|
||||
return if (
|
||||
preferences.biometricLock.value == BiometricLock.ENABLED &&
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
|
||||
|
@ -394,16 +414,11 @@ fun ContextWrapper.copyDatabase(): Pair<NotallyDatabase, File> {
|
|||
val cipher = getInitializedCipherForDecryption(iv = preferences.iv.value!!)
|
||||
val passphrase = cipher.doFinal(preferences.databaseEncryptionKey.value)
|
||||
val decryptedFile = File(cacheDir, DATABASE_NAME)
|
||||
decryptDatabase(
|
||||
this,
|
||||
passphrase,
|
||||
decryptedFile,
|
||||
NotallyDatabase.getCurrentDatabaseName(this),
|
||||
)
|
||||
decryptDatabase(this, passphrase, databaseFile, decryptedFile)
|
||||
Pair(database, decryptedFile)
|
||||
} else {
|
||||
val dbFile = File(cacheDir, DATABASE_NAME)
|
||||
NotallyDatabase.getCurrentDatabaseFile(this).copyTo(dbFile, overwrite = true)
|
||||
databaseFile.copyTo(dbFile, overwrite = true)
|
||||
Pair(database, dbFile)
|
||||
}
|
||||
}
|
||||
|
@ -489,7 +504,7 @@ private fun ZipParameters.copy(fileNameInZip: String? = this.fileNameInZip): Zip
|
|||
}
|
||||
|
||||
fun exportPdfFile(
|
||||
app: Application,
|
||||
app: Context,
|
||||
note: BaseNote,
|
||||
folder: DocumentFile,
|
||||
fileName: String = note.title,
|
||||
|
@ -499,13 +514,14 @@ fun exportPdfFile(
|
|||
total: Int? = null,
|
||||
duplicateFileCount: Int = 1,
|
||||
) {
|
||||
val filePath = "$fileName.${ExportMimeType.PDF.fileExtension}"
|
||||
val validFileName = fileName.ifBlank { app.getString(R.string.note) }
|
||||
val filePath = "$validFileName.${ExportMimeType.PDF.fileExtension}"
|
||||
if (folder.findFile(filePath)?.exists() == true) {
|
||||
return exportPdfFile(
|
||||
app,
|
||||
note,
|
||||
folder,
|
||||
"${fileName.removeTrailingParentheses()} ($duplicateFileCount)",
|
||||
"${validFileName.removeTrailingParentheses()} ($duplicateFileCount)",
|
||||
pdfPrintListener,
|
||||
progress,
|
||||
counter,
|
||||
|
@ -539,7 +555,7 @@ fun exportPdfFile(
|
|||
}
|
||||
|
||||
suspend fun exportPlainTextFile(
|
||||
app: Application,
|
||||
app: Context,
|
||||
note: BaseNote,
|
||||
exportType: ExportMimeType,
|
||||
folder: DocumentFile,
|
||||
|
@ -563,8 +579,9 @@ suspend fun exportPlainTextFile(
|
|||
)
|
||||
}
|
||||
return withContext(Dispatchers.IO) {
|
||||
val validFileName = fileName.takeIf { it.isNotBlank() } ?: app.getString(R.string.note)
|
||||
val file =
|
||||
folder.createFile(exportType.mimeType, fileName)?.let {
|
||||
folder.createFile(exportType.mimeType, validFileName)?.let {
|
||||
app.contentResolver.openOutputStream(it.uri)?.use { stream ->
|
||||
OutputStreamWriter(stream).use { writer ->
|
||||
writer.write(
|
||||
|
@ -606,52 +623,166 @@ fun Context.exportPreferences(preferences: NotallyXPreferences, uri: Uri): Boole
|
|||
}
|
||||
}
|
||||
|
||||
private fun Context.postErrorNotification(e: Throwable) {
|
||||
getSystemService<NotificationManager>()?.let { manager ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
manager.createChannelIfNotExists(NOTIFICATION_CHANNEL_ID)
|
||||
}
|
||||
val notification =
|
||||
NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.error)
|
||||
.setContentTitle(getString(R.string.auto_backup_failed))
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setStyle(
|
||||
NotificationCompat.BigTextStyle()
|
||||
.bigText(
|
||||
getString(
|
||||
R.string.auto_backup_error_message,
|
||||
"${e.javaClass.simpleName}: ${e.localizedMessage}",
|
||||
private fun Context.tryPostErrorNotification(e: Throwable) {
|
||||
fun postErrorNotification(e: Throwable) {
|
||||
getSystemService<NotificationManager>()?.let { manager ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
manager.createChannelIfNotExists(NOTIFICATION_CHANNEL_ID)
|
||||
}
|
||||
val notification =
|
||||
NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.error)
|
||||
.setContentTitle(getString(R.string.auto_backup_failed))
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setStyle(
|
||||
NotificationCompat.BigTextStyle()
|
||||
.bigText(
|
||||
getString(
|
||||
R.string.auto_backup_error_message,
|
||||
"${e.javaClass.simpleName}: ${e.localizedMessage}",
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.addAction(
|
||||
R.drawable.settings,
|
||||
getString(R.string.settings),
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
putExtra(MainActivity.EXTRA_FRAGMENT_TO_OPEN, R.id.Settings)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
),
|
||||
)
|
||||
.addAction(
|
||||
R.drawable.error,
|
||||
getString(R.string.report_bug),
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
createReportBugIntent(
|
||||
e.stackTraceToString(),
|
||||
title = "Auto Backup failed",
|
||||
body = "Error occurred during auto backup, see logs below",
|
||||
)
|
||||
.addAction(
|
||||
R.drawable.settings,
|
||||
getString(R.string.settings),
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
putExtra(MainActivity.EXTRA_FRAGMENT_TO_OPEN, R.id.Settings)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
manager.notify(NOTIFICATION_ID, notification)
|
||||
)
|
||||
.addAction(
|
||||
R.drawable.error,
|
||||
getString(R.string.report_bug),
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
createReportBugIntent(
|
||||
e.stackTraceToString(),
|
||||
title = "Auto Backup failed",
|
||||
body = "Error occurred during auto backup, see logs below",
|
||||
),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
manager.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
if (
|
||||
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
postErrorNotification(e)
|
||||
}
|
||||
} else {
|
||||
postErrorNotification(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun LockedActivity<*>.exportNotes(
|
||||
mimeType: ExportMimeType,
|
||||
notes: Collection<BaseNote>,
|
||||
saveFileResultLauncher: ActivityResultLauncher<Intent>,
|
||||
exportToFolderResultLauncher: ActivityResultLauncher<Intent>,
|
||||
) {
|
||||
baseModel.selectedExportMimeType = mimeType
|
||||
if (notes.size == 1) {
|
||||
val baseNote = notes.first()
|
||||
when (mimeType) {
|
||||
ExportMimeType.PDF -> {
|
||||
exportPdfFile(
|
||||
this,
|
||||
baseNote,
|
||||
DocumentFile.fromFile(getExportedPath()),
|
||||
pdfPrintListener =
|
||||
object : PdfPrintListener {
|
||||
|
||||
override fun onSuccess(file: DocumentFile) {
|
||||
showFileOptionsDialog(
|
||||
file,
|
||||
ExportMimeType.PDF.mimeType,
|
||||
saveFileResultLauncher,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onFailure(message: CharSequence?) {
|
||||
Toast.makeText(
|
||||
this@exportNotes,
|
||||
R.string.something_went_wrong,
|
||||
Toast.LENGTH_SHORT,
|
||||
)
|
||||
.show()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
ExportMimeType.TXT,
|
||||
ExportMimeType.JSON,
|
||||
ExportMimeType.HTML ->
|
||||
lifecycleScope.launch {
|
||||
exportPlainTextFile(
|
||||
this@exportNotes,
|
||||
baseNote,
|
||||
mimeType,
|
||||
DocumentFile.fromFile(getExportedPath()),
|
||||
)
|
||||
?.let {
|
||||
showFileOptionsDialog(it, mimeType.mimeType, saveFileResultLauncher)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lifecycleScope.launch {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
.apply { addCategory(Intent.CATEGORY_DEFAULT) }
|
||||
.wrapWithChooser(this@exportNotes)
|
||||
exportToFolderResultLauncher.launch(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LockedActivity<*>.showFileOptionsDialog(
|
||||
file: DocumentFile,
|
||||
mimeType: String,
|
||||
resultLauncher: ActivityResultLauncher<Intent>,
|
||||
) {
|
||||
MenuDialog(this)
|
||||
.add(R.string.view_file) { viewFile(getUriForFile(File(file.uri.path!!)), mimeType) }
|
||||
.add(R.string.save_to_device) { saveFileToDevice(file, mimeType, resultLauncher) }
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun LockedActivity<*>.viewFile(uri: Uri, mimeType: String) {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.apply {
|
||||
setDataAndType(uri, mimeType)
|
||||
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
}
|
||||
.wrapWithChooser(this)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun LockedActivity<*>.saveFileToDevice(
|
||||
file: DocumentFile,
|
||||
mimeType: String,
|
||||
resultLauncher: ActivityResultLauncher<Intent>,
|
||||
) {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT)
|
||||
.apply {
|
||||
type = mimeType
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
putExtra(Intent.EXTRA_TITLE, file.nameWithoutExtension!!)
|
||||
}
|
||||
.wrapWithChooser(this)
|
||||
baseModel.selectedExportFile = file
|
||||
resultLauncher.launch(intent)
|
||||
}
|
||||
|
|
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