Compare commits

...

319 commits
v7.0.0 ... main

Author SHA1 Message Date
PhilKes
d00300fa0e Bump version 7.4.1 2025-06-04 18:35:01 +02:00
Phil
f13e8227ca
Merge pull request #605 from PhilKes/fix/convert-to-updated-model
Fix convertTo action using updated model
2025-06-04 17:30:55 +02:00
PhilKes
11e472cd30 Fix convertTo action using updated model 2025-06-04 17:26:01 +02:00
Phil
4cc957ccd4
Merge pull request #589 from PhilKes/fix/bold-text-weight
For API >= 33 use font weight 700 for bold
2025-05-10 17:51:23 +02:00
PhilKes
86b74762c5 For API >= 33 use font weight 700 for bold 2025-05-10 17:49:19 +02:00
PhilKes
118285545a Bump versionCode 7404 2025-05-10 16:45:43 +02:00
Phil
402baf8056
Merge pull request #588 from PhilKes/fix/backup-on-save-decrypt
Fix decrypting database on auto-save backup if biometric lock is enabled
2025-05-10 16:43:25 +02:00
PhilKes
de27d40880 Fix decrypting database on auto-save backup if biometric lock is enabled 2025-05-10 15:55:18 +02:00
PhilKes
66ce623e85 Fix NOTALLYX_BACKUP_LOGS_FILE .txt postfix 2025-05-10 15:26:38 +02:00
PhilKes
3c2400c7e6 Bump versionCode 7403 2025-05-10 14:31:18 +02:00
Phil
c64a7b2ed7
Merge pull request #587 from PhilKes/fix/reminder-identifier-requestcode
Use note.id and reminder.id for reminder requestCode
2025-05-10 14:30:49 +02:00
PhilKes
fb687856f1 Use note.id and reminder.id for reminder requestCode 2025-05-10 14:29:23 +02:00
PhilKes
23d678c8a3 Fix double encoded stacktrace on report bug 2025-05-10 13:50:39 +02:00
PhilKes
ade08b52ed Fix widget when displayed note is deleted 2025-05-10 13:18:54 +02:00
PhilKes
62a35132e0 Improve import failed toast message 2025-05-10 12:24:57 +02:00
Phil
9fbe5a6b94
Merge pull request #583 from PhilKes/fix/export-pdf-empty-title
Fix export PDF file name if title is blank
2025-05-08 19:52:31 +02:00
PhilKes
cf7f6f9dda Fix export PDF file name if title is blank 2025-05-08 18:04:50 +02:00
PhilKes
01ac48f930 Bump versionCode 7402 2025-05-07 19:13:36 +02:00
Phil
015f43e94b
Merge pull request #580 from PhilKes/feat/hide-images-overview
Add setting to hide images in overview
2025-05-07 19:13:03 +02:00
PhilKes
830fb6a75c Update translations for new hide images option 2025-05-07 19:05:57 +02:00
PhilKes
c34ee3633e Add setting to hide images in overview 2025-05-07 19:05:57 +02:00
Phil
628bd9d564
Merge pull request #579 from PhilKes/fix/lock-widget
Fix showing widget when actually locked
2025-05-07 19:04:59 +02:00
PhilKes
3e889879fb Fix showing widget when actually locked 2025-05-07 17:52:41 +02:00
PhilKes
b191618a46 Bump versionCode 7401 2025-05-06 18:53:51 +02:00
Phil
5cbc62bdf7
Merge pull request #577 from PhilKes/translation/update
Update cs ru es fr zh-rCN strings.xml
2025-05-06 18:51:23 +02:00
PhilKes
d1e5770180 Update zh-rCN/strings.xml 2025-05-06 18:49:28 +02:00
PhilKes
29cee8faf4 Update fr/strings.xml 2025-05-06 18:48:01 +02:00
PhilKes
4f993af93f Update es/strings.xml 2025-05-06 18:47:34 +02:00
PhilKes
0f0eb80e9b Update ru/strings.xml 2025-05-06 18:46:18 +02:00
PhilKes
1314ab4437 Update cs/strings.xml 2025-05-06 18:45:45 +02:00
Phil
39022edfab
Merge pull request #576 from PhilKes/fix/checked-item-editable
Make checked ListItem non editable
2025-05-06 18:44:14 +02:00
PhilKes
157ecb1b13 Make checked ListItem non editable 2025-05-06 18:44:06 +02:00
Phil
fb35ffdac4
Merge pull request #573 from PhilKes/fix/biometric-lock-open-note
Fix/biometric lock open note
2025-05-06 18:41:46 +02:00
PhilKes
0fee25f022 Use androidx.biometric to fix compatibility issues 2025-05-06 18:41:35 +02:00
PhilKes
2341c30586 Fix hide on create Activity if unlocked 2025-05-06 18:41:35 +02:00
Phil
e553e78efb
Merge pull request #575 from PhilKes/feat/view-intent
Add intent-filter to open any text based file
2025-05-06 18:41:06 +02:00
PhilKes
724d08507a Add intent-filter to open any text based file 2025-05-06 18:40:58 +02:00
Phil
771546a0cb
Merge pull request #572 from PhilKes/fix/backup-logs-duplicates
Fix checking for existing backup logs file
2025-05-06 18:33:44 +02:00
PhilKes
1a6d4083e4 Fix checking for existing backup logs file 2025-05-06 18:33:37 +02:00
Phil
3ac63349d8
Merge pull request #571 from PhilKes/fix/search-actionmode
Update selected notes when search query is changed
2025-05-06 18:32:40 +02:00
PhilKes
06c48ab8d9 Update selected notes when search query is changed 2025-05-05 18:52:03 +02:00
PhilKes
8d20f26eae Add v7.4.0 changelogs 2025-04-18 16:22:17 +02:00
PhilKes
66f0d33cd4 Bump version 7.4.0 2025-04-18 16:15:08 +02:00
Phil
6d81b6f7c0
Merge pull request #551 from PhilKes/feat/disallow-screenshots
Add security option to disallow screenshots
2025-04-18 16:14:12 +02:00
PhilKes
2c1f5d5338 Add security option to disallow screenshots 2025-04-18 16:10:16 +02:00
Phil
adb981d76c
Merge pull request #550 from PhilKes/fix/keyboard-api-24
Fix ListItem deletion order and repair broken parent check state
2025-04-18 15:42:58 +02:00
PhilKes
73a0345fe4 Fix ListItem deletion order and repair broken parent check state 2025-04-18 15:41:45 +02:00
Phil
0407f2bdc4
Merge pull request #549 from PhilKes/fix/keyboard-api-24
Show keyboard when EditText is focused for API < 27
2025-04-18 15:25:15 +02:00
PhilKes
212c354072 Show keyboard when EditText is focused for API < 27 2025-04-18 15:15:49 +02:00
Phil
07ff5691e2
Merge pull request #548 from PhilKes/fix/reminders-dialog-scroll
Reduce reminder dialog padding and add scrolling
2025-04-18 14:54:24 +02:00
PhilKes
20cf84ab69 Reduce reminder dialog padding and add scrolling 2025-04-18 14:52:35 +02:00
Phil
6bfa013a6c
Merge pull request #547 from PhilKes/fix/navigationbarcolor-api24
Fix navigationBarColor in Note and BottomSheetFragments for API < 27
2025-04-18 14:47:29 +02:00
PhilKes
66a7b02c69 Fix navigationBarColor in Note and BottomSheetFragments for API < 27 2025-04-18 14:47:20 +02:00
Phil
62035091f5
Merge pull request #546 from PhilKes/fix/movementmethod-enterbody
Fix setting movementmethod for links
2025-04-18 14:28:06 +02:00
PhilKes
209e19d690 Fix setting movementmethod for links 2025-04-18 14:27:38 +02:00
Phil
899f2c0f9a
Merge pull request #545 from PhilKes/fix/share-text-note
Fix share pure text note
2025-04-18 14:08:19 +02:00
PhilKes
3a0b699c82 Fix share pure text note 2025-04-18 14:06:38 +02:00
Phil
18947835f1
Merge pull request #534 from PhilKes/translation/update
Translation/update
2025-04-10 17:26:09 +02:00
PhilKes
5c0ea100ee Remove unused translations 2025-04-10 17:24:45 +02:00
PhilKes
392a060329 Update fr/strings.xml 2025-04-10 17:11:57 +02:00
PhilKes
1aa5e2c9e7 Update pl/strings.xml 2025-04-10 17:11:19 +02:00
Phil
0d86d7aad8
Merge pull request #533 from PhilKes/fix/remove-capitalize-labels
Remove capitalization in input dialogs
2025-04-10 17:10:18 +02:00
PhilKes
772a43de31 Remove capitalization in input dialogs 2025-04-10 17:09:16 +02:00
PhilKes
ad9b410d45 Add v7.3.1 changelogs 2025-04-08 17:31:48 +02:00
PhilKes
6cf2a8ce1f Bump version 7.3.1 2025-04-08 17:29:24 +02:00
Phil
c33b639a07
Merge pull request #521 from PhilKes/fix/label-crash
Fix search navigate up loop and navigate label crash
2025-04-08 17:22:25 +02:00
PhilKes
61112a18d2 Fix search navigate up loop 2025-04-08 17:21:04 +02:00
PhilKes
30f889f3db Downgrade androidx.navigation to v2.3.5 2025-04-08 17:20:15 +02:00
PhilKes
b95782e53a Add v7.3.0 changelogs 2025-04-07 18:37:55 +02:00
PhilKes
19df4b817f Bump version 7.3.0 2025-04-07 18:23:11 +02:00
PhilKes
333b57c29d Remove it-It and pl-PL changelogs since too much effort 2025-04-07 18:22:23 +02:00
Phil
87124c32f4
Merge pull request #516 from PhilKes/fix/navigationbar-api-24
Remove white navigation bar background for API < 27
2025-04-06 21:55:09 +02:00
PhilKes
f4a7074811 Remove white navigation bar background for API < 27 2025-04-06 21:54:13 +02:00
PhilKes
1f6afb03d4 Fix docusaurus.config.ts baseUrl 2025-04-05 16:07:10 +02:00
PhilKes
56683d5255 Adjust Docusarus theme and index page 2025-04-05 16:03:43 +02:00
PhilKes
e24f630acf Add initial dokusaurus project + github workflows 2025-04-05 15:28:48 +02:00
PhilKes
6b3fec40eb Bump versionCode 7220 2025-04-05 12:26:33 +02:00
Phil
205116ac60
Merge pull request #510 from PhilKes/fix/search-focus
Fix initial focus on Search EditText + back pressing re-focus Search EditText
2025-04-05 12:25:28 +02:00
PhilKes
ffeecdf1ca Fix back pressing re-focus Search EditText 2025-04-05 12:15:24 +02:00
PhilKes
8d6b318e3b Fix initial focus on Search EditText 2025-04-05 12:15:24 +02:00
Phil
8d477e4366
Merge pull request #507 from PhilKes/translation/update
Update de + es + cs + zh-rCN strings.xml
2025-03-31 18:22:37 +02:00
PhilKes
79eacb079d Update de/strings.xml 2025-03-31 18:21:17 +02:00
PhilKes
c122c2cc48 Update zh-rCN/strings.xml 2025-03-31 18:14:29 +02:00
PhilKes
f97f99ded0 Update es/strings.xml 2025-03-31 18:13:46 +02:00
PhilKes
506bc5c362 Update cs/strings.xml 2025-03-31 18:13:11 +02:00
PhilKes
8784145b83 Bump versionCode 7219 2025-03-29 19:27:48 +01:00
PhilKes
7b1aa83fca Fix BaseNote.viewMode db migration 2025-03-29 19:27:20 +01:00
PhilKes
f9ea26f1fa Bump versionCode 7218 2025-03-29 14:10:51 +01:00
Phil
54d835e40b
Merge pull request #503 from PhilKes/feat/persist-note-viewmode
Persist viewMode for each note individually
2025-03-29 14:10:16 +01:00
PhilKes
9fd735ba95 Add viewMode to BaseNote.toJson 2025-03-29 14:09:36 +01:00
PhilKes
b5e13ce73a Remove obsolete defaultNoteViewMode preference 2025-03-29 13:46:17 +01:00
PhilKes
c586eab072 Persist viewMode for each note individually 2025-03-29 13:31:17 +01:00
Phil
860db3e6bb
Merge pull request #502 from PhilKes/fix/new-note-viewmode
Always use Edit view mode for new notes
2025-03-29 12:26:04 +01:00
PhilKes
883998e27f Always use Edit view mode for new notes 2025-03-29 12:24:24 +01:00
Phil
4924ee46ec
Merge pull request #500 from PhilKes/fix/next-action-edittext
Fix next action on ListItem EditText
2025-03-29 12:20:11 +01:00
PhilKes
ad5ad25e11 Fix next action on ListItem EditText 2025-03-29 12:19:36 +01:00
PhilKes
93119098bc Bump versionCode 7217 2025-03-28 18:28:02 +01:00
Phil
764c562859
Merge pull request #494 from PhilKes/feat/note-view-mode
Add Note view mode toggle for read-only mode
2025-03-28 18:27:10 +01:00
PhilKes
b1bf6bc12c Add Note view mode toggle for read-only mode 2025-03-28 18:18:27 +01:00
PhilKes
44d19341b1 Bump versionCode 7216 2025-03-27 19:28:02 +01:00
Phil
be734e080c
Merge pull request #492 from PhilKes/fix/backups-webdav
Fix file append mode not working with WebDAV backup Folder
2025-03-27 19:26:08 +01:00
PhilKes
8a4e2f9a92 Fix file append mode not working with WebDAV backup Folder 2025-03-27 19:21:11 +01:00
Phil
48c07dfe0f
Merge pull request #491 from PhilKes/feat/sharing-images
Add attached images to share note and support sharing files to the app
2025-03-27 18:37:38 +01:00
PhilKes
e09b0f44b7 Support sharing images/files to the app 2025-03-27 18:34:25 +01:00
PhilKes
155d4c1cd9 Add attached images to share note 2025-03-27 18:34:25 +01:00
PhilKes
8e3110f077 Remove unused android.permission.READ_EXTERNAL_STORAGE 2025-03-27 18:33:31 +01:00
PhilKes
17a3eda124 Disable obfuscation in proguard-rules.pro 2025-03-27 18:14:08 +01:00
PhilKes
7edbcccbe4 Bump versionCode 7215 2025-03-26 18:30:03 +01:00
Phil
f4bfa7ccb8
Merge pull request #490 from PhilKes/translation/update
Update zh-rCN and fr strings.xml
2025-03-26 18:29:17 +01:00
PhilKes
ae8bc8e859 Update fr/strings.xml 2025-03-26 18:27:56 +01:00
PhilKes
8af22e1e88 Update zh-rCN/strings.xml 2025-03-26 18:27:56 +01:00
Phil
a57628dc7a
Merge pull request #489 from PhilKes/feat/archived-notes-in-label
Display archived notes in label view
2025-03-26 18:27:25 +01:00
PhilKes
d239f20e6f Display archived notes in label view 2025-03-26 18:21:59 +01:00
Phil
2ddfd5adb9
Merge pull request #488 from PhilKes/feat/labels-hide
Do not show hidden labels in notes overview
2025-03-26 18:21:16 +01:00
PhilKes
b7b0b48c62 Do not show hidden labels in notes overview 2025-03-26 18:17:11 +01:00
Phil
cc64bad689
Merge pull request #487 from PhilKes/feat/edit-label-in-note
Hide hidden labels in overview and quick jump to label from note view
2025-03-26 18:16:16 +01:00
PhilKes
f4de4133ed Hide hidden labels in overview and quick jump to label from note view 2025-03-26 18:03:17 +01:00
PhilKes
21707dfe08 Bump versionCode 7214 2025-03-24 17:41:18 +01:00
Phil
d2ba38a20e
Merge pull request #482 from PhilKes/feat/json-import
Add json import
2025-03-24 17:33:03 +01:00
PhilKes
fe6d6eca8b Add json import 2025-03-24 17:31:00 +01:00
Phil
c5cfa7a6c9
Merge pull request #481 from PhilKes/translation/update
Update es and zh-rCN strings.xml
2025-03-24 17:06:03 +01:00
PhilKes
5711830b0f Update zh-rCN/strings.xml 2025-03-24 17:04:42 +01:00
PhilKes
dae19f07e0 Update es/strings.xml 2025-03-24 17:04:42 +01:00
Phil
36f3cc284f
Merge pull request #480 from PhilKes/fix/ignore-system-suggestionspans
Fix ignoring system SuggestionSpan changes
2025-03-24 17:04:11 +01:00
PhilKes
f38f813af6 Fix ignoring system SuggestionSpan changes 2025-03-24 17:03:59 +01:00
PhilKes
6b80b37714 Bump versionCode 7213 2025-03-23 14:26:19 +01:00
Phil
6c78204111
Merge pull request #474 from PhilKes/feat/convert-note-types
Add convert from/to list/text note actions
2025-03-23 14:25:56 +01:00
PhilKes
900defa670 Add convert from/to list/text note actions 2025-03-23 14:24:06 +01:00
PhilKes
a925712e1f Bump versionCode 7212 2025-03-23 13:28:55 +01:00
Phil
11d1d1fcc5
Merge pull request #473 from PhilKes/feat/dynamic-colors
Add option to use system's wallpaper colors
2025-03-23 13:28:29 +01:00
PhilKes
67e2a35c8f Add option to use system's wallpaper colors 2025-03-23 13:24:15 +01:00
Phil
11eeb0d5bc
Merge pull request #471 from PhilKes/fix/database-decrypt
Make sure decrypted DB cache file is deleted before decrypting
2025-03-22 11:41:20 +01:00
PhilKes
259e223637 Make sure decrypted DB cache file is deleted before decrypting 2025-03-22 11:40:08 +01:00
PhilKes
d7ad549878 Bump versionCode 7211 2025-03-22 11:08:54 +01:00
Phil
86998a84a0
Merge pull request #470 from PhilKes/feat/strikethrough-checked
Strike-through checked ListItems
2025-03-22 11:07:57 +01:00
PhilKes
ac2b87bba1 Strike-through checked ListItems 2025-03-22 11:05:14 +01:00
Phil
13e7b5ac1e
Merge pull request #469 from PhilKes/feat/quick-undo-redo-all
Long click to undo/redo all
2025-03-22 11:05:01 +01:00
PhilKes
5ac794885f Ignore SuggestionSpans for text changes and simplify ListEditTextChange 2025-03-22 11:00:59 +01:00
PhilKes
320c9048a5 Long click to undo/redo all 2025-03-22 11:00:59 +01:00
PhilKes
0cb3fa92df Add ripple effect to bottom appbar buttons 2025-03-22 11:00:59 +01:00
PhilKes
2bd5c575fe Add v7.2.1 changelogs 2025-03-19 18:11:52 +01:00
PhilKes
dbe1b0726b Bump version v7.2.1 2025-03-18 20:51:26 +01:00
Phil
dad74ace96 Merge pull request #465 from PhilKes/translation/update
Update zh-rCN/strings.xml
2025-03-18 18:00:19 +01:00
PhilKes
467f877dcc Update zh-rCN/strings.xml 2025-03-18 17:59:55 +01:00
Phil
3e4acd9355 Merge pull request #464 from PhilKes/fix/export-backup-database
Fix databaseFile path when creating backup
2025-03-18 17:57:48 +01:00
PhilKes
715d1aba1d Fix databaseFile path when creating backup 2025-03-18 17:53:27 +01:00
PhilKes
000bfe7466 Add rate app in About settings 2025-03-16 18:11:32 +01:00
Phil
b0d3cde257 Merge pull request #458 from PhilKes/feat/auto-save-idle
Add auto-save after user idle time
2025-03-16 18:05:28 +01:00
PhilKes
d330f93f00 Add auto-save after user idle time 2025-03-16 18:01:13 +01:00
Phil
f64267c226 Add Google Play badge to README.md 2025-03-12 18:17:29 +01:00
Phil
be16ef27a9
Merge pull request #452 from PhilKes/translation/update
Update cs/fr/zh-rCN translations
2025-03-10 19:01:56 +01:00
PhilKes
2f128f8de1 Update zh-rCN/strings.xml 2025-03-10 19:00:09 +01:00
PhilKes
7bb2ee53e4 Update fr/strings.xml 2025-03-10 18:59:19 +01:00
PhilKes
9fdfb61311 Update cs/strings.xml 2025-03-10 18:58:43 +01:00
PhilKes
628b9a4835 Add NDK debugSymbols to build 2025-03-10 18:11:01 +01:00
PhilKes
95a3b9c048 Add v7.2.0 changelogs 2025-03-09 11:40:31 +01:00
PhilKes
1ea19694fd Bump versionCode 7200 2025-03-08 18:54:53 +01:00
PhilKes
a3b7cdb984 Update de/strings.xml 2025-03-08 11:11:47 +01:00
PhilKes
ab63bfdb9d Added v7.2.0 changelogs 2025-03-08 11:10:09 +01:00
Phil
5a29ce7b12
Merge pull request #445 from PhilKes/fix/bottom-sheet-scrolling
Maximize ActionBottomSheets and allow scrolling
2025-03-07 21:46:37 +01:00
PhilKes
086ca01c74 Add scrolling to DialogColorPicker 2025-03-07 19:30:09 +01:00
PhilKes
b15b8efb1f Maximize ActionBottomSheets and allow scrolling 2025-03-07 19:28:35 +01:00
Phil
1dbca6457b
Merge pull request #443 from PhilKes/feat/sort-by-color
Add sort notes by color option
2025-03-06 18:10:09 +01:00
PhilKes
684388634b Add sort notes by color option 2025-03-06 18:08:30 +01:00
PhilKes
d42d9ebd69 Set Github Token for generate-changelogs.sh 2025-03-06 18:08:11 +01:00
Phil
e48c7b5dec
Merge pull request #441 from PhilKes/feat/list-drag-scroll
Add auto-scroll when dragging ListItem to top/bottom
2025-03-05 18:26:07 +01:00
PhilKes
a02e45ad98 Scroll back to changed ListItem when using undo/redo 2025-03-05 17:40:35 +01:00
PhilKes
0215bcd676 Add auto-scroll when dragging ListItem to top/bottom 2025-03-05 17:37:08 +01:00
PhilKes
7c25838d97 Bump versionCode 7108 2025-03-04 17:25:16 +01:00
Phil
aae366eaff
Merge pull request #438 from PhilKes/fix/move-child-same-parent
Fix update new parent first in finishMove
2025-03-04 17:24:41 +01:00
PhilKes
e153cafe08 Fix update new parent first in finishMove 2025-03-04 17:24:01 +01:00
Phil
4c0ca095d4
Merge pull request #435 from PhilKes/fix/paste-list
Fix paste list text in ListItem
2025-03-03 18:47:46 +01:00
PhilKes
86e68b7936 Fix paste list text in ListItem 2025-03-03 18:25:35 +01:00
PhilKes
10fe736e46 Update translations.xlsx 2025-03-03 18:13:41 +01:00
Phil
45e8c28808
Merge pull request #433 from PhilKes/translation/update
Update zh-rTW/strings.xml + Bump android-translations-converter v1.0.5
2025-03-02 19:12:55 +01:00
PhilKes
b78c5c2259 Update zh-rTW/strings.xml 2025-03-02 19:12:20 +01:00
PhilKes
bed5b08236 Bump android-translations-converter v1.0.5 2025-03-02 19:12:04 +01:00
PhilKes
a00bc28d8e Bump versionCode 7107 2025-03-02 13:11:10 +01:00
PhilKes
a3281d8195 Exclude issues labeled 'already done' from CHANGELOG.md 2025-03-02 12:19:05 +01:00
Phil
94d470956a
Merge pull request #431 from PhilKes/fix/move-inplace
Call finishMove if ListItem dragged to same position
2025-03-02 12:11:59 +01:00
PhilKes
935433bef5 Call finishMove if ListItem dragged to same position 2025-03-02 12:11:34 +01:00
Phil
94e4b5cb7f
Merge pull request #430 from PhilKes/fix/delete-lock
Lock delete action while already deleting note
2025-03-02 11:47:27 +01:00
PhilKes
6698ddfa52 Lock delete action while already deleting note 2025-03-02 11:46:37 +01:00
Phil
6d2f2c00d0
Merge pull request #426 from PhilKes/fix/check-parent
Fix always check previous parent for checked on finishMove
2025-03-02 11:16:54 +01:00
PhilKes
14239cc0a6 Fix always check previous parent for checked on finishMove 2025-03-02 11:15:53 +01:00
PhilKes
0d2b5116a5 Fix enable swipe on previous first ListItem after move 2025-03-02 10:53:13 +01:00
PhilKes
bf7002b0c0 Bump versionCode 7106 2025-02-28 18:39:12 +01:00
Phil
d11b461708
Merge pull request #420 from PhilKes/fix/childindex-calculation
Calculate child index based on ListItem.order
2025-02-28 18:37:20 +01:00
PhilKes
b207b8df77 Fix init List note without auto-sort 2025-02-28 18:08:24 +01:00
PhilKes
bd24776421 Calculate child index based on ListItem.order 2025-02-28 18:02:32 +01:00
PhilKes
02930a25b8 Bump versionCode 7105 2025-02-27 18:24:00 +01:00
Phil
437942536d
Merge pull request #417 from PhilKes/fix/recreate-editactivity
Correctly reload state if EditActivity was destroyed by system
2025-02-27 18:23:03 +01:00
PhilKes
d02171eec4 Correctly reload state if EditActivity was destroyed by system 2025-02-27 18:21:22 +01:00
Phil
e57cec7eef
Merge pull request #416 from PhilKes/fix/savenote-on-crash
Save open note on crash
2025-02-27 18:21:07 +01:00
PhilKes
ac8ec46361 Save open note on crash 2025-02-27 18:15:57 +01:00
PhilKes
ae70770c27 Bump versionCode 7104 2025-02-26 17:41:18 +01:00
Phil
58e9d5a439
Merge pull request #415 from PhilKes/fix/move-ischild
Only make moved items child if item below is child
2025-02-26 17:40:09 +01:00
PhilKes
2b2f7a696b Only make moved items child if item below is child 2025-02-26 17:39:49 +01:00
Phil
3b550162ff
Merge pull request #414 from PhilKes/translation/update
Add zh-rTW/strings.xml
2025-02-26 17:39:07 +01:00
PhilKes
ba9db21ee9 Add zh-rTW/strings.xml 2025-02-26 17:38:32 +01:00
Phil
6f2aee42fd
Merge pull request #388 from PhilKes/refactor/checked-list
Refactor checked items into separate list
2025-02-25 18:44:22 +01:00
PhilKes
b3519b6dc3 Bump versionCode 7103 2025-02-25 18:39:51 +01:00
PhilKes
5dc578eb44 Cleanup old ListItemSortedList implementation 2025-02-25 18:01:40 +01:00
PhilKes
650243edc9 Fix changeIsChild order update when checked items between 2025-02-25 17:42:46 +01:00
PhilKes
503c719a2e Bump versionCode 7102 2025-02-24 20:11:32 +01:00
PhilKes
a4c7822f5c Check parent if only unchecked child was made parent 2025-02-24 20:11:12 +01:00
PhilKes
8ee9d99213 Fix isChild when moving parent with children 2025-02-24 20:11:12 +01:00
PhilKes
3c15cd771f Fix EditListActivity.updateModel items list 2025-02-24 18:55:04 +01:00
PhilKes
9a1d2c4df0 Fix en-/disable swipe if first ListItem changed/moved 2025-02-24 18:47:26 +01:00
PhilKes
e8ff4d7e44 Fix remove ListItem with other items having same body 2025-02-24 18:31:31 +01:00
PhilKes
dcd2aea1c9 Bump versionName 7.2.0 2025-02-23 18:30:31 +01:00
PhilKes
80ac25debd Bump versionCode 7101 2025-02-23 18:25:24 +01:00
PhilKes
8b9c4dec13 Update orders of checkedlist when moving unchecked 2025-02-23 18:25:07 +01:00
PhilKes
2863bb8476 Decrease FastScrollBar min touch size for better delete access 2025-02-23 18:06:04 +01:00
PhilKes
43158e67e5 Use MutableList instead of SortedList 2025-02-23 17:42:48 +01:00
PhilKes
80da91e00b Check parent if unchecked child is deleted if no unchecked child left 2025-02-21 11:25:33 +01:00
PhilKes
b88f80fe75 Check-/Uncheck parent if a child is dragged into it 2025-02-21 11:25:33 +01:00
PhilKes
1c7ee05c69 Fix recalc multiple child positions on move 2025-02-21 11:25:33 +01:00
PhilKes
34c18f8842 Decrease drag handle margin 2025-02-21 11:25:28 +01:00
PhilKes
41cfb1bb2b Fix List item add with children order 2025-02-21 11:25:28 +01:00
PhilKes
0df152491f Fix EditListActivity search in checked items 2025-02-21 11:25:28 +01:00
PhilKes
b2bd349aae Fix ListItemSortedList.moveItemRange position recalculations 2025-02-21 11:25:25 +01:00
PhilKes
55df9cff48 Add v7.1.0 changelogs 2025-02-20 17:42:01 +01:00
PhilKes
c4861a8510 Bump v7.1.0 2025-02-20 17:40:32 +01:00
Phil
cb5a220ec1
Merge pull request #394 from PhilKes/fix/backup-error-notification
Try send notification if backup folder does not exist
2025-02-16 13:10:30 +01:00
PhilKes
b1205b5409 Try send notification if backup folder does not exist 2025-02-16 13:09:34 +01:00
Phil
f24c791fc3
Merge pull request #389 from PhilKes/translation/update
Update polish + spanish translations
2025-02-15 13:20:40 +01:00
PhilKes
157967fa82 Update es/strings.xml 2025-02-15 13:19:39 +01:00
PhilKes
74864fc134 Add fastlane docs for pl-PL 2025-02-15 13:17:53 +01:00
PhilKes
08a2721ca9 Update pl/strings.xml 2025-02-15 13:16:47 +01:00
PhilKes
bc6631adbb Bump versionCode 7008 2025-02-05 18:45:29 +01:00
Phil
09864e3671
Merge pull request #375 from PhilKes/fix/notify-autosave-backup-error
Fix AutoBackup temp dirs cleanup and send error notification
2025-02-05 18:44:39 +01:00
PhilKes
47f3f369c8 Fix german auto_backup_error_message translation 2025-02-05 18:22:51 +01:00
PhilKes
a1e863203d Fix AutoBackup temp dirs cleanup and send error notification 2025-02-05 18:22:51 +01:00
PhilKes
c8387e1f99 Fix periodInDays and maxBackups when periodic backup is enabled 2025-02-05 18:22:51 +01:00
Phil
642eea49ae Merge pull request #376 from PhilKes/fix/textview-color-api28
Fix TextView selectionHandleColor for API smaller 28
2025-02-05 18:21:13 +01:00
PhilKes
1d6c310e8a Fix TextView selectionHandleColor for API smaller 28 2025-02-05 17:42:26 +01:00
Phil
2991d441c4 Merge pull request #374 from PhilKes/fix/backuponsave-import
Refresh backupOnSave on settings import
2025-02-04 17:39:51 +01:00
PhilKes
d9568af924 Refresh backupOnSave on settings import 2025-02-04 17:39:29 +01:00
Phil
7de8559815
Merge pull request #370 from PhilKes/translation/update
Update fr/strings.xml
2025-02-03 18:28:20 +01:00
PhilKes
bde9cde875 Update fr/strings.xml 2025-02-03 18:28:00 +01:00
Phil
6e0c453d79
Merge pull request #369 from PhilKes/fix/startview-import-refresh
Fix startView refresh on import
2025-02-03 18:26:40 +01:00
PhilKes
392ac47b43 Fix startView refresh on import 2025-02-03 18:25:34 +01:00
PhilKes
7eb84dfa3f Add CHANGELOG.md generator 2025-02-02 17:46:55 +01:00
PhilKes
5ce329133a Update .gitignore 2025-02-02 17:16:01 +01:00
Phil
aa98179acf
Merge pull request #365 from PhilKes/translation/update
Update italian translations and add italian fastlane metadata
2025-02-02 17:04:40 +01:00
PhilKes
28b20ce504 Add italian fastlane metadata 2025-02-02 17:02:35 +01:00
PhilKes
a3bc7c9797 Update it/strings.xml 2025-02-02 16:35:49 +01:00
PhilKes
2e46527372 Bump versionCode 7007 2025-02-02 16:02:22 +01:00
Phil
17fe0038b6
Merge pull request #363 from PhilKes/fix/move-child-item
Fix child item startDrag call
2025-02-02 16:01:53 +01:00
PhilKes
c89ec57534 Fix child item startDrag call 2025-02-02 16:00:56 +01:00
PhilKes
55c091d28c Add drag child item to end of list test 2025-02-02 13:16:32 +01:00
PhilKes
93ce1c32d8 Bump versionCode 7006 2025-02-02 12:13:53 +01:00
Phil
acdfa7003a
Merge pull request #360 from PhilKes/fix/list-item-drag
Fix ListItem drag reset isDragged flag
2025-02-02 12:13:26 +01:00
PhilKes
dfafd22775 Fix ListItem drag reset isDragged flag 2025-02-02 12:11:46 +01:00
PhilKes
f00ce70cea Add all ErrorActivity translations 2025-02-02 11:45:55 +01:00
PhilKes
db6afd01b6 Bump versionCode 7005 2025-02-01 19:33:42 +01:00
Phil
45eca07d74
Merge pull request #355 from PhilKes/fix/new-item
Fix add new item after current on next pressed
2025-02-01 19:33:11 +01:00
PhilKes
111aabd249 Fix add new item after current on next pressed 2025-02-01 19:30:04 +01:00
PhilKes
feb0764303 Bump versionCode 7004 2025-02-01 17:56:02 +01:00
Phil
a2f14dfae7
Merge pull request #353 from PhilKes/fix/move-checked-autosort
Fix ListItem order updates when dragging
2025-02-01 17:55:36 +01:00
PhilKes
9c7588d19c Fix ListItem order updates when dragging 2025-02-01 17:50:50 +01:00
Phil
1627226765
Merge pull request #350 from PhilKes/feat/pure-dark-mode
Make dark mode pitch black
2025-02-01 17:10:28 +01:00
PhilKes
ec25080056 Make dark mode pitch black 2025-02-01 17:09:02 +01:00
PhilKes
4125572a42 Add v7.1.0 changelogs 2025-02-01 16:37:42 +01:00
PhilKes
f819e4a0a0 Update phoneScreenshots and featureGraphic 2025-02-01 16:31:18 +01:00
Phil
5738330726
Merge pull request #349 from PhilKes/fix/delete-color-title
Fix wrong title/message in delete color dialog
2025-02-01 14:55:43 +01:00
PhilKes
dd90e2b9d9 Fix wrong title/message in delete color dialog 2025-02-01 14:49:16 +01:00
PhilKes
419d2acaa7 Bump versionCode 7003 2025-02-01 13:21:44 +01:00
Phil
b5248f8f5a
Merge pull request #346 from PhilKes/translation/update
Update french + italian + spanish + german translations
2025-02-01 13:20:48 +01:00
PhilKes
914bf07174 Update de/strings.xml 2025-02-01 13:16:33 +01:00
PhilKes
465f7cf69b Update es/strings.xml 2025-02-01 13:08:35 +01:00
PhilKes
c1e926c402 Update it/strings.xml 2025-02-01 13:05:00 +01:00
PhilKes
87cacf4ce8 Update fr/strings.xml 2025-02-01 13:03:45 +01:00
Phil
9e0a3f948c
Merge pull request #345 from PhilKes/fix/drag-into-checked
Fix item orders when dragging unchecked into checked
2025-02-01 12:54:01 +01:00
PhilKes
6c88f0c281 Fix item orders when dragging unchecked into checked 2025-02-01 12:53:51 +01:00
Phil
369c8fa393
Merge pull request #343 from PhilKes/feat/back-press-startview
Navigate to startView on back press and make ColorEditDialog full size
2025-02-01 12:31:37 +01:00
PhilKes
2c86fbcd8a Navigate to startView on back press 2025-02-01 12:31:14 +01:00
PhilKes
a86932a463 Make ColorEditDialog full size 2025-02-01 11:58:39 +01:00
PhilKes
d7c67d9875 Bump versionCode 7002 2025-01-31 17:25:06 +01:00
Phil
157d6a77f7 Merge pull request #335 from PhilKes/feat/unlabeled-view
Add Unlabeled notes view and option to start app in specified notes view
2025-01-31 18:02:44 +01:00
PhilKes
9c5731a006 Add UnlabeledFragment to view note without labels 2025-01-31 18:01:12 +01:00
PhilKes
9fd6a4c745 Add setting to configure start view 2025-01-31 17:59:33 +01:00
PhilKes
eaef290780 Move Max labels in Navigation to content density settings 2025-01-31 17:59:26 +01:00
Phil
694c0f77a9 Merge pull request #334 from PhilKes/feat/custom-colors
Allow to choose custom notes colors
2025-01-31 17:58:30 +01:00
PhilKes
d47bd2a676 Allow to choose custom notes colors 2025-01-31 17:57:47 +01:00
PhilKes
603fb40961 Display default color options 2025-01-31 17:55:13 +01:00
PhilKes
97aea76b50 Allow to change any notes color 2025-01-31 17:41:13 +01:00
PhilKes
1b56b9e9cc Migrate Color Enum to string hex values 2025-01-31 17:38:13 +01:00
Phil
e49686d2da Merge pull request #333 from PhilKes/fix/undo-delete-checked
Simplify DeleteCheckedChange and ChangeCheckedForAllChange
2025-01-31 17:35:39 +01:00
PhilKes
3f3e16a66b Simplify DeleteCheckedChange and ChangeCheckedForAllChange 2025-01-31 17:33:50 +01:00
Phil
71ab55abe5 Merge pull request #332 from PhilKes/fix/import-settings-theme
Fix backups folder dialog when theme changes on settings import
2025-01-31 17:31:45 +01:00
PhilKes
ec9b9984a3 Fix dataInPublicfolder reload on reset 2025-01-31 17:12:15 +01:00
PhilKes
e68bfc33c7 Fix refresh backups folder dialog after theme change 2025-01-31 17:06:24 +01:00
PhilKes
c749ff1018 Fix ErrorActivity report bug 2025-01-31 17:03:48 +01:00
PhilKes
ceaf4cc3a3 Clearify send feedback and crash buttons 2025-01-30 18:19:12 +01:00
PhilKes
26b46c8fb3 Add donation button in settings 2025-01-30 18:19:12 +01:00
Phil
12131fafa2 Merge pull request #329 from PhilKes/feat/export-from-note
Add Export option to Note more options
2025-01-30 18:01:16 +01:00
PhilKes
22ecb5dcf2 Add Export option to Note more options 2025-01-29 20:03:28 +01:00
Phil
0f549f88e5 Merge pull request #328 from PhilKes/feat/widget-note-color
Color widgets in note's color
2025-01-29 20:03:18 +01:00
PhilKes
724fe1b241 Color widgets in note's color 2025-01-29 19:05:54 +01:00
PhilKes
f9f7aee5da Update fr/strings.xml 2025-01-29 18:23:15 +01:00
PhilKes
0c1d9e1181 Fix dis-/enable public folder database copy 2025-01-29 18:21:30 +01:00
PhilKes
24b54b066b Bump versionCode 7001 2025-01-29 17:50:35 +01:00
Phil
5508d96917 Merge pull request #326 from PhilKes/fix/public-data-biometric
Fix database file path when data in public folder enabled
2025-01-29 17:49:30 +01:00
PhilKes
1a4c28818c Fix database file path when data in public folder enabled 2025-01-29 17:49:21 +01:00
Phil
c75a33dd1a Merge pull request #320 from PhilKes/feat/list-checked-sort
Add option to auto-sort checked ListItems to end
2025-01-29 17:49:12 +01:00
PhilKes
62104e0878 Fix notifies on List move and checked change 2025-01-29 17:49:00 +01:00
PhilKes
bb9886e7e8 Fix moving parent with children into other parent 2025-01-29 17:49:00 +01:00
PhilKes
33f60ac7bc Unhide option to choose sort List items 2025-01-29 17:49:00 +01:00
Phil
176f1d54e3 Merge pull request #321 from PhilKes/fix/empty-list-preview
Show first item if list has no title
2025-01-29 17:48:48 +01:00
PhilKes
b9ad92b33a Show first item if list has no title 2025-01-28 18:30:12 +01:00
PhilKes
2438b42115 Temporarily disable flaky test in ListManagerWithChangeHistoryTest 2025-01-27 17:50:33 +01:00
PhilKes
4c90096dd2 Bump v7.0.0 2025-01-27 17:42:38 +01:00
Phil
165799d12f Merge pull request #318 from PhilKes/translation/update
Update german + french + czech translations
2025-01-27 17:37:21 +01:00
PhilKes
7fd074bff1 Update fr/strings.xml 2025-01-27 17:35:20 +01:00
PhilKes
d882187227 Update cs/strings.xml 2025-01-27 17:33:34 +01:00
PhilKes
1ef391f7e8 Update de/strings.xml 2025-01-27 17:31:21 +01:00
Phil
9fc29d5d3b Merge pull request #313 from PhilKes/fix/import-backups-folder
Prompt user to choose backupsFolder on import
2025-01-26 18:15:41 +01:00
255 changed files with 307231 additions and 3515 deletions

View file

@ -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
View 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
View file

@ -12,4 +12,5 @@ fastlane/*
!fastlane/join-testers.png
!fastlane/metadata
Gemfile*
*.sh
*.sh
!generate-changelogs.sh

371
CHANGELOG.md Normal file
View 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 Doesnt 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)*

View file

@ -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

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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

View 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')"
]
}
}

View 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')"
]
}
}

View file

@ -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" />

View file

@ -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()
}

View file

@ -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) {}
}

View file

@ -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}'"
)
}
}
}
}

View file

@ -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

View file

@ -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
}

View file

@ -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"

View 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,
)
}

View file

@ -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,
)
}

View file

@ -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,

View file

@ -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)
}
}

View file

@ -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,
)
)
}

View file

@ -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(

View file

@ -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
}
}

View file

@ -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

View file

@ -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
}
}
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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
}
}
}

View file

@ -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)
}
}

View file

@ -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
}
}
}

View file

@ -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
}
}

View file

@ -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)

View file

@ -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()

View file

@ -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"
}
}

View file

@ -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)
}
}

View file

@ -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())

View file

@ -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)

View file

@ -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"
}
}

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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"
}
}

View file

@ -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()
}
}

View file

@ -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 {

View file

@ -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)

View file

@ -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 =

View file

@ -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(

View file

@ -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) {

View file

@ -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
}

View file

@ -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>) {

View file

@ -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

View file

@ -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 {

View file

@ -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
}
}
}
}

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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) {

View file

@ -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,

View file

@ -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
}
}
}

View file

@ -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() }
}

View file

@ -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)

View file

@ -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)
}

View file

@ -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,
)

View file

@ -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()

View file

@ -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
}
)
}
}

View file

@ -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
},
)
}

View file

@ -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()

View file

@ -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)
}

View file

@ -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
}

View file

@ -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"
}
}

View file

@ -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 {

View file

@ -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)
}

View file

@ -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
}
}
}

View file

@ -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,
)
}

View file

@ -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) }
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
}
}
}

View file

@ -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)
}

View file

@ -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,

View file

@ -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

View file

@ -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? {

View file

@ -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)
}

View file

@ -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 {

View file

@ -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"

View file

@ -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()
}

View file

@ -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,
)

View file

@ -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() }
} ?: ""

View file

@ -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)

View file

@ -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 {

View file

@ -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)

View file

@ -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
}

View file

@ -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()
}
}

View file

@ -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