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
253 changed files with 307254 additions and 3542 deletions

View file

@ -22,7 +22,7 @@ body:
- type: input - type: input
id: android-version id: android-version
attributes: attributes:
label: Android Version label: Android Version (API Level)
description: What Android version are you using? description: What Android version are you using?
- type: textarea - type: textarea
id: logs 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/join-testers.png
!fastlane/metadata !fastlane/metadata
Gemfile* 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> <b>NotallyX | Minimalistic note taking app</b>
<p> <p>
<center> <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://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://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> </center>
</p> </p>
</h2> </h2>
@ -27,7 +27,7 @@
[Notally](https://github.com/OmGodse/Notally), but eXtended [Notally](https://github.com/OmGodse/Notally), but eXtended
* Create **rich text** notes with support for bold, italics, mono space and strike-through * 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 * Set **reminders** with notifications for important notes
* Complement your notes with any type of file such as **pictures**, PDFs, etc. * Complement your notes with any type of file such as **pictures**, PDFs, etc.
* **Sort notes** by title, last modified date, creation date * **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.android.build.gradle.internal.tasks.factory.dependsOn
import com.ncorti.ktfmt.gradle.tasks.KtfmtFormatTask import com.ncorti.ktfmt.gradle.tasks.KtfmtFormatTask
import org.apache.commons.configuration2.PropertiesConfiguration
import org.apache.commons.configuration2.io.FileHandler
plugins { plugins {
id("com.android.application") id("com.android.application")
@ -8,25 +10,27 @@ plugins {
id("com.google.devtools.ksp") id("com.google.devtools.ksp")
id("com.ncorti.ktfmt.gradle") version "0.20.1" id("com.ncorti.ktfmt.gradle") version "0.20.1"
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.0" 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 { android {
namespace = "com.philkes.notallyx" namespace = "com.philkes.notallyx"
compileSdk = 34 compileSdk = 34
ndkVersion = "29.0.13113456"
defaultConfig { defaultConfig {
applicationId = "com.philkes.notallyx" applicationId = "com.philkes.notallyx"
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = 7000 versionCode = project.findProperty("app.versionCode").toString().toInt()
versionName = "7.0.0" versionName = project.findProperty("app.versionName").toString()
resourceConfigurations += listOf( 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() vectorDrawables.generatedDensities?.clear()
ndk {
debugSymbolLevel= "FULL"
}
} }
ksp { ksp {
arg("room.generateKotlin", "true") arg("room.generateKotlin", "true")
arg("room.schemaLocation", "$projectDir/schemas") arg("room.schemaLocation", "$projectDir/schemas")
@ -130,12 +134,46 @@ tasks.register<Copy>("installLocalGitHooks") {
tasks.preBuild.dependsOn(tasks.named("installLocalGitHooks"), tasks.exportTranslationsToExcel) 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 { afterEvaluate {
tasks.named("bundleRelease").configure { tasks.named("bundleRelease").configure {
dependsOn(tasks.named("testReleaseUnitTest")) dependsOn(tasks.named("testReleaseUnitTest"))
} }
tasks.named("assembleRelease").configure { tasks.named("assembleRelease").configure {
dependsOn(tasks.named("testReleaseUnitTest")) dependsOn(tasks.named("testReleaseUnitTest"))
finalizedBy(tasks.named("generateChangelogs"))
} }
} }
@ -152,11 +190,12 @@ dependencies {
implementation("androidx.security:security-crypto:1.1.0-alpha06") implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("androidx.sqlite:sqlite-ktx:2.4.0") implementation("androidx.sqlite:sqlite-ktx:2.4.0")
implementation("androidx.work:work-runtime:2.9.1") implementation("androidx.work:work-runtime:2.9.1")
implementation("androidx.biometric:biometric:1.1.0")
implementation("cat.ereza:customactivityoncrash:2.4.0") implementation("cat.ereza:customactivityoncrash:2.4.0")
implementation("com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0") implementation("com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0")
implementation("com.github.bumptech.glide:glide:4.15.1") implementation("com.github.bumptech.glide:glide:4.15.1")
implementation("cn.Leaqi:SwipeDrawer:1.6") 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.android.material:material:1.12.0")
implementation("com.google.code.findbugs:jsr305:3.0.2") implementation("com.google.code.findbugs:jsr305:3.0.2")
implementation("me.zhanghai.android.fastscroll:library:1.3.0") 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 { #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *; # public *;
#} #}
-keepattributes LineNumberTable,SourceFile
# 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.
-renamesourcefileattribute 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 ** extends androidx.navigation.Navigator
-keep class ** implements org.ocpsoft.prettytime.TimeUnit -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 <uses-permission
android:name="android.permission.ACCESS_NETWORK_STATE" android:name="android.permission.ACCESS_NETWORK_STATE"
tools:node="remove" /> tools:node="remove" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <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_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <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" /> <uses-permission android:name="android.permission.USE_FINGERPRINT" />
<application <application
@ -69,9 +68,30 @@
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" /> <data android:mimeType="*/*" />
</intent-filter> </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>
<activity android:name=".presentation.activity.note.ViewImageActivity" /> <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 package com.philkes.notallyx
import android.app.Activity
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.os.Build import android.os.Build
import android.os.Bundle
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.work.WorkInfo import androidx.work.WorkInfo
import androidx.work.WorkManager 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.view.misc.NotNullLiveData
import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
@ -32,7 +36,7 @@ import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class NotallyXApplication : Application() { class NotallyXApplication : Application(), Application.ActivityLifecycleCallbacks {
private lateinit var biometricLockObserver: Observer<BiometricLock> private lateinit var biometricLockObserver: Observer<BiometricLock>
private lateinit var preferences: NotallyXPreferences private lateinit var preferences: NotallyXPreferences
@ -42,11 +46,17 @@ class NotallyXApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
registerActivityLifecycleCallbacks(this)
if (isTestRunner()) return if (isTestRunner()) return
preferences = NotallyXPreferences.getInstance(this) 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) { when (theme) {
Theme.DARK -> Theme.DARK ->
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
@ -59,6 +69,9 @@ class NotallyXApplication : Application() {
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
) )
} }
if (oldTheme != null) {
WidgetProvider.updateWidgets(this, locked = locked.value)
}
} }
preferences.backupsFolder.observeForeverWithPrevious { (backupFolderBefore, backupFolder) -> preferences.backupsFolder.observeForeverWithPrevious { (backupFolderBefore, backupFolder) ->
@ -159,4 +172,20 @@ class NotallyXApplication : Application() {
return Build.FINGERPRINT.equals("robolectric", ignoreCase = true) 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.CommonDao
import com.philkes.notallyx.data.dao.LabelDao import com.philkes.notallyx.data.dao.LabelDao
import com.philkes.notallyx.data.model.BaseNote 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.Converters
import com.philkes.notallyx.data.model.Label 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.view.misc.NotNullLiveData
import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences 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.SQLCipherUtils
import com.philkes.notallyx.utils.security.getInitializedCipherForDecryption import com.philkes.notallyx.utils.security.getInitializedCipherForDecryption
import java.io.File import java.io.File
import net.sqlcipher.database.SQLiteDatabase
import net.sqlcipher.database.SupportFactory import net.sqlcipher.database.SupportFactory
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@Database(entities = [BaseNote::class, Label::class], version = 7) @Database(entities = [BaseNote::class, Label::class], version = 9)
abstract class NotallyDatabase : RoomDatabase() { abstract class NotallyDatabase : RoomDatabase() {
abstract fun getLabelDao(): LabelDao abstract fun getLabelDao(): LabelDao
@ -43,7 +47,7 @@ abstract class NotallyDatabase : RoomDatabase() {
} }
private var biometricLockObserver: Observer<BiometricLock>? = null private var biometricLockObserver: Observer<BiometricLock>? = null
private var externalDataFolderObserver: Observer<Boolean>? = null private var dataInPublicFolderObserver: Observer<Boolean>? = null
companion object { companion object {
@ -75,7 +79,16 @@ abstract class NotallyDatabase : RoomDatabase() {
return context.getDatabasePath(DATABASE_NAME) 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) { return if (NotallyXPreferences.getInstance(context).dataInPublicFolder.value) {
getExternalDatabaseFile(context).absolutePath getExternalDatabaseFile(context).absolutePath
} else { } else {
@ -96,6 +109,10 @@ abstract class NotallyDatabase : RoomDatabase() {
} }
} }
fun getFreshDatabase(context: ContextWrapper): NotallyDatabase {
return createInstance(context, NotallyXPreferences.getInstance(context), false)
}
private fun createInstance( private fun createInstance(
context: ContextWrapper, context: ContextWrapper,
preferences: NotallyXPreferences, preferences: NotallyXPreferences,
@ -114,11 +131,14 @@ abstract class NotallyDatabase : RoomDatabase() {
Migration5, Migration5,
Migration6, Migration6,
Migration7, Migration7,
Migration8,
Migration9,
) )
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
SQLiteDatabase.loadLibs(context)
if (preferences.biometricLock.value == BiometricLock.ENABLED) { if (preferences.biometricLock.value == BiometricLock.ENABLED) {
if ( if (
SQLCipherUtils.getDatabaseState(context, getCurrentDatabaseName(context)) == SQLCipherUtils.getDatabaseState(getCurrentDatabaseFile(context)) ==
SQLCipherUtils.State.ENCRYPTED SQLCipherUtils.State.ENCRYPTED
) { ) {
initializeDecryption(preferences, instanceBuilder) initializeDecryption(preferences, instanceBuilder)
@ -127,7 +147,7 @@ abstract class NotallyDatabase : RoomDatabase() {
} }
} else { } else {
if ( if (
SQLCipherUtils.getDatabaseState(context, getCurrentDatabaseName(context)) == SQLCipherUtils.getDatabaseState(getCurrentDatabaseFile(context)) ==
SQLCipherUtils.State.ENCRYPTED SQLCipherUtils.State.ENCRYPTED
) { ) {
preferences.biometricLock.save(BiometricLock.ENABLED) preferences.biometricLock.save(BiometricLock.ENABLED)
@ -150,18 +170,18 @@ abstract class NotallyDatabase : RoomDatabase() {
instance.biometricLockObserver!! instance.biometricLockObserver!!
) )
instance.externalDataFolderObserver = Observer { instance.dataInPublicFolderObserver = Observer {
NotallyDatabase.instance?.value?.externalDataFolderObserver?.let { NotallyDatabase.instance?.value?.dataInPublicFolderObserver?.let {
preferences.dataInPublicFolder.removeObserver(it) preferences.dataInPublicFolder.removeObserver(it)
} }
val newInstance = createInstance(context, preferences, true) val newInstance = createInstance(context, preferences, true)
NotallyDatabase.instance?.postValue(newInstance) NotallyDatabase.instance?.postValue(newInstance)
preferences.dataInPublicFolder.observeForeverSkipFirst( preferences.dataInPublicFolder.observeForeverSkipFirst(
newInstance.externalDataFolderObserver!! newInstance.dataInPublicFolderObserver!!
) )
} }
preferences.dataInPublicFolder.observeForeverSkipFirst( preferences.dataInPublicFolder.observeForeverSkipFirst(
instance.externalDataFolderObserver!! instance.dataInPublicFolderObserver!!
) )
} }
return instance 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 androidx.sqlite.db.SupportSQLiteQuery
import com.philkes.notallyx.data.model.Audio import com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.data.model.BaseNote 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.FileAttachment
import com.philkes.notallyx.data.model.Folder import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.data.model.LabelsInBaseNote 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 != '[]'") @Query("SELECT id, reminders FROM BaseNote WHERE reminders IS NOT NULL AND reminders != '[]'")
suspend fun getAllReminders(): List<NoteIdReminder> suspend fun getAllReminders(): List<NoteIdReminder>
@Query("SELECT color FROM BaseNote WHERE id = :id ") fun getColorOfNote(id: Long): String?
@Query( @Query(
"SELECT id, title, type, reminders FROM BaseNote WHERE reminders IS NOT NULL AND reminders != '[]'" "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)") @Query("UPDATE BaseNote SET folder = :folder WHERE id IN (:ids)")
suspend fun move(ids: LongArray, folder: Folder) 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)") @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)") @Query("UPDATE BaseNote SET pinned = :pinned WHERE id IN (:ids)")
suspend fun updatePinned(ids: LongArray, pinned: Boolean) suspend fun updatePinned(ids: LongArray, pinned: Boolean)
@ -155,14 +163,19 @@ interface BaseNoteDao {
* directly on the LiveData to filter the results accordingly. * directly on the LiveData to filter the results accordingly.
*/ */
fun getBaseNotesByLabel(label: String): LiveData<List<BaseNote>> { 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) } } return result.map { list -> list.filter { baseNote -> baseNote.labels.contains(label) } }
} }
@Query( @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> { suspend fun getListOfBaseNotesByLabel(label: String): List<BaseNote> {
val result = getListOfBaseNotesByLabelImpl(label) val result = getListOfBaseNotesByLabelImpl(label)
@ -172,16 +185,42 @@ interface BaseNoteDao {
@Query("SELECT * FROM BaseNote WHERE labels LIKE '%' || :label || '%'") @Query("SELECT * FROM BaseNote WHERE labels LIKE '%' || :label || '%'")
suspend fun getListOfBaseNotesByLabelImpl(label: String): List<BaseNote> suspend fun getListOfBaseNotesByLabelImpl(label: String): List<BaseNote>
fun getBaseNotesByKeyword(keyword: String, folder: Folder): LiveData<List<BaseNote>> { fun getBaseNotesByKeyword(
val result = getBaseNotesByKeywordImpl(keyword, folder) 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) } } 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( @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" "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>> 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 { private fun matchesKeyword(baseNote: BaseNote, keyword: String): Boolean {
if (baseNote.title.contains(keyword, true)) { if (baseNote.title.contains(keyword, true)) {
return 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") fun getAll(): LiveData<List<String>>
@Query("SELECT value FROM Label ORDER BY value") suspend fun getArrayOfAll(): Array<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.NotallyDatabase
import com.philkes.notallyx.data.imports.evernote.EvernoteImporter import com.philkes.notallyx.data.imports.evernote.EvernoteImporter
import com.philkes.notallyx.data.imports.google.GoogleKeepImporter 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.imports.txt.PlainTextImporter
import com.philkes.notallyx.data.model.Audio import com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.data.model.FileAttachment 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.GOOGLE_KEEP -> GoogleKeepImporter()
ImportSource.EVERNOTE -> EvernoteImporter() ImportSource.EVERNOTE -> EvernoteImporter()
ImportSource.PLAIN_TEXT -> PlainTextImporter() ImportSource.PLAIN_TEXT -> PlainTextImporter()
ImportSource.JSON -> JsonImporter()
}.import(app, uri, tempDir, progress) }.import(app, uri, tempDir, progress)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "import: failed", e) Log.e(TAG, "import: failed", e)
@ -153,6 +155,13 @@ enum class ImportSource(
null, null,
R.drawable.text_file, 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" 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.imports.parseBodyAndSpansFromHtml
import com.philkes.notallyx.data.model.Audio import com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.data.model.BaseNote 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.FileAttachment
import com.philkes.notallyx.data.model.Folder import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.data.model.ListItem 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.data.model.Type
import com.philkes.notallyx.utils.log import com.philkes.notallyx.utils.log
import com.philkes.notallyx.utils.startsWithAnyOf import com.philkes.notallyx.utils.startsWithAnyOf
@ -143,7 +143,7 @@ fun EvernoteNote.mapToBaseNote(): BaseNote {
type = if (tasks.isEmpty()) Type.NOTE else Type.LIST, type = if (tasks.isEmpty()) Type.NOTE else Type.LIST,
folder = Folder.NOTES, // There is no archive in Evernote, also deleted notes are not folder = Folder.NOTES, // There is no archive in Evernote, also deleted notes are not
// exported // exported
color = Color.DEFAULT, // TODO: possible in Evernote? color = BaseNote.COLOR_DEFAULT, // TODO: possible in Evernote?
title = title, title = title,
pinned = false, // not exported from Evernote pinned = false, // not exported from Evernote
timestamp = parseTimestamp(created), timestamp = parseTimestamp(created),
@ -156,6 +156,7 @@ fun EvernoteNote.mapToBaseNote(): BaseNote {
files = files, files = files,
audios = audios, audios = audios,
reminders = mutableListOf(), 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.imports.parseBodyAndSpansFromHtml
import com.philkes.notallyx.data.model.Audio import com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.data.model.BaseNote 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.FileAttachment
import com.philkes.notallyx.data.model.Folder import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.data.model.ListItem 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.data.model.Type
import com.philkes.notallyx.utils.listFilesRecursive import com.philkes.notallyx.utils.listFilesRecursive
import com.philkes.notallyx.utils.log import com.philkes.notallyx.utils.log
@ -150,7 +150,7 @@ class GoogleKeepImporter : ExternalImporter {
googleKeepNote.isArchived -> Folder.ARCHIVED googleKeepNote.isArchived -> Folder.ARCHIVED
else -> Folder.NOTES else -> Folder.NOTES
}, },
color = Color.DEFAULT, // Ignoring color mapping color = BaseNote.COLOR_DEFAULT, // Ignoring color mapping
title = googleKeepNote.title, title = googleKeepNote.title,
pinned = googleKeepNote.isPinned, pinned = googleKeepNote.isPinned,
timestamp = googleKeepNote.createdTimestampUsec / 1000, timestamp = googleKeepNote.createdTimestampUsec / 1000,
@ -163,6 +163,7 @@ class GoogleKeepImporter : ExternalImporter {
files = files, files = files,
audios = audios, audios = audios,
reminders = mutableListOf(), reminders = mutableListOf(),
NoteViewMode.EDIT,
) )
} }

View file

@ -1,12 +1,12 @@
package com.philkes.notallyx.data.imports.google 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 import kotlinx.serialization.Serializable
@Serializable @Serializable
data class GoogleKeepNote( data class GoogleKeepNote(
val attachments: List<GoogleKeepAttachment> = listOf(), val attachments: List<GoogleKeepAttachment> = listOf(),
val color: String = Color.DEFAULT.name, val color: String = BaseNote.COLOR_DEFAULT,
val isTrashed: Boolean = false, val isTrashed: Boolean = false,
val isArchived: Boolean = false, val isArchived: Boolean = false,
val isPinned: 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.ExternalImporter
import com.philkes.notallyx.data.imports.ImportProgress import com.philkes.notallyx.data.imports.ImportProgress
import com.philkes.notallyx.data.model.BaseNote 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.Folder
import com.philkes.notallyx.data.model.ListItem 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.data.model.Type
import com.philkes.notallyx.utils.MIME_TYPE_JSON 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.File
import java.io.InputStreamReader
class PlainTextImporter : ExternalImporter { class PlainTextImporter : ExternalImporter {
@ -36,12 +35,7 @@ class PlainTextImporter : ExternalImporter {
return return
} }
val fileNameWithoutExtension = file.name?.substringBeforeLast(".") ?: "" val fileNameWithoutExtension = file.name?.substringBeforeLast(".") ?: ""
var content = var content = app.contentResolver.readFileContents(file.uri)
app.contentResolver.openInputStream(file.uri)?.use { inputStream ->
BufferedReader(InputStreamReader(inputStream)).use { reader ->
reader.readText()
}
} ?: ""
val listItems = mutableListOf<ListItem>() val listItems = mutableListOf<ListItem>()
content.findListSyntaxRegex()?.let { listSyntaxRegex -> content.findListSyntaxRegex()?.let { listSyntaxRegex ->
listItems.addAll(content.extractListItems(listSyntaxRegex)) listItems.addAll(content.extractListItems(listSyntaxRegex))
@ -53,7 +47,7 @@ class PlainTextImporter : ExternalImporter {
id = 0L, // Auto-generated id = 0L, // Auto-generated
type = if (listItems.isEmpty()) Type.NOTE else Type.LIST, type = if (listItems.isEmpty()) Type.NOTE else Type.LIST,
folder = Folder.NOTES, folder = Folder.NOTES,
color = Color.DEFAULT, color = BaseNote.COLOR_DEFAULT,
title = fileNameWithoutExtension, title = fileNameWithoutExtension,
pinned = false, pinned = false,
timestamp = timestamp, timestamp = timestamp,
@ -66,6 +60,7 @@ class PlainTextImporter : ExternalImporter {
files = listOf(), files = listOf(),
audios = listOf(), audios = listOf(),
reminders = listOf(), reminders = listOf(),
NoteViewMode.EDIT,
) )
) )
} }

View file

@ -4,12 +4,15 @@ import androidx.room.Entity
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
/** Format: `#RRGGBB` or `#AARRGGBB` or [BaseNote.COLOR_DEFAULT] */
typealias ColorString = String
@Entity(indices = [Index(value = ["id", "folder", "pinned", "timestamp", "labels"])]) @Entity(indices = [Index(value = ["id", "folder", "pinned", "timestamp", "labels"])])
data class BaseNote( data class BaseNote(
@PrimaryKey(autoGenerate = true) val id: Long, @PrimaryKey(autoGenerate = true) val id: Long,
val type: Type, val type: Type,
val folder: Folder, val folder: Folder,
val color: Color, val color: ColorString,
val title: String, val title: String,
val pinned: Boolean, val pinned: Boolean,
val timestamp: Long, val timestamp: Long,
@ -22,7 +25,60 @@ data class BaseNote(
val files: List<FileAttachment>, val files: List<FileAttachment>,
val audios: List<Audio>, val audios: List<Audio>,
val reminders: List<Reminder>, 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 { fun BaseNote.deepCopy(): BaseNote {
return copy( return copy(

View file

@ -12,5 +12,45 @@ enum class Color {
DUSK, DUSK,
FLOWER, FLOWER,
BLOSSOM, 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 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 @TypeConverter
fun filesToJson(files: List<FileAttachment>): String { fun filesToJson(files: List<FileAttachment>): String {
@ -24,10 +26,10 @@ object Converters {
return JSONArray(objects).toString() return JSONArray(objects).toString()
} }
@TypeConverter @TypeConverter fun jsonToFiles(json: String) = jsonToFiles(JSONArray(json))
fun jsonToFiles(json: String): List<FileAttachment> {
val iterable = JSONArray(json).iterable<JSONObject>() fun jsonToFiles(jsonArray: JSONArray): List<FileAttachment> {
return iterable.map { jsonObject -> return jsonArray.iterable<JSONObject>().map { jsonObject ->
val localName = getSafeLocalName(jsonObject) val localName = getSafeLocalName(jsonObject)
val originalName = getSafeOriginalName(jsonObject) val originalName = getSafeOriginalName(jsonObject)
val mimeType = jsonObject.getString("mimeType") val mimeType = jsonObject.getString("mimeType")
@ -47,10 +49,10 @@ object Converters {
return JSONArray(objects).toString() return JSONArray(objects).toString()
} }
@TypeConverter @TypeConverter fun jsonToAudios(json: String) = jsonToAudios(JSONArray(json))
fun jsonToAudios(json: String): List<Audio> {
val iterable = JSONArray(json).iterable<JSONObject>() fun jsonToAudios(json: JSONArray): List<Audio> {
return iterable.map { jsonObject -> return json.iterable<JSONObject>().map { jsonObject ->
val name = jsonObject.getString("name") val name = jsonObject.getString("name")
val duration = jsonObject.getSafeLong("duration") val duration = jsonObject.getSafeLong("duration")
val timestamp = jsonObject.getLong("timestamp") val timestamp = jsonObject.getLong("timestamp")
@ -58,31 +60,63 @@ object Converters {
} }
} }
@TypeConverter @TypeConverter fun jsonToSpans(json: String) = jsonToSpans(JSONArray(json))
fun jsonToSpans(json: String): List<SpanRepresentation> {
val iterable = JSONArray(json).iterable<JSONObject>() fun jsonToSpans(jsonArray: JSONArray): List<SpanRepresentation> {
return iterable.map { jsonObject -> return jsonArray
val bold = jsonObject.getSafeBoolean("bold") .iterable<JSONObject>()
val link = jsonObject.getSafeBoolean("link") .map { jsonObject ->
val linkData = jsonObject.getSafeString("linkData") val bold = jsonObject.getSafeBoolean("bold")
val italic = jsonObject.getSafeBoolean("italic") val link = jsonObject.getSafeBoolean("link")
val monospace = jsonObject.getSafeBoolean("monospace") val linkData = jsonObject.getSafeString("linkData")
val strikethrough = jsonObject.getSafeBoolean("strikethrough") val italic = jsonObject.getSafeBoolean("italic")
val start = jsonObject.getInt("start") val monospace = jsonObject.getSafeBoolean("monospace")
val end = jsonObject.getInt("end") val strikethrough = jsonObject.getSafeBoolean("strikethrough")
SpanRepresentation(start, end, bold, link, linkData, italic, monospace, 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 @TypeConverter
fun spansToJson(list: List<SpanRepresentation>) = spansToJSONArray(list).toString() fun spansToJson(list: List<SpanRepresentation>) = spansToJSONArray(list).toString()
@TypeConverter fun spansToJSONArray(list: List<SpanRepresentation>): JSONArray {
fun jsonToItems(json: String): List<ListItem> { val objects =
val iterable = JSONArray(json).iterable<JSONObject>() list.map { representation ->
return iterable.map { jsonObject -> val jsonObject = JSONObject()
val body = jsonObject.getString("body") jsonObject.put("bold", representation.bold)
val checked = jsonObject.getBoolean("checked") 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 isChild = jsonObject.getSafeBoolean("isChild")
val order = jsonObject.getSafeInt("order") val order = jsonObject.getSafeInt("order")
ListItem(body, checked, isChild, order, mutableListOf()) ListItem(body, checked, isChild, order, mutableListOf())
@ -103,39 +137,25 @@ object Converters {
return JSONArray(objects) 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 @TypeConverter
fun remindersToJson(reminders: List<Reminder>): String { fun remindersToJson(reminders: List<Reminder>) = remindersToJSONArray(reminders).toString()
fun remindersToJSONArray(reminders: List<Reminder>): JSONArray {
val objects = val objects =
reminders.map { reminder -> reminders.map { reminder ->
JSONObject().apply { JSONObject().apply {
put("id", reminder.id) // Store date as long timestamp put("id", reminder.id) // Store date as long timestamp
put("dateTime", reminder.dateTime.time) // 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 @TypeConverter fun jsonToReminders(json: String) = jsonToReminders(JSONArray(json))
fun jsonToReminders(json: String): List<Reminder> {
val iterable = JSONArray(json).iterable<JSONObject>() fun jsonToReminders(jsonArray: JSONArray): List<Reminder> {
return iterable.map { jsonObject -> return jsonArray.iterable<JSONObject>().map { jsonObject ->
val id = jsonObject.getLong("id") val id = jsonObject.getLong("id")
val dateTime = Date(jsonObject.getLong("dateTime")) val dateTime = Date(jsonObject.getLong("dateTime"))
val repetition = jsonObject.getSafeString("repetition")?.let { jsonToRepetition(it) } val repetition = jsonObject.getSafeString("repetition")?.let { jsonToRepetition(it) }
@ -145,10 +165,14 @@ object Converters {
@TypeConverter @TypeConverter
fun repetitionToJson(repetition: Repetition): String { fun repetitionToJson(repetition: Repetition): String {
return repetitionToJsonObject(repetition).toString()
}
fun repetitionToJsonObject(repetition: Repetition): JSONObject {
val jsonObject = JSONObject() val jsonObject = JSONObject()
jsonObject.put("value", repetition.value) jsonObject.put("value", repetition.value)
jsonObject.put("unit", repetition.unit.name) // Store the TimeUnit as a string jsonObject.put("unit", repetition.unit.name) // Store the TimeUnit as a string
return jsonObject.toString() return jsonObject
} }
@TypeConverter @TypeConverter

View file

@ -5,5 +5,14 @@ import java.io.Serializable
enum class Folder : Serializable { enum class Folder : Serializable {
NOTES, NOTES,
DELETED, 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 false
} }
return (this.body == other.body && return (this.body == other.body &&
this.order == other.order &&
this.checked == other.checked && this.checked == other.checked &&
this.isChild == other.isChild) this.isChild == other.isChild)
} }

View file

@ -1,5 +1,7 @@
package com.philkes.notallyx.data.model package com.philkes.notallyx.data.model
import com.philkes.notallyx.presentation.view.note.listitem.areAllChecked
operator fun ListItem.plus(list: List<ListItem>): List<ListItem> { operator fun ListItem.plus(list: List<ListItem>): List<ListItem> {
return mutableListOf(this) + list return mutableListOf(this) + list
} }
@ -8,35 +10,17 @@ fun ListItem.findChild(childId: Int): ListItem? {
return this.children.find { child -> child.id == childId } return this.children.find { child -> child.id == childId }
} }
fun List<ListItem>.areAllChecked(except: ListItem? = null): Boolean { fun ListItem.check(checked: Boolean, checkChildren: Boolean = true) {
return this.none { !it.checked && it != except } this.checked = checked
} if (checkChildren) {
this.children.forEach { child -> child.checked = checked }
fun MutableList<ListItem>.containsId(id: Int): Boolean {
return this.any { it.id == id }
}
fun Collection<ListItem>.toReadableString(): String {
return map { "$it uncheckedPos: ${it.order} id: ${it.id}" }.joinToString("\n")
}
fun List<ListItem>.findChildrenPositions(parentPosition: Int): List<Int> {
val childrenPositions = mutableListOf<Int>()
for (position in parentPosition + 1 until this.size) {
if (this[position].isChild) {
childrenPositions.add(position)
} else {
break
}
} }
return childrenPositions
} }
fun List<ListItem>.findParentPosition(childPosition: Int): Int? { fun ListItem.shouldParentBeUnchecked(): Boolean {
for (position in childPosition - 1 downTo 0) { return children.isNotEmpty() && !children.areAllChecked() && checked
if (!this[position].isChild) { }
return position
} fun ListItem.shouldParentBeChecked(): Boolean {
} return children.isNotEmpty() && children.areAllChecked() && !checked
return null
} }

View file

@ -5,6 +5,7 @@ import android.text.Html
import androidx.core.text.toHtml import androidx.core.text.toHtml
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.data.dao.NoteIdReminder import com.philkes.notallyx.data.dao.NoteIdReminder
import com.philkes.notallyx.data.model.BaseNote.Companion.COLOR_DEFAULT
import com.philkes.notallyx.presentation.applySpans import com.philkes.notallyx.presentation.applySpans
import java.text.DateFormat import java.text.DateFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -12,6 +13,7 @@ import java.util.Calendar
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
private const val NOTE_URL_PREFIX = "note://" private const val NOTE_URL_PREFIX = "note://"
@ -41,7 +43,15 @@ fun String.getNoteTypeFromUrl(): Type {
val FileAttachment.isImage: Boolean val FileAttachment.isImage: Boolean
get() { 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) = fun BaseNote.toTxt(includeTitle: Boolean = true, includeCreationDate: Boolean = true) =
@ -67,10 +77,11 @@ fun BaseNote.toJson(): String {
val jsonObject = val jsonObject =
JSONObject() JSONObject()
.put("type", type.name) .put("type", type.name)
.put("color", color.name) .put("color", color)
.put("title", title) .put("title", title)
.put("pinned", pinned) .put("pinned", pinned)
.put("date-created", timestamp) .put("timestamp", timestamp)
.put("modifiedTimestamp", modifiedTimestamp)
.put("labels", JSONArray(labels)) .put("labels", JSONArray(labels))
when (type) { when (type) {
@ -83,10 +94,85 @@ fun BaseNote.toJson(): String {
jsonObject.put("items", Converters.itemsToJSONArray(items)) jsonObject.put("items", Converters.itemsToJSONArray(items))
} }
} }
jsonObject.put("reminders", Converters.remindersToJSONArray(reminders))
jsonObject.put("viewMode", viewMode.name)
return jsonObject.toString(2) 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 { fun BaseNote.toHtml(showDateCreated: Boolean) = buildString {
val date = DateFormat.getDateInstance(DateFormat.FULL).format(timestamp) val date = DateFormat.getDateInstance(DateFormat.FULL).format(timestamp)
val title = Html.escapeHtml(title) val title = Html.escapeHtml(title)
@ -222,3 +308,15 @@ fun List<ListItem>.toText() = buildString {
} }
fun Collection<ListItem>.deepCopy() = map { it.copy(children = mutableListOf()) } 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() value = emptyList()
} }
fun fetch(keyword: String, folder: Folder) { fun fetch(keyword: String, folder: Folder, label: String?) {
job?.cancel() job?.cancel()
liveData?.removeObserver(observer) liveData?.removeObserver(observer)
job = job =
scope.launch { scope.launch {
liveData = liveData = baseNoteDao.getBaseNotesByKeyword(keyword, folder, label)
if (keyword.isNotEmpty()) baseNoteDao.getBaseNotesByKeyword(keyword, folder) // if (keyword.isNotEmpty())
else baseNoteDao.getFrom(folder) // baseNoteDao.getBaseNotesByKeyword(keyword, folder, label)
// else baseNoteDao.getFrom(folder)
liveData?.observeForever(observer) liveData?.observeForever(observer)
} }
} }

View file

@ -2,5 +2,14 @@ package com.philkes.notallyx.data.model
enum class Type { enum class Type {
NOTE, 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.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.content.res.Resources
import android.graphics.PorterDuff
import android.graphics.Typeface import android.graphics.Typeface
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
@ -21,6 +23,7 @@ import android.text.TextWatcher
import android.text.style.CharacterStyle import android.text.style.CharacterStyle
import android.text.style.StrikethroughSpan import android.text.style.StrikethroughSpan
import android.text.style.StyleSpan import android.text.style.StyleSpan
import android.text.style.SuggestionSpan
import android.text.style.TypefaceSpan import android.text.style.TypefaceSpan
import android.text.style.URLSpan import android.text.style.URLSpan
import android.util.TypedValue import android.util.TypedValue
@ -36,6 +39,7 @@ import android.view.WindowManager
import android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE import android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.Button
import android.widget.CompoundButton import android.widget.CompoundButton
import android.widget.EditText import android.widget.EditText
import android.widget.ImageButton import android.widget.ImageButton
@ -49,22 +53,29 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.drawable.DrawableCompat import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.text.getSpans
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.marginBottom import androidx.core.view.marginBottom
import androidx.core.view.marginTop import androidx.core.view.marginTop
import androidx.core.view.setPadding import androidx.core.view.setPadding
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.core.widget.TextViewCompat
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton 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.checkbox.MaterialCheckBox
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup 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.RelativeCornerSize
import com.google.android.material.shape.RoundedCornerTreatment import com.google.android.material.shape.RoundedCornerTreatment
import com.google.android.material.shape.ShapeAppearanceModel import com.google.android.material.shape.ShapeAppearanceModel
import com.google.android.material.textfield.MaterialAutoCompleteTextView
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.data.imports.ImportProgress import com.philkes.notallyx.data.imports.ImportProgress
import com.philkes.notallyx.data.imports.ImportStage 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.Folder
import com.philkes.notallyx.data.model.SpanRepresentation 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.DialogProgressBinding
import com.philkes.notallyx.databinding.LabelBinding import com.philkes.notallyx.databinding.LabelBinding
import com.philkes.notallyx.presentation.view.main.ColorAdapter import com.philkes.notallyx.presentation.activity.main.MainActivity
import com.philkes.notallyx.presentation.view.misc.ItemListener
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
import com.philkes.notallyx.presentation.view.misc.Progress import com.philkes.notallyx.presentation.view.misc.Progress
import com.philkes.notallyx.presentation.view.misc.StylableEditTextWithHistory 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.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.DateFormat
import com.philkes.notallyx.presentation.viewmodel.preference.TextSize import com.philkes.notallyx.presentation.viewmodel.preference.TextSize
import com.philkes.notallyx.utils.changehistory.ChangeHistory import com.philkes.notallyx.utils.changehistory.ChangeHistory
@ -109,7 +122,7 @@ fun String.applySpans(representations: List<SpanRepresentation>): Editable {
-> ->
try { try {
if (bold) { if (bold) {
editable.setSpan(StyleSpan(Typeface.BOLD), start, end) editable.setSpan(createBoldSpan(), start, end)
} }
if (italic) { if (italic) {
editable.setSpan(StyleSpan(Typeface.ITALIC), start, end) editable.setSpan(StyleSpan(Typeface.ITALIC), start, end)
@ -131,6 +144,13 @@ fun String.applySpans(representations: List<SpanRepresentation>): Editable {
return 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. * Adjusts or removes spans based on the selection range.
* *
@ -221,14 +241,23 @@ fun ViewGroup.addIconButton(
title: Int, title: Int,
drawable: Int, drawable: Int,
marginStart: Int = 10, marginStart: Int = 10,
onClick: ((item: View) -> Unit)? = null, onLongClick: View.OnLongClickListener? = null,
): View { onClick: View.OnClickListener? = null,
): ImageButton {
val view = val view =
ImageButton(ContextThemeWrapper(context, R.style.AppTheme)).apply { ImageButton(ContextThemeWrapper(context, R.style.AppTheme)).apply {
setImageResource(drawable) setImageResource(drawable)
contentDescription = context.getString(title) val titleText = context.getString(title)
setBackgroundResource(R.color.Transparent) 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) setOnClickListener(onClick)
scaleType = ImageView.ScaleType.FIT_CENTER scaleType = ImageView.ScaleType.FIT_CENTER
adjustViewBounds = true adjustViewBounds = true
layoutParams = layoutParams =
@ -236,8 +265,8 @@ fun ViewGroup.addIconButton(
LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT,
) )
.apply { setMargins(marginStart.dp(context), marginTop, 0, marginBottom) } .apply { setMargins(marginStart.dp, marginTop, 0, marginBottom) }
setPadding(8.dp(context)) setPadding(8.dp)
} }
addView(view) addView(view)
return view return view
@ -255,13 +284,11 @@ fun TextView.displayFormattedTimestamp(
} else visibility = View.GONE } else visibility = View.GONE
} }
fun Int.dp(context: Context): Int = val Int.dp: Int
TypedValue.applyDimension( get() = (this * Resources.getSystem().displayMetrics.density + 0.5f).toInt()
TypedValue.COMPLEX_UNIT_DIP,
this.toFloat(), val Float.dp: Int
context.resources.displayMetrics, get() = (this * Resources.getSystem().displayMetrics.density + 0.5f).toInt()
)
.toInt()
/** /**
* Creates a TextWatcher for an EditText that is part of a list. Everytime the text is changed, a * 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, onTextChanged: ((text: CharSequence, start: Int, count: Int) -> Boolean)? = null,
) = ) =
object : TextWatcher { object : TextWatcher {
private lateinit var stateBefore: EditTextState
private var ignoreOriginalChange: Boolean = false private var ignoreOriginalChange: Boolean = false
private lateinit var textBefore: Editable
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { 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) { override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
@ -290,13 +317,14 @@ fun EditText.createListTextWatcherWithHistory(
} }
override fun afterTextChanged(s: Editable?) { override fun afterTextChanged(s: Editable?) {
val textAfter = s!!.clone()
if (textAfter.hasNotChanged(textBefore)) {
return
}
if (!ignoreOriginalChange) { if (!ignoreOriginalChange) {
listManager.changeText( listManager.changeText(
this@createListTextWatcherWithHistory,
this,
positionGetter.invoke(), positionGetter.invoke(),
EditTextState(getText()!!.clone(), selectionStart), EditTextState(textAfter, selectionStart),
before = stateBefore,
) )
} }
} }
@ -320,6 +348,9 @@ fun StylableEditTextWithHistory.createTextWatcherWithHistory(
override fun afterTextChanged(s: Editable?) { override fun afterTextChanged(s: Editable?) {
val textAfter = requireNotNull(s).clone() val textAfter = requireNotNull(s).clone()
if (textAfter.hasNotChanged(stateBefore.text)) {
return
}
updateModel.invoke(textAfter) updateModel.invoke(textAfter)
changeHistory.push( changeHistory.push(
EditTextWithHistoryChange( 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 Editable.clone(): Editable = Editable.Factory.getInstance().newEditable(this)
fun View.getString(id: Int, vararg formatArgs: String): String { fun View.getString(id: Int, vararg formatArgs: String): String {
@ -354,11 +389,16 @@ fun RadioGroup.checkedTag(): Any {
return this.findViewById<RadioButton?>(this.checkedRadioButtonId).tag return this.findViewById<RadioButton?>(this.checkedRadioButtonId).tag
} }
fun Activity.showKeyboard(view: View) { fun Context.showKeyboard(view: View) {
ContextCompat.getSystemService(this, InputMethodManager::class.java) ContextCompat.getSystemService(this, InputMethodManager::class.java)
?.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) ?.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) { fun MutableLiveData<out Progress>.setupProgressDialog(activity: Activity, titleId: Int) {
setupProgressDialog(activity, activity.layoutInflater, activity as LifecycleOwner, titleId) 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( private fun <T : Progress> MutableLiveData<T>.setupProgressDialog(
context: Context, context: Context,
layoutInflater: LayoutInflater, layoutInflater: LayoutInflater,
@ -491,6 +538,64 @@ fun Activity.checkAlarmPermission(
} else onSuccess() } 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 { private fun formatTimestamp(timestamp: Long, dateFormat: DateFormat): String {
val date = Date(timestamp) val date = Date(timestamp)
return when (dateFormat) { 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( fun MaterialAlertDialogBuilder.showAndFocus(
viewToFocus: View? = null, viewToFocus: View? = null,
selectAll: Boolean = false, selectAll: Boolean = false,
allowFullSize: Boolean = false, allowFullSize: Boolean = false,
onShowListener: DialogInterface.OnShowListener? = null,
applyToPositiveButton: ((positiveButton: Button) -> Unit)? = null,
): AlertDialog { ): AlertDialog {
if (allowFullSize) { if (allowFullSize) {
setBackgroundInsetEnd(0) setBackgroundInsetEnd(0)
@ -550,7 +631,11 @@ fun MaterialAlertDialogBuilder.showAndFocus(
WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT,
) )
} }
onShowListener?.let { setOnShowListener(it) }
show() 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 { fun Context.getColorFromAttr(@AttrRes attr: Int): Int {
val typedValue = TypedValue() val typedValue = TypedValue()
val resolved = theme.resolveAttribute(attr, typedValue, true) val resolved = theme.resolveAttribute(attr, typedValue, true)
if (resolved) { if (!resolved) {
return typedValue.data // Returns the color as an Int
} else {
throw IllegalArgumentException("Attribute not found in current theme") 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( fun View.setControlsContrastColorForAllViews(
@ -602,6 +692,15 @@ fun View.setControlsColorForAllViews(
overwriteBackground, overwriteBackground,
) // Recursive call for nested layouts ) // 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 { } else {
val controlsStateList = val controlsStateList =
ColorStateList( ColorStateList(
@ -627,15 +726,7 @@ fun View.setControlsColorForAllViews(
val highlight = controlsColor.withAlpha(0.4f) val highlight = controlsColor.withAlpha(0.4f)
setHintTextColor(highlight) setHintTextColor(highlight)
highlightColor = highlight highlightColor = highlight
val selectHandleColor = controlsColor.withAlpha(0.8f) setSelectionHandleColor(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) }
} }
} }
if (this is CompoundButton) { 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) { fun TextView.setCompoundDrawableTint(@ColorInt color: Int) {
compoundDrawablesRelative.forEach { drawable -> compoundDrawablesRelative.forEach { drawable ->
drawable?.let { DrawableCompat.setTint(DrawableCompat.wrap(it), color) } drawable?.let { DrawableCompat.setTint(DrawableCompat.wrap(it), color) }
@ -675,21 +830,12 @@ fun Drawable.withTint(@ColorInt color: Int): Drawable {
} }
@ColorInt @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) return if (backgroundColor.isLightColor()) ContextCompat.getColor(this, R.color.TextDark)
else ContextCompat.getColor(this, R.color.TextLight) else ContextCompat.getColor(this, R.color.TextLight)
} }
fun @receiver:ColorInt Int.isLightColor(): Boolean { fun @receiver:ColorInt Int.isLightColor() = ColorUtils.calculateLuminance(this) > 0.5
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 MaterialAlertDialogBuilder.setCancelButton(listener: DialogInterface.OnClickListener? = null) = fun MaterialAlertDialogBuilder.setCancelButton(listener: DialogInterface.OnClickListener? = null) =
setNegativeButton(R.string.cancel, listener) setNegativeButton(R.string.cancel, listener)
@ -726,34 +872,38 @@ fun Context.showToast(message: CharSequence) =
Toast.makeText(this, message, Toast.LENGTH_LONG).show() 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) { fun ViewGroup.addFastScroll(context: Context) {
FastScrollerBuilder(this) FastScrollerBuilder(this)
.useMd2Style() .useMd2Style()
.setTrackDrawable(ContextCompat.getDrawable(context, R.drawable.scroll_track)!!) .setTrackDrawable(ContextCompat.getDrawable(context, R.drawable.scroll_track)!!)
.setPadding(0, 0, 2.dp(context), 0) .setPadding(0, 0, 2.dp, 0)
.build() .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) { fun Window.setLightStatusAndNavBar(value: Boolean, view: View = decorView) {
val windowInsetsControllerCompat = WindowInsetsControllerCompat(this, view) val windowInsetsControllerCompat = WindowInsetsControllerCompat(this, view)
windowInsetsControllerCompat.isAppearanceLightStatusBars = value windowInsetsControllerCompat.isAppearanceLightStatusBars = value
@ -765,6 +915,8 @@ fun ChipGroup.bindLabels(
textSize: TextSize, textSize: TextSize,
paddingTop: Boolean, paddingTop: Boolean,
color: Int? = null, color: Int? = null,
onClick: ((label: String) -> Unit)? = null,
onLongClick: ((label: String) -> Unit)? = null,
) { ) {
if (labels.isEmpty()) { if (labels.isEmpty()) {
visibility = View.GONE visibility = View.GONE
@ -772,7 +924,7 @@ fun ChipGroup.bindLabels(
apply { apply {
visibility = View.VISIBLE visibility = View.VISIBLE
removeAllViews() removeAllViews()
updatePadding(top = if (paddingTop) 8.dp(context) else 0) updatePadding(top = if (paddingTop) 8.dp else 0)
} }
val inflater = LayoutInflater.from(context) val inflater = LayoutInflater.from(context)
val labelSize = textSize.displayBodySize val labelSize = textSize.displayBodySize
@ -782,6 +934,13 @@ fun ChipGroup.bindLabels(
setTextSize(TypedValue.COMPLEX_UNIT_SP, labelSize) setTextSize(TypedValue.COMPLEX_UNIT_SP, labelSize)
text = label text = label
color?.let { setControlsContrastColorForAllViews(it) } 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)) addItemDecoration(DividerItemDecoration(context, RecyclerView.VERTICAL))
setPadding(0, 0, 0, 0) 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) preferences.updateWidget(id, baseNote.id, baseNote.type)
val manager = AppWidgetManager.getInstance(this) 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() val success = Intent()
success.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id) 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.BaseNoteModel
import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.utils.security.disableBiometricLock
import com.philkes.notallyx.utils.security.showBiometricOrPinPrompt import com.philkes.notallyx.utils.security.showBiometricOrPinPrompt
abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() { abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() {
@ -34,7 +33,7 @@ abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() {
protected lateinit var binding: T protected lateinit var binding: T
protected lateinit var preferences: NotallyXPreferences protected lateinit var preferences: NotallyXPreferences
protected val baseModel: BaseNoteModel by viewModels() val baseModel: BaseNoteModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -65,7 +64,10 @@ abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() {
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
if (preferences.biometricLock.value == BiometricLock.ENABLED) { if (
preferences.biometricLock.value == BiometricLock.ENABLED &&
notallyXApplication.locked.value
) {
hide() hide()
} }
} }
@ -84,7 +86,7 @@ abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() {
.setMessage(R.string.unlock_with_biometrics_not_setup) .setMessage(R.string.unlock_with_biometrics_not_setup)
.setPositiveButton(R.string.disable) { _, _ -> .setPositiveButton(R.string.disable) { _, _ ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
disableBiometricLock(baseModel) baseModel.disableBiometricLock()
} }
show() show()
} }
@ -104,7 +106,7 @@ abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() {
BIOMETRIC_ERROR_HW_NOT_PRESENT -> { BIOMETRIC_ERROR_HW_NOT_PRESENT -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
disableBiometricLock(baseModel) baseModel.disableBiometricLock()
showToast(R.string.biometrics_disable_success) showToast(R.string.biometrics_disable_success)
} }
show() show()

View file

@ -1,33 +1,24 @@
package com.philkes.notallyx.presentation.activity.main package com.philkes.notallyx.presentation.activity.main
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.print.PdfPrintListener
import android.transition.TransitionManager import android.transition.TransitionManager
import android.view.Menu import android.view.Menu
import android.view.Menu.CATEGORY_CONTAINER import android.view.Menu.CATEGORY_CONTAINER
import android.view.Menu.CATEGORY_SYSTEM import android.view.Menu.CATEGORY_SYSTEM
import android.view.MenuItem import android.view.MenuItem
import android.view.View 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.OnBackPressedCallback
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.view.GravityCompat import androidx.core.view.GravityCompat
import androidx.core.widget.doAfterTextChanged import androidx.core.view.children
import androidx.documentfile.provider.DocumentFile
import androidx.drawerlayout.widget.DrawerLayout import androidx.drawerlayout.widget.DrawerLayout
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.navOptions import androidx.navigation.navOptions
import androidx.navigation.ui.AppBarConfiguration 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.NotallyDatabase
import com.philkes.notallyx.data.model.BaseNote import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Folder 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.databinding.ActivityMainBinding
import com.philkes.notallyx.presentation.activity.LockedActivity 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.DisplayLabelFragment.Companion.EXTRA_DISPLAYED_LABEL
import com.philkes.notallyx.presentation.activity.main.fragment.NotallyFragment 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.EditListActivity
import com.philkes.notallyx.presentation.activity.note.EditNoteActivity import com.philkes.notallyx.presentation.activity.note.EditNoteActivity
import com.philkes.notallyx.presentation.add import com.philkes.notallyx.presentation.add
import com.philkes.notallyx.presentation.applySpans
import com.philkes.notallyx.presentation.getQuantityString import com.philkes.notallyx.presentation.getQuantityString
import com.philkes.notallyx.presentation.movedToResId import com.philkes.notallyx.presentation.movedToResId
import com.philkes.notallyx.presentation.setCancelButton 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.NotNullLiveData
import com.philkes.notallyx.presentation.view.misc.tristatecheckbox.TriStateCheckBox import com.philkes.notallyx.presentation.view.misc.tristatecheckbox.TriStateCheckBox
import com.philkes.notallyx.presentation.view.misc.tristatecheckbox.setMultiChoiceTriStateItems import com.philkes.notallyx.presentation.view.misc.tristatecheckbox.setMultiChoiceTriStateItems
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel 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.presentation.viewmodel.ExportMimeType
import com.philkes.notallyx.utils.backup.exportPdfFile import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.START_VIEW_DEFAULT
import com.philkes.notallyx.utils.backup.exportPlainTextFile import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.START_VIEW_UNLABELED
import com.philkes.notallyx.utils.getExportedPath import com.philkes.notallyx.utils.backup.exportNotes
import com.philkes.notallyx.utils.getUriForFile
import com.philkes.notallyx.utils.nameWithoutExtension
import com.philkes.notallyx.utils.shareNote import com.philkes.notallyx.utils.shareNote
import com.philkes.notallyx.utils.wrapWithChooser import com.philkes.notallyx.utils.showColorSelectDialog
import java.io.File import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : LockedActivity<ActivityMainBinding>() { class MainActivity : LockedActivity<ActivityMainBinding>() {
@ -78,6 +64,7 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
private lateinit var exportFileActivityResultLauncher: ActivityResultLauncher<Intent> private lateinit var exportFileActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var exportNotesActivityResultLauncher: ActivityResultLauncher<Intent> private lateinit var exportNotesActivityResultLauncher: ActivityResultLauncher<Intent>
private var isStartViewFragment = false
private val actionModeCancelCallback = private val actionModeCancelCallback =
object : OnBackPressedCallback(true) { object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
@ -88,6 +75,7 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
var getCurrentFragmentNotes: (() -> Collection<BaseNote>?)? = null var getCurrentFragmentNotes: (() -> Collection<BaseNote>?)? = null
override fun onSupportNavigateUp(): Boolean { override fun onSupportNavigateUp(): Boolean {
baseModel.keyword = ""
return navController.navigateUp(configuration) return navController.navigateUp(configuration)
} }
@ -100,16 +88,51 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
setupMenu() setupMenu()
setupActionMode() setupActionMode()
setupNavigation() setupNavigation()
setupSearch()
setupActivityResultLaunchers() setupActivityResultLaunchers()
onBackPressedDispatcher.addCallback(this, actionModeCancelCallback)
val fragmentIdToLoad = intent.getIntExtra(EXTRA_FRAGMENT_TO_OPEN, -1) val fragmentIdToLoad = intent.getIntExtra(EXTRA_FRAGMENT_TO_OPEN, -1)
if (fragmentIdToLoad != -1) { if (fragmentIdToLoad != -1) {
val bundle = Bundle() navController.navigate(fragmentIdToLoad, intent.extras)
navController.navigate(fragmentIdToLoad, bundle) } 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() { private fun setupFAB() {
@ -144,6 +167,8 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
private fun setupMenu() { private fun setupMenu() {
binding.NavigationView.menu.apply { binding.NavigationView.menu.apply {
add(0, R.id.Notes, 0, R.string.notes).setCheckable(true).setIcon(R.drawable.home) add(0, R.id.Notes, 0, R.string.notes).setCheckable(true).setIcon(R.drawable.home)
addStaticLabelsMenuItems()
NotallyDatabase.getDatabase(application).observe(this@MainActivity) { database -> NotallyDatabase.getDatabase(application).observe(this@MainActivity) { database ->
labelsLiveData?.removeObservers(this@MainActivity) labelsLiveData?.removeObservers(this@MainActivity)
labelsLiveData = labelsLiveData =
@ -168,7 +193,7 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
.setCheckable(true) .setCheckable(true)
.setIcon(R.drawable.settings) .setIcon(R.drawable.settings)
} }
baseModel.preferences.labelsHiddenInNavigation.observe(this) { hiddenLabels -> baseModel.preferences.labelsHidden.observe(this) { hiddenLabels ->
hideLabelsInNavigation(hiddenLabels, baseModel.preferences.maxLabels.value) hideLabelsInNavigation(hiddenLabels, baseModel.preferences.maxLabels.value)
} }
baseModel.preferences.maxLabels.observe(this) { maxLabels -> baseModel.preferences.maxLabels.observe(this) { maxLabels ->
@ -176,21 +201,29 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
} }
} }
private fun Menu.setupLabelsMenuItems(labels: List<String>, maxLabelsToDisplay: Int) { private fun Menu.addStaticLabelsMenuItems() {
removeGroup(1) add(1, R.id.Unlabeled, CATEGORY_CONTAINER + 1, R.string.unlabeled)
add(1, R.id.Labels, CATEGORY_CONTAINER + 1, R.string.labels) .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) .setCheckable(true)
.setIcon(R.drawable.label_more) .setIcon(R.drawable.label_more)
}
private fun Menu.setupLabelsMenuItems(labels: List<String>, maxLabelsToDisplay: Int) {
removeGroup(1)
addStaticLabelsMenuItems()
labelsMenuItems = labelsMenuItems =
labels labels
.mapIndexed { index, label -> .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) .setCheckable(true)
.setChecked(baseModel.currentLabel == label)
.setVisible(index < maxLabelsToDisplay) .setVisible(index < maxLabelsToDisplay)
.setIcon(R.drawable.label) .setIcon(R.drawable.label)
.setOnMenuItemClickListener { .setOnMenuItemClickListener {
val bundle = Bundle().apply { putString(EXTRA_DISPLAYED_LABEL, label) } navigateToLabel(label)
navController.navigate(R.id.DisplayLabel, bundle)
false false
} }
} }
@ -209,10 +242,12 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
} else null } else null
configuration = AppBarConfiguration(binding.NavigationView.menu, binding.DrawerLayout) configuration = AppBarConfiguration(binding.NavigationView.menu, binding.DrawerLayout)
setupActionBarWithNavController(navController, configuration) setupActionBarWithNavController(navController, configuration)
hideLabelsInNavigation( hideLabelsInNavigation(baseModel.preferences.labelsHidden.value, maxLabelsToDisplay)
baseModel.preferences.labelsHiddenInNavigation.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) { private fun hideLabelsInNavigation(hiddenLabels: Set<String>, maxLabelsToDisplay: Int) {
@ -257,28 +292,34 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
val menu = binding.ActionMode.menu val menu = binding.ActionMode.menu
baseModel.folder.observe(this@MainActivity, ModelFolderObserver(menu, baseModel)) 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) { private fun moveNotes(folderTo: Folder) {
val folderFrom = baseModel.actionMode.getFirstNote().folder if (baseModel.actionMode.loading.value || baseModel.actionMode.isEmpty()) {
val ids = baseModel.moveBaseNotes(folderTo) return
Snackbar.make( }
findViewById(R.id.DrawerLayout), try {
getQuantityString(folderTo.movedToResId(), ids.size), baseModel.actionMode.loading.value = true
Snackbar.LENGTH_SHORT, val folderFrom = baseModel.actionMode.getFirstNote().folder
) val ids = baseModel.moveBaseNotes(folderTo)
.apply { setAction(R.string.undo) { baseModel.moveBaseNotes(ids, folderFrom) } } Snackbar.make(
.show() 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() { private fun share() {
val baseNote = baseModel.actionMode.getFirstNote() val baseNote = baseModel.actionMode.getFirstNote()
val body = this.shareNote(baseNote)
when (baseNote.type) {
Type.NOTE -> baseNote.body.applySpans(baseNote.spans)
Type.LIST -> baseNote.items.toText()
}
this.shareNote(baseNote.title, body)
} }
private fun deleteForever() { private fun deleteForever() {
@ -358,86 +399,12 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
} }
private fun exportSelectedNotes(mimeType: ExportMimeType) { private fun exportSelectedNotes(mimeType: ExportMimeType) {
if (baseModel.actionMode.count.value == 1) { exportNotes(
val baseNote = baseModel.actionMode.getFirstNote() mimeType,
when (mimeType) { baseModel.actionMode.selectedNotes.values,
ExportMimeType.PDF -> { exportFileActivityResultLauncher,
exportPdfFile( exportNotesActivityResultLauncher,
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)
} }
private fun setupNavigation() { private fun setupNavigation() {
@ -468,52 +435,47 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
} }
) )
navController.addOnDestinationChangedListener { _, destination, _ -> navController.addOnDestinationChangedListener { _, destination, bundle ->
fragmentIdToLoad = destination.id fragmentIdToLoad = destination.id
binding.NavigationView.setCheckedItem(destination.id) when (fragmentIdToLoad) {
if (destination.id != R.id.Search) { R.id.DisplayLabel ->
binding.EnterSearchKeyword.apply { bundle?.getString(EXTRA_DISPLAYED_LABEL)?.let {
setText("") baseModel.currentLabel = it
clearFocus() 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) { else -> {
R.id.Notes, baseModel.currentLabel = CURRENT_LABEL_EMPTY
R.id.Deleted, binding.NavigationView.setCheckedItem(destination.id)
R.id.Archived -> binding.EnterSearchKeywordLayout.visibility = VISIBLE
else -> binding.EnterSearchKeywordLayout.visibility = GONE
} }
} }
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) { private fun isStartViewFragment(id: Int, bundle: Bundle?): Boolean {
when (destination.id) { val (startViewId, startViewBundle) = getStartViewNavigation()
R.id.Notes, return startViewId == id &&
R.id.DisplayLabel -> { startViewBundle.getString(EXTRA_DISPLAYED_LABEL) ==
binding.TakeNote.show() bundle?.getString(EXTRA_DISPLAYED_LABEL)
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 navigateWithAnimation(id: Int) { private fun navigateWithAnimation(id: Int) {
@ -530,34 +492,6 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
navController.navigate(id, null, options) 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() { private fun setupActivityResultLaunchers() {
exportFileActivityResultLauncher = exportFileActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
@ -648,7 +582,38 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM
): MenuItem { ): MenuItem {
return add(R.string.change_color, R.drawable.change_color, showAsAction) { 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 { companion object {
const val EXTRA_FRAGMENT_TO_OPEN = "notallyx.intent.extra.FRAGMENT_TO_OPEN" 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.MenuInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController 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.databinding.FragmentNotesBinding
import com.philkes.notallyx.presentation.activity.main.fragment.DisplayLabelFragment.Companion.EXTRA_DISPLAYED_LABEL import com.philkes.notallyx.presentation.activity.main.fragment.DisplayLabelFragment.Companion.EXTRA_DISPLAYED_LABEL
import com.philkes.notallyx.presentation.add import com.philkes.notallyx.presentation.add
import com.philkes.notallyx.presentation.displayEditLabelDialog
import com.philkes.notallyx.presentation.initListView import com.philkes.notallyx.presentation.initListView
import com.philkes.notallyx.presentation.setCancelButton import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.showAndFocus import com.philkes.notallyx.presentation.showAndFocus
@ -43,7 +44,7 @@ class LabelsFragment : Fragment(), LabelListener {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
labelAdapter = LabelAdapter(this) labelAdapter = LabelAdapter(this)
binding?.RecyclerView?.apply { binding?.MainListView?.apply {
initListView(requireContext()) initListView(requireContext())
adapter = labelAdapter adapter = labelAdapter
binding?.ImageView?.setImageResource(R.drawable.label) binding?.ImageView?.setImageResource(R.drawable.label)
@ -76,7 +77,7 @@ class LabelsFragment : Fragment(), LabelListener {
override fun onEdit(position: Int) { override fun onEdit(position: Int) {
labelAdapter?.currentList?.get(position)?.let { (label, _) -> labelAdapter?.currentList?.get(position)?.let { (label, _) ->
displayEditLabelDialog(label) displayEditLabelDialog(label, model)
} }
} }
@ -86,13 +87,13 @@ class LabelsFragment : Fragment(), LabelListener {
override fun onToggleVisibility(position: Int) { override fun onToggleVisibility(position: Int) {
labelAdapter?.currentList?.get(position)?.let { value -> labelAdapter?.currentList?.get(position)?.let { value ->
val hiddenLabels = model.preferences.labelsHiddenInNavigation.value.toMutableSet() val hiddenLabels = model.preferences.labelsHidden.value.toMutableSet()
if (value.visibleInNavigation) { if (value.visibleInNavigation) {
hiddenLabels.add(value.label) hiddenLabels.add(value.label)
} else { } else {
hiddenLabels.remove(value.label) hiddenLabels.remove(value.label)
} }
model.savePreference(model.preferences.labelsHiddenInNavigation, hiddenLabels) model.savePreference(model.preferences.labelsHidden, hiddenLabels)
val currentList = labelAdapter!!.currentList.toMutableList() val currentList = labelAdapter!!.currentList.toMutableList()
currentList[position] = currentList[position] =
@ -103,7 +104,7 @@ class LabelsFragment : Fragment(), LabelListener {
private fun setupObserver() { private fun setupObserver() {
model.labels.observe(viewLifecycleOwner) { labels -> 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)) } val labelsData = labels.map { label -> LabelData(label, !hiddenLabels.contains(label)) }
labelAdapter?.submitList(labelsData) labelAdapter?.submitList(labelsData)
binding?.ImageView?.isVisible = labels.isEmpty() 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) { private fun confirmDeletion(value: String) {
@ -142,32 +148,4 @@ class LabelsFragment : Fragment(), LabelListener {
.setCancelButton() .setCancelButton()
.show() .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.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.navigation.findNavController
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager 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.data.model.Type
import com.philkes.notallyx.databinding.FragmentNotesBinding import com.philkes.notallyx.databinding.FragmentNotesBinding
import com.philkes.notallyx.presentation.activity.main.MainActivity 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_FROM
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_FOLDER_TO import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_FOLDER_TO
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_NOTE_ID import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_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.EditListActivity
import com.philkes.notallyx.presentation.activity.note.EditNoteActivity import com.philkes.notallyx.presentation.activity.note.EditNoteActivity
import com.philkes.notallyx.presentation.getQuantityString import com.philkes.notallyx.presentation.getQuantityString
import com.philkes.notallyx.presentation.hideKeyboard
import com.philkes.notallyx.presentation.movedToResId 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.BaseNoteAdapter
import com.philkes.notallyx.presentation.view.main.BaseNoteVHPreferences import com.philkes.notallyx.presentation.view.main.BaseNoteVHPreferences
import com.philkes.notallyx.presentation.view.misc.ItemListener import com.philkes.notallyx.presentation.view.misc.ItemListener
@ -55,7 +61,7 @@ abstract class NotallyFragment : Fragment(), ItemListener {
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
val layoutManager = binding?.RecyclerView?.layoutManager as? LinearLayoutManager val layoutManager = binding?.MainListView?.layoutManager as? LinearLayoutManager
if (layoutManager != null) { if (layoutManager != null) {
val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition() val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()
if (firstVisiblePosition != RecyclerView.NO_POSITION) { if (firstVisiblePosition != RecyclerView.NO_POSITION) {
@ -73,6 +79,7 @@ abstract class NotallyFragment : Fragment(), ItemListener {
setupAdapter() setupAdapter()
setupRecyclerView() setupRecyclerView()
setupObserver() setupObserver()
setupSearch()
setupActivityResultLaunchers() setupActivityResultLaunchers()
@ -80,8 +87,8 @@ abstract class NotallyFragment : Fragment(), ItemListener {
val scrollPosition = bundle.getInt(EXTRA_SCROLL_POS, -1) val scrollPosition = bundle.getInt(EXTRA_SCROLL_POS, -1)
val scrollOffset = bundle.getInt(EXTRA_SCROLL_OFFSET, 0) val scrollOffset = bundle.getInt(EXTRA_SCROLL_OFFSET, 0)
if (scrollPosition > -1) { if (scrollPosition > -1) {
binding?.RecyclerView?.post { binding?.MainListView?.post {
val layoutManager = binding?.RecyclerView?.layoutManager as? LinearLayoutManager val layoutManager = binding?.MainListView?.layoutManager as? LinearLayoutManager
layoutManager?.scrollToPositionWithOffset(scrollPosition, scrollOffset) 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) { private fun handleNoteSelection(id: Long, position: Int, baseNote: BaseNote) {
if (model.actionMode.selectedNotes.contains(id)) { if (model.actionMode.selectedNotes.contains(id)) {
model.actionMode.remove(id) model.actionMode.remove(id)
@ -192,7 +236,8 @@ abstract class NotallyFragment : Fragment(), ItemListener {
maxItems.value, maxItems.value,
maxLines.value, maxLines.value,
maxTitle.value, maxTitle.value,
labelsHiddenInOverview.value, labelTagsHiddenInOverview.value,
imagesHiddenInOverview.value,
), ),
model.imageRoot, model.imageRoot,
this@NotallyFragment, this@NotallyFragment,
@ -203,12 +248,12 @@ abstract class NotallyFragment : Fragment(), ItemListener {
object : RecyclerView.AdapterDataObserver() { object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (itemCount > 0) { if (itemCount > 0) {
binding?.RecyclerView?.scrollToPosition(positionStart) binding?.MainListView?.scrollToPosition(positionStart)
} }
} }
} }
) )
binding?.RecyclerView?.apply { binding?.MainListView?.apply {
adapter = notesAdapter adapter = notesAdapter
setHasFixedSize(false) setHasFixedSize(false)
} }
@ -242,7 +287,7 @@ abstract class NotallyFragment : Fragment(), ItemListener {
} }
private fun setupRecyclerView() { private fun setupRecyclerView() {
binding?.RecyclerView?.layoutManager = binding?.MainListView?.layoutManager =
if (model.preferences.notesView.value == NotesView.GRID) { if (model.preferences.notesView.value == NotesView.GRID) {
StaggeredGridLayoutManager(2, RecyclerView.VERTICAL) StaggeredGridLayoutManager(2, RecyclerView.VERTICAL)
} else LinearLayoutManager(requireContext()) } else LinearLayoutManager(requireContext())

View file

@ -36,7 +36,7 @@ class RemindersFragment : Fragment(), NoteReminderListener {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
reminderAdapter = NoteReminderAdapter(this) reminderAdapter = NoteReminderAdapter(this)
binding?.RecyclerView?.apply { binding?.MainListView?.apply {
initListView(requireContext()) initListView(requireContext())
adapter = reminderAdapter adapter = reminderAdapter
binding?.ImageView?.setImageResource(R.drawable.notifications) binding?.ImageView?.setImageResource(R.drawable.notifications)

View file

@ -4,38 +4,49 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.core.os.BundleCompat import androidx.core.os.BundleCompat
import androidx.core.view.isVisible
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Folder import com.philkes.notallyx.data.model.Folder
class SearchFragment : NotallyFragment() { class SearchFragment : NotallyFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// TODO: autofocus and show keyboard
val initialFolder = val initialFolder =
arguments?.let { arguments?.let {
BundleCompat.getSerializable(it, EXTRA_INITIAL_FOLDER, Folder::class.java) BundleCompat.getSerializable(it, EXTRA_INITIAL_FOLDER, Folder::class.java)
} }
binding?.ChipGroup?.visibility = View.VISIBLE binding?.ChipGroup?.visibility = View.VISIBLE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 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) super.onViewCreated(view, savedInstanceState)
val checked = val initialLabel = arguments?.getString(EXTRA_INITIAL_LABEL)
when (initialFolder ?: model.folder.value) { model.currentLabel = initialLabel
Folder.NOTES -> R.id.Notes if (initialLabel?.isEmpty() == true) {
Folder.DELETED -> R.id.Deleted val checked =
Folder.ARCHIVED -> R.id.Archived when (initialFolder ?: model.folder.value) {
} Folder.NOTES -> R.id.Notes
Folder.DELETED -> R.id.Deleted
binding?.ChipGroup?.apply { Folder.ARCHIVED -> R.id.Archived
setOnCheckedStateChangeListener { _, checkedId ->
when (checkedId.first()) {
R.id.Notes -> model.folder.value = Folder.NOTES
R.id.Deleted -> model.folder.value = Folder.DELETED
R.id.Archived -> model.folder.value = Folder.ARCHIVED
} }
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 { companion object {
const val EXTRA_INITIAL_FOLDER = "notallyx.intent.extra.INITIAL_FOLDER" 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.text.method.PasswordTransformationMethod
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import androidx.core.view.isVisible
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.google.android.material.color.DynamicColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider
import com.google.android.material.textfield.TextInputLayout.END_ICON_PASSWORD_TOGGLE import com.google.android.material.textfield.TextInputLayout.END_ICON_PASSWORD_TOGGLE
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.databinding.ChoiceItemBinding import com.philkes.notallyx.databinding.ChoiceItemBinding
import com.philkes.notallyx.databinding.DialogDateFormatBinding
import com.philkes.notallyx.databinding.DialogNotesSortBinding import com.philkes.notallyx.databinding.DialogNotesSortBinding
import com.philkes.notallyx.databinding.DialogPreferenceBooleanBinding 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.DialogTextInputBinding
import com.philkes.notallyx.databinding.PreferenceBinding import com.philkes.notallyx.databinding.PreferenceBinding
import com.philkes.notallyx.databinding.PreferenceSeekbarBinding import com.philkes.notallyx.databinding.PreferenceSeekbarBinding
import com.philkes.notallyx.presentation.checkedTag import com.philkes.notallyx.presentation.checkedTag
import com.philkes.notallyx.presentation.select
import com.philkes.notallyx.presentation.setCancelButton import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.showAndFocus import com.philkes.notallyx.presentation.showAndFocus
import com.philkes.notallyx.presentation.showToast 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.EnumPreference
import com.philkes.notallyx.presentation.viewmodel.preference.IntPreference 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.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.NotesSort
import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortBy import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortBy
import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortPreference import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortPreference
import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection
import com.philkes.notallyx.presentation.viewmodel.preference.StringPreference import com.philkes.notallyx.presentation.viewmodel.preference.StringPreference
import com.philkes.notallyx.presentation.viewmodel.preference.TextProvider 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.canAuthenticateWithBiometrics
import com.philkes.notallyx.utils.toReadablePath import com.philkes.notallyx.utils.toReadablePath
@ -194,28 +201,35 @@ fun PreferenceBinding.setup(
Value.text = dateFormatValue.getText(context) Value.text = dateFormatValue.getText(context)
root.setOnClickListener { 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 -> DateFormat.entries.forEachIndexed { idx, dateFormat ->
ChoiceItemBinding.inflate(layoutInflater).root.apply { ChoiceItemBinding.inflate(layoutInflater).root.apply {
id = idx id = idx
text = dateFormat.getText(context) text = dateFormat.getText(context)
tag = dateFormat tag = dateFormat
layout.DateFormatRadioGroup.addView(this) layout.EnumRadioGroup.addView(this)
if (dateFormat == dateFormatValue) { 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) MaterialAlertDialogBuilder(context)
.setTitle(dateFormatPreference.titleResId) .setTitle(dateFormatPreference.titleResId)
.setView(layout.root) .setView(layout.root)
.setPositiveButton(R.string.save) { dialog, _ -> .setPositiveButton(R.string.save) { dialog, _ ->
dialog.cancel() dialog.cancel()
val dateFormat = layout.DateFormatRadioGroup.checkedTag() as DateFormat val dateFormat = layout.EnumRadioGroup.checkedTag() as DateFormat
val applyToNoteView = layout.ApplyToNoteView.isChecked val applyToNoteView = layout.Toggle.isChecked
onSave(dateFormat, applyToNoteView) onSave(dateFormat, applyToNoteView)
} }
.setCancelButton() .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( fun PreferenceBinding.setup(
preference: BooleanPreference, preference: BooleanPreference,
value: Boolean, value: Boolean,
@ -429,3 +489,71 @@ fun PreferenceSeekbarBinding.setup(
onChange(newValue) onChange(newValue)
} }
} }
fun PreferenceSeekbarBinding.setupAutoSaveIdleTime(
preference: IntPreference,
context: Context,
value: Int = preference.value,
onChange: (newValue: Int) -> Unit,
) {
Slider.apply {
setLabelFormatter { sliderValue ->
if (sliderValue == -1f) {
context.getString(R.string.disabled)
} else "${sliderValue.toInt()}s"
}
addOnChangeListener { _, value, _ ->
if (value == -1f) {
setAlpha(0.6f) // Reduce opacity to make it look disabled
} else {
setAlpha(1f) // Restore normal appearance
}
}
}
setup(preference, context, value, onChange)
}
fun PreferenceBinding.setupStartView(
preference: StringPreference,
value: String,
labels: List<String>?,
context: Context,
layoutInflater: LayoutInflater,
onSave: (value: String) -> Unit,
) {
Title.setText(preference.titleResId!!)
val notesText = "${context.getText(R.string.notes)} (${context.getText(R.string.text_default)})"
val unlabeledText = context.getText(R.string.unlabeled).toString()
val textValue =
when (value) {
START_VIEW_DEFAULT -> notesText
START_VIEW_UNLABELED -> unlabeledText
else -> value
}
Value.text = textValue
root.setOnClickListener {
val layout = DialogSelectionBoxBinding.inflate(layoutInflater, null, false)
layout.Message.setText(R.string.start_view_hint)
val values =
mutableListOf(notesText to START_VIEW_DEFAULT, unlabeledText to START_VIEW_UNLABELED)
.apply { labels?.forEach { add(it to it) } }
var selected = -1
layout.SelectionBox.apply {
setSimpleItems(values.map { it.first }.toTypedArray())
select(textValue)
setOnItemClickListener { _, _, position, _ -> selected = position }
}
MaterialAlertDialogBuilder(context)
.setTitle(preference.titleResId)
.setView(layout.root)
.setPositiveButton(R.string.save) { dialog, _ ->
dialog.cancel()
val newValue = values[selected].second
onSave(newValue)
}
.setCancelButton()
.showAndFocus(allowFullSize = true)
}
}

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.data.model.toText
import com.philkes.notallyx.databinding.DialogTextInputBinding import com.philkes.notallyx.databinding.DialogTextInputBinding
import com.philkes.notallyx.databinding.FragmentSettingsBinding 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.setCancelButton
import com.philkes.notallyx.presentation.setEnabledSecureFlag
import com.philkes.notallyx.presentation.setupImportProgressDialog import com.philkes.notallyx.presentation.setupImportProgressDialog
import com.philkes.notallyx.presentation.setupProgressDialog import com.philkes.notallyx.presentation.setupProgressDialog
import com.philkes.notallyx.presentation.showAndFocus 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.showToast
import com.philkes.notallyx.presentation.view.misc.TextWithIconAdapter import com.philkes.notallyx.presentation.view.misc.TextWithIconAdapter
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel 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.Constants.PASSWORD_EMPTY
import com.philkes.notallyx.presentation.viewmodel.preference.LongPreference import com.philkes.notallyx.presentation.viewmodel.preference.LongPreference
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences 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.EMPTY_PATH
import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackup 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.PeriodicBackup.Companion.BACKUP_PERIOD_DAYS_MIN
import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackupsPreference import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackupsPreference
import com.philkes.notallyx.utils.MIME_TYPE_JSON import com.philkes.notallyx.utils.MIME_TYPE_JSON
import com.philkes.notallyx.utils.MIME_TYPE_ZIP import com.philkes.notallyx.utils.MIME_TYPE_ZIP
import com.philkes.notallyx.utils.backup.exportPreferences import com.philkes.notallyx.utils.backup.exportPreferences
import com.philkes.notallyx.utils.catchNoBrowserInstalled import com.philkes.notallyx.utils.catchNoBrowserInstalled
import com.philkes.notallyx.utils.getExtraBooleanFromBundleOrIntent
import com.philkes.notallyx.utils.getLastExceptionLog import com.philkes.notallyx.utils.getLastExceptionLog
import com.philkes.notallyx.utils.getLogFile import com.philkes.notallyx.utils.getLogFile
import com.philkes.notallyx.utils.getUriForFile import com.philkes.notallyx.utils.getUriForFile
import com.philkes.notallyx.utils.reportBug 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.security.showBiometricOrPinPrompt
import com.philkes.notallyx.utils.wrapWithChooser import com.philkes.notallyx.utils.wrapWithChooser
import java.util.Date import java.util.Date
@ -97,6 +98,27 @@ class SettingsFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
setupActivityResultLaunchers() 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() { private fun setupActivityResultLaunchers() {
@ -171,11 +193,12 @@ class SettingsFragment : Fragment() {
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) { if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri -> result.data?.data?.let { uri ->
if ( model.importPreferences(
model.importPreferences(requireContext(), uri, ::askForUriPermissions) requireContext(),
uri,
::askForUriPermissions,
{ showToast(R.string.import_settings_success) },
) { ) {
showToast(R.string.import_settings_success)
} else {
showToast(R.string.import_settings_failure) showToast(R.string.import_settings_failure)
} }
} }
@ -224,9 +247,27 @@ class SettingsFragment : Fragment() {
} }
} }
theme.observe(viewLifecycleOwner) { value -> theme.merge(useDynamicColors).observe(viewLifecycleOwner) {
binding.Theme.setup(theme, value, requireContext()) { newValue -> (themeValue, useDynamicColorsValue) ->
model.savePreference(theme, newValue) 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, model,
) )
} }
// TODO: Hide for now until checked auto-sort is working reliably
// listItemSorting.observe(viewLifecycleOwner) { value -> listItemSorting.observe(viewLifecycleOwner) { value ->
// binding.CheckedListItemSorting.setup(ListItemSorting, value) binding.CheckedListItemSorting.setup(listItemSorting, value, requireContext()) {
// } newValue ->
model.savePreference(listItemSorting, newValue)
}
}
binding.MaxLabels.setup(maxLabels, requireContext()) { newValue -> binding.MaxLabels.setup(maxLabels, requireContext()) { newValue ->
model.savePreference(maxLabels, 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) { private fun NotallyXPreferences.setupContentDensity(binding: FragmentSettingsBinding) {
@ -281,15 +337,29 @@ class SettingsFragment : Fragment() {
MaxLines.setup(maxLines, requireContext()) { newValue -> MaxLines.setup(maxLines, requireContext()) { newValue ->
model.savePreference(maxLines, 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( binding.LabelsHiddenInOverview.setup(
labelsHiddenInOverview, labelTagsHiddenInOverview,
value, value,
requireContext(), requireContext(),
layoutInflater, layoutInflater,
R.string.labels_hidden_in_overview, R.string.labels_hidden_in_overview,
) { enabled -> ) { 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) { when (selectedImportSource.mimeType) {
FOLDER_OR_FILE_MIMETYPE -> FOLDER_OR_FILE_MIMETYPE ->
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.plain_text_files) .setTitle(selectedImportSource.displayNameResId)
.setItems( .setItems(
arrayOf( arrayOf(
getString(R.string.folder), getString(R.string.folder),
@ -464,9 +534,17 @@ class SettingsFragment : Fragment() {
enabled = backupFolder != EMPTY_PATH, enabled = backupFolder != EMPTY_PATH,
) { enabled -> ) { enabled ->
if (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( model.savePreference(
preference, preference,
preference.value.copy(periodInDays = BACKUP_PERIOD_DAYS_MIN), preference.value.copy(periodInDays = periodInDays, maxBackups = maxBackups),
) )
} else { } else {
model.savePreference(preference, preference.value.copy(periodInDays = 0)) model.savePreference(preference, preference.value.copy(periodInDays = 0))
@ -526,6 +604,14 @@ class SettingsFragment : Fragment() {
model.savePreference(backupPassword, newValue) model.savePreference(backupPassword, newValue)
} }
} }
secureFlag.observe(viewLifecycleOwner) { value ->
binding.SecureFlag.setup(secureFlag, value, requireContext(), layoutInflater) { newValue
->
model.savePreference(secureFlag, newValue)
activity?.setEnabledSecureFlag(newValue)
}
}
} }
private fun NotallyXPreferences.setupSettings(binding: FragmentSettingsBinding) { private fun NotallyXPreferences.setupSettings(binding: FragmentSettingsBinding) {
@ -558,8 +644,7 @@ class SettingsFragment : Fragment() {
} }
ResetSettings.setOnClickListener { ResetSettings.setOnClickListener {
showDialog(R.string.reset_settings_message, R.string.reset_settings) { _, _ -> showDialog(R.string.reset_settings_message, R.string.reset_settings) { _, _ ->
model.resetPreferences() model.resetPreferences { _ -> showToast(R.string.reset_settings_success) }
showToast(R.string.reset_settings_success)
} }
} }
dataInPublicFolder.observe(viewLifecycleOwner) { value -> dataInPublicFolder.observe(viewLifecycleOwner) { value ->
@ -577,6 +662,11 @@ class SettingsFragment : Fragment() {
} }
} }
} }
AutoSaveAfterIdle.setupAutoSaveIdleTime(autoSaveAfterIdleTime, requireContext()) {
newValue ->
model.savePreference(autoSaveAfterIdleTime, newValue)
}
ClearData.setOnClickListener { ClearData.setOnClickListener {
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.clear_data_message) .setMessage(R.string.clear_data_message)
@ -591,34 +681,14 @@ class SettingsFragment : Fragment() {
private fun setupAbout(binding: FragmentSettingsBinding) { private fun setupAbout(binding: FragmentSettingsBinding) {
binding.apply { binding.apply {
SendFeedback.setOnClickListener { 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 = val options =
arrayOf( arrayOf(
getString(R.string.report_bug), getString(R.string.report_bug),
getString(R.string.make_feature_request), getString(R.string.make_feature_request),
getString(R.string.send_feedback),
) )
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.create_github_issue) .setTitle(R.string.send_feedback)
.setItems(options) { _, which -> .setItems(options) { _, which ->
when (which) { when (which) {
0 -> { 0 -> {
@ -627,7 +697,7 @@ class SettingsFragment : Fragment() {
reportBug(logs) reportBug(logs)
} }
else -> 1 ->
requireContext().catchNoBrowserInstalled { requireContext().catchNoBrowserInstalled {
startActivity( startActivity(
Intent( Intent(
@ -639,11 +709,40 @@ class SettingsFragment : Fragment() {
.wrapWithChooser(requireContext()) .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() .setCancelButton()
.show() .show()
} }
Rate.setOnClickListener {
openLink("https://play.google.com/store/apps/details?id=com.philkes.notallyx")
}
SourceCode.setOnClickListener { openLink("https://github.com/PhilKes/NotallyX") } SourceCode.setOnClickListener { openLink("https://github.com/PhilKes/NotallyX") }
Libraries.setOnClickListener { Libraries.setOnClickListener {
val libraries = val libraries =
@ -657,6 +756,7 @@ class SettingsFragment : Fragment() {
"SQLCipher", "SQLCipher",
"Zip4J", "Zip4J",
"AndroidFastScroll", "AndroidFastScroll",
"ColorPickerView",
) )
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.libraries) .setTitle(R.string.libraries)
@ -680,11 +780,13 @@ class SettingsFragment : Fragment() {
6 -> openLink("https://github.com/sqlcipher/sqlcipher") 6 -> openLink("https://github.com/sqlcipher/sqlcipher")
7 -> openLink("https://github.com/srikanth-lingala/zip4j") 7 -> openLink("https://github.com/srikanth-lingala/zip4j")
8 -> openLink("https://github.com/zhanghai/AndroidFastScroll") 8 -> openLink("https://github.com/zhanghai/AndroidFastScroll")
9 -> openLink("https://github.com/skydoves/ColorPickerView")
} }
} }
.setCancelButton() .setCancelButton()
.show() .show()
} }
Donate.setOnClickListener { openLink("https://ko-fi.com/philkes") }
try { try {
val pInfo = val pInfo =
@ -710,14 +812,7 @@ class SettingsFragment : Fragment() {
R.string.enable_lock_description, R.string.enable_lock_description,
onSuccess = { cipher -> onSuccess = { cipher ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
model.savePreference(model.preferences.iv, cipher.iv) model.enableBiometricLock(cipher)
val passphrase = model.preferences.databaseEncryptionKey.init(cipher)
encryptDatabase(requireContext(), passphrase)
model.savePreference(
model.preferences.fallbackDatabaseEncryptionKey,
passphrase,
)
model.savePreference(model.preferences.biometricLock, BiometricLock.ENABLED)
} }
val app = (activity?.application as NotallyXApplication) val app = (activity?.application as NotallyXApplication)
app.locked.value = false app.locked.value = false
@ -737,7 +832,7 @@ class SettingsFragment : Fragment() {
model.preferences.iv.value!!, model.preferences.iv.value!!,
onSuccess = { cipher -> onSuccess = { cipher ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
requireContext().disableBiometricLock(model, cipher) model.disableBiometricLock(cipher)
} }
showToast(R.string.biometrics_disable_success) 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.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.Settings import android.provider.Settings
import android.text.Editable import android.text.Editable
import android.util.Log
import android.util.TypedValue import android.util.TypedValue
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.View.GONE import android.view.View.GONE
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams import android.view.ViewGroup.LayoutParams
import android.view.ViewGroup.VISIBLE import android.view.ViewGroup.VISIBLE
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.ImageButton
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
@ -32,13 +37,18 @@ import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.data.NotallyDatabase
import com.philkes.notallyx.data.model.Audio import com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.data.model.FileAttachment import com.philkes.notallyx.data.model.FileAttachment
import com.philkes.notallyx.data.model.Folder 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.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.databinding.ActivityEditBinding
import com.philkes.notallyx.presentation.activity.LockedActivity 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.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.SelectLabelsActivity.Companion.EXTRA_SELECTED_LABELS
import com.philkes.notallyx.presentation.activity.note.reminders.RemindersActivity 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.addFastScroll
import com.philkes.notallyx.presentation.addIconButton import com.philkes.notallyx.presentation.addIconButton
import com.philkes.notallyx.presentation.bindLabels import com.philkes.notallyx.presentation.bindLabels
import com.philkes.notallyx.presentation.displayEditLabelDialog
import com.philkes.notallyx.presentation.displayFormattedTimestamp import com.philkes.notallyx.presentation.displayFormattedTimestamp
import com.philkes.notallyx.presentation.extractColor import com.philkes.notallyx.presentation.extractColor
import com.philkes.notallyx.presentation.getQuantityString import com.philkes.notallyx.presentation.getQuantityString
import com.philkes.notallyx.presentation.hideKeyboard
import com.philkes.notallyx.presentation.isLightColor import com.philkes.notallyx.presentation.isLightColor
import com.philkes.notallyx.presentation.setCancelButton import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews
import com.philkes.notallyx.presentation.setLightStatusAndNavBar import com.philkes.notallyx.presentation.setLightStatusAndNavBar
import com.philkes.notallyx.presentation.setupProgressDialog import com.philkes.notallyx.presentation.setupProgressDialog
import com.philkes.notallyx.presentation.showColorSelectDialog
import com.philkes.notallyx.presentation.showKeyboard import com.philkes.notallyx.presentation.showKeyboard
import com.philkes.notallyx.presentation.showToast import com.philkes.notallyx.presentation.showToast
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData 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.audio.AudioAdapter
import com.philkes.notallyx.presentation.view.note.preview.PreviewFileAdapter import com.philkes.notallyx.presentation.view.note.preview.PreviewFileAdapter
import com.philkes.notallyx.presentation.view.note.preview.PreviewImageAdapter 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.NotallyModel
import com.philkes.notallyx.presentation.viewmodel.preference.DateFormat 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.viewmodel.preference.NotesSortBy
import com.philkes.notallyx.presentation.widget.WidgetProvider import com.philkes.notallyx.presentation.widget.WidgetProvider
import com.philkes.notallyx.utils.FileError 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.changehistory.ChangeHistory
import com.philkes.notallyx.utils.getMimeType
import com.philkes.notallyx.utils.getUriForFile import com.philkes.notallyx.utils.getUriForFile
import com.philkes.notallyx.utils.log import com.philkes.notallyx.utils.log
import com.philkes.notallyx.utils.mergeSkipFirst import com.philkes.notallyx.utils.mergeSkipFirst
import com.philkes.notallyx.utils.observeSkipFirst import com.philkes.notallyx.utils.observeSkipFirst
import com.philkes.notallyx.utils.shareNote import com.philkes.notallyx.utils.shareNote
import com.philkes.notallyx.utils.showColorSelectDialog
import com.philkes.notallyx.utils.wrapWithChooser import com.philkes.notallyx.utils.wrapWithChooser
import java.io.File import java.io.File
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
abstract class EditActivity(private val type: Type) : abstract class EditActivity(private val type: Type) :
LockedActivity<ActivityEditBinding>(), AddActions, MoreActions { LockedActivity<ActivityEditBinding>(), AddActions, MoreActions {
@ -93,6 +111,8 @@ abstract class EditActivity(private val type: Type) :
private lateinit var selectLabelsActivityResultLauncher: ActivityResultLauncher<Intent> private lateinit var selectLabelsActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var playAudioActivityResultLauncher: ActivityResultLauncher<Intent> private lateinit var playAudioActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var attachFilesActivityResultLauncher: 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 private lateinit var pinMenuItem: MenuItem
protected var search = Search() protected var search = Search()
@ -106,17 +126,38 @@ abstract class EditActivity(private val type: Type) :
protected var colorInt: Int = -1 protected var colorInt: Int = -1
protected var inputMethodManager: InputMethodManager? = null 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() { override fun finish() {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
if (notallyModel.isEmpty()) { if (notallyModel.isEmpty()) {
notallyModel.deleteBaseNote(checkAutoSave = false) notallyModel.deleteBaseNote(checkAutoSave = false)
} else if (notallyModel.isModified()) { } else if (notallyModel.isModified()) {
saveNote() saveNote()
} else {
notallyModel.checkBackupOnSave()
} }
super.finish() super.finish()
} }
} }
protected open fun updateModel() {
notallyModel.modifiedTimestamp = System.currentTimeMillis()
}
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
outState.putLong("id", notallyModel.id) outState.putLong("id", notallyModel.id)
@ -125,9 +166,9 @@ abstract class EditActivity(private val type: Type) :
} }
} }
open suspend fun saveNote() { open suspend fun saveNote(checkAutoSave: Boolean = true) {
notallyModel.modifiedTimestamp = System.currentTimeMillis() updateModel()
notallyModel.saveNote() notallyModel.saveNote(checkAutoSave)
WidgetProvider.sendBroadcast(application, longArrayOf(notallyModel.id)) WidgetProvider.sendBroadcast(application, longArrayOf(notallyModel.id))
} }
@ -144,14 +185,18 @@ abstract class EditActivity(private val type: Type) :
val persistedId = savedInstanceState?.getLong("id") val persistedId = savedInstanceState?.getLong("id")
val selectedId = intent.getLongExtra(EXTRA_SELECTED_BASE_NOTE, 0L) val selectedId = intent.getLongExtra(EXTRA_SELECTED_BASE_NOTE, 0L)
val id = persistedId ?: selectedId val id = persistedId ?: selectedId
if (persistedId == null) { if (persistedId == null || notallyModel.originalNote == null) {
notallyModel.setState(id) notallyModel.setState(id)
} }
if (notallyModel.isNewNote && intent.action == Intent.ACTION_SEND) { if (notallyModel.isNewNote) {
handleSharedNote() when (intent.action) {
} else if (notallyModel.isNewNote) { Intent.ACTION_SEND,
intent.getStringExtra(EXTRA_DISPLAYED_LABEL)?.let { Intent.ACTION_SEND_MULTIPLE -> handleSharedNote()
notallyModel.setLabels(listOf(it)) 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() 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() { private fun setupActivityResultLaunchers() {
@ -210,10 +292,7 @@ abstract class EditActivity(private val type: Type) :
selectLabelsActivityResultLauncher = selectLabelsActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) { if (result.resultCode == RESULT_OK) {
val list = val list = result.data?.getStringArrayListExtra(EXTRA_SELECTED_LABELS)
result.data?.getStringArrayListExtra(
SelectLabelsActivity.EXTRA_SELECTED_LABELS
)
if (list != null && list != notallyModel.labels) { if (list != null && list != notallyModel.labels) {
notallyModel.setLabels(list) notallyModel.setLabels(list)
binding.LabelGroup.bindLabels( binding.LabelGroup.bindLabels(
@ -222,6 +301,7 @@ abstract class EditActivity(private val type: Type) :
paddingTop = true, paddingTop = true,
colorInt, 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( override fun onRequestPermissionsResult(
@ -281,6 +376,7 @@ abstract class EditActivity(private val type: Type) :
ChangeHistory().apply { ChangeHistory().apply {
canUndo.observe(this@EditActivity) { canUndo -> undo?.isEnabled = canUndo } canUndo.observe(this@EditActivity) { canUndo -> undo?.isEnabled = canUndo }
canRedo.observe(this@EditActivity) { canRedo -> redo?.isEnabled = canRedo } 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 = pinMenuItem =
add(R.string.pin, R.drawable.pin, MenuItem.SHOW_AS_ACTION_ALWAYS) { pin() } add(R.string.pin, R.drawable.pin, MenuItem.SHOW_AS_ACTION_ALWAYS) { pin() }
bindPinned() 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) -> search.results.mergeSkipFirst(search.resultPos).observe(this) { (amount, pos) ->
@ -429,7 +505,19 @@ abstract class EditActivity(private val type: Type) :
binding.BottomAppBarCenter.apply { binding.BottomAppBarCenter.apply {
removeAllViews() removeAllViews()
undo = 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 { try {
changeHistory.undo() changeHistory.undo()
} catch (e: ChangeHistory.ChangeHistoryException) { } catch (e: ChangeHistory.ChangeHistoryException) {
@ -439,7 +527,19 @@ abstract class EditActivity(private val type: Type) :
.apply { isEnabled = changeHistory.canUndo.value } .apply { isEnabled = changeHistory.canUndo.value }
redo = 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 { try {
changeHistory.redo() changeHistory.redo()
} catch (e: ChangeHistory.ChangeHistoryException) { } catch (e: ChangeHistory.ChangeHistoryException) {
@ -450,41 +550,132 @@ abstract class EditActivity(private val type: Type) :
} }
binding.BottomAppBarRight.apply { binding.BottomAppBarRight.apply {
removeAllViews() 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) .show(supportFragmentManager, MoreNoteBottomSheet.TAG)
} }
} }
setBottomAppBarColor(colorInt) 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() = protected fun createFolderActions() =
when (notallyModel.folder) { when (notallyModel.folder) {
Folder.NOTES -> Folder.NOTES ->
listOf( listOf(
Action(R.string.archive, R.drawable.archive, callback = ::archive), Action(R.string.archive, R.drawable.archive) { _ ->
Action(R.string.delete, R.drawable.delete, callback = ::delete), archive()
true
},
Action(R.string.delete, R.drawable.delete) { _ ->
delete()
true
},
) )
Folder.DELETED -> Folder.DELETED ->
listOf( listOf(
Action(R.string.delete_forever, R.drawable.delete, callback = ::deleteForever), Action(R.string.delete_forever, R.drawable.delete) { _ ->
Action(R.string.restore, R.drawable.restore, callback = ::restore), deleteForever()
true
},
Action(R.string.restore, R.drawable.restore) { _ ->
restore()
true
},
) )
Folder.ARCHIVED -> Folder.ARCHIVED ->
listOf( listOf(
Action(R.string.delete, R.drawable.delete, callback = ::delete), Action(R.string.delete, R.drawable.delete) { _ ->
Action(R.string.unarchive, R.drawable.unarchive, callback = ::restore), 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() abstract fun configureUI()
open fun setupListeners() { open fun setupListeners() {
binding.EnterTitle.initHistory(changeHistory) { text -> binding.EnterTitle.initHistory(changeHistory) { text ->
notallyModel.title = text.trim().toString() 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?) { open fun setStateFromModel(savedInstanceState: Bundle?) {
@ -501,26 +692,90 @@ abstract class EditActivity(private val type: Type) :
} else DateFormat.ABSOLUTE } else DateFormat.ABSOLUTE
binding.Date.displayFormattedTimestamp(date, dateFormat, datePrefixResId) binding.Date.displayFormattedTimestamp(date, dateFormat, datePrefixResId)
binding.EnterTitle.setText(notallyModel.title) binding.EnterTitle.setText(notallyModel.title)
bindLabels()
setColor()
}
private fun bindLabels() {
binding.LabelGroup.bindLabels( binding.LabelGroup.bindLabels(
notallyModel.labels, notallyModel.labels,
notallyModel.textSize, notallyModel.textSize,
paddingTop = true, paddingTop = true,
colorInt, 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() { private fun handleSharedNote() {
val title = intent.getStringExtra(Intent.EXTRA_SUBJECT) val title = intent.getStringExtra(Intent.EXTRA_SUBJECT)
val string = intent.getStringExtra(Intent.EXTRA_TEXT) 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) { if (string != null) {
notallyModel.body = Editable.Factory.getInstance().newEditable(string) notallyModel.body = Editable.Factory.getInstance().newEditable(string)
} }
if (title != null) { if (title != null) {
notallyModel.title = title 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) @RequiresApi(24)
@ -589,9 +844,38 @@ abstract class EditActivity(private val type: Type) :
} }
override fun changeColor() { override fun changeColor() {
showColorSelectDialog(colorInt.isLightColor()) { selectedColor -> lifecycleScope.launch {
notallyModel.color = selectedColor val colors =
setColor() 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() { override fun share() {
val body = this.shareNote(notallyModel.getBaseNote())
when (type) { }
Type.NOTE -> notallyModel.body
Type.LIST -> notallyModel.items.toMutableList().toText() override fun export(mimeType: ExportMimeType) {
} exportNotes(
this.shareNote(notallyModel.title, body) mimeType,
listOf(notallyModel.getBaseNote()),
exportFileActivityResultLauncher,
exportNotesActivityResultLauncher,
)
} }
private fun delete() { private fun delete() {
@ -807,7 +1095,9 @@ abstract class EditActivity(private val type: Type) :
colorInt = extractColor(notallyModel.color) colorInt = extractColor(notallyModel.color)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
window.statusBarColor = colorInt window.statusBarColor = colorInt
window.navigationBarColor = colorInt if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
window.navigationBarColor = colorInt
}
window.setLightStatusAndNavBar(colorInt.isLightColor()) window.setLightStatusAndNavBar(colorInt.isLightColor())
} }
binding.apply { binding.apply {
@ -816,7 +1106,8 @@ abstract class EditActivity(private val type: Type) :
setControlsContrastColorForAllViews(colorInt) setControlsContrastColorForAllViews(colorInt)
} }
root.setBackgroundColor(colorInt) root.setBackgroundColor(colorInt)
RecyclerView.setBackgroundColor(colorInt) MainListView.setBackgroundColor(colorInt)
CheckedListView.setBackgroundColor(colorInt)
Toolbar.backgroundTintList = ColorStateList.valueOf(colorInt) Toolbar.backgroundTintList = ColorStateList.valueOf(colorInt)
Toolbar.setControlsContrastColorForAllViews(colorInt) Toolbar.setControlsContrastColorForAllViews(colorInt)
} }
@ -838,10 +1129,15 @@ abstract class EditActivity(private val type: Type) :
when (type) { when (type) {
Type.NOTE -> { Type.NOTE -> {
binding.AddItem.visibility = GONE binding.AddItem.visibility = GONE
binding.RecyclerView.visibility = GONE binding.MainListView.visibility = GONE
binding.CheckedListView.visibility = GONE
} }
Type.LIST -> { Type.LIST -> {
binding.EnterBody.visibility = GONE 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_NOTE_ID = "notallyx.intent.extra.NOTE_ID"
const val EXTRA_FOLDER_FROM = "notallyx.intent.extra.FOLDER_FROM" const val EXTRA_FOLDER_FROM = "notallyx.intent.extra.FOLDER_FROM"
const val EXTRA_FOLDER_TO = "notallyx.intent.extra.FOLDER_TO" 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 package com.philkes.notallyx.presentation.activity.note
import android.os.Bundle 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.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.data.model.Type
import com.philkes.notallyx.presentation.addIconButton import com.philkes.notallyx.presentation.addIconButton
import com.philkes.notallyx.presentation.hideKeyboardOnFocusedItem
import com.philkes.notallyx.presentation.setOnNextAction 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.MoreListActions
import com.philkes.notallyx.presentation.view.note.action.MoreListBottomSheet 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.HighlightText
import com.philkes.notallyx.presentation.view.note.listitem.ListItemVH
import com.philkes.notallyx.presentation.view.note.listitem.ListManager 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.adapter.CheckedListItemAdapter
import com.philkes.notallyx.presentation.view.note.listitem.sorting.ListItemSortedByCheckedCallback import com.philkes.notallyx.presentation.view.note.listitem.adapter.ListItemAdapter
import com.philkes.notallyx.presentation.view.note.listitem.sorting.ListItemSortedList import com.philkes.notallyx.presentation.view.note.listitem.adapter.ListItemHighlight
import com.philkes.notallyx.presentation.view.note.listitem.sorting.indices import com.philkes.notallyx.presentation.view.note.listitem.adapter.ListItemVH
import com.philkes.notallyx.presentation.view.note.listitem.sorting.mapIndexed import com.philkes.notallyx.presentation.view.note.listitem.init
import com.philkes.notallyx.presentation.view.note.listitem.sorting.toMutableList import com.philkes.notallyx.presentation.view.note.listitem.setItems
import com.philkes.notallyx.presentation.viewmodel.preference.ListItemSort 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.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.autoSortByCheckedEnabled
import com.philkes.notallyx.utils.findAllOccurrences 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 { class EditListActivity : EditActivity(Type.LIST), MoreListActions {
private var adapter: ListItemAdapter? = null 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 private lateinit var listManager: ListManager
override fun finish() { override fun finish() {
notallyModel.setItems(items.toMutableList()) notallyModel.setItems(items.toMutableList() + (itemsChecked?.toMutableList() ?: listOf()))
super.finish() super.finish()
} }
override fun updateModel() {
super.updateModel()
notallyModel.setItems(items.toMutableList() + (itemsChecked?.toMutableList() ?: listOf()))
}
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
notallyModel.setItems(items.toMutableList()) updateModel()
binding.RecyclerView.focusedChild?.let { focusedChild -> binding.MainListView.focusedChild?.let { focusedChild ->
val viewHolder = binding.RecyclerView.findContainingViewHolder(focusedChild) val viewHolder = binding.MainListView.findContainingViewHolder(focusedChild)
if (viewHolder is ListItemVH) { if (viewHolder is ListItemVH) {
val itemPos = binding.RecyclerView.getChildAdapterPosition(focusedChild) val itemPos = binding.MainListView.getChildAdapterPosition(focusedChild)
if (itemPos > -1) { if (itemPos > -1) {
val (selectionStart, selectionEnd) = viewHolder.getSelection() val (selectionStart, selectionEnd) = viewHolder.getSelection()
outState.apply { outState.apply {
@ -51,6 +73,21 @@ class EditListActivity : EditActivity(Type.LIST), MoreListActions {
super.onSaveInstanceState(outState) 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() { override fun deleteChecked() {
listManager.deleteCheckedItems() listManager.deleteCheckedItems()
} }
@ -67,52 +104,112 @@ class EditListActivity : EditActivity(Type.LIST), MoreListActions {
super.initBottomMenu() super.initBottomMenu()
binding.BottomAppBarRight.apply { binding.BottomAppBarRight.apply {
removeAllViews() removeAllViews()
addIconButton(R.string.more, R.drawable.more_vert, marginStart = 0) { addToggleViewMode()
MoreListBottomSheet(this@EditListActivity, createFolderActions(), colorInt) addIconButton(R.string.tap_for_more_options, R.drawable.more_vert, marginStart = 0) {
MoreListBottomSheet(
this@EditListActivity,
createNoteTypeActions() + createFolderActions(),
colorInt,
)
.show(supportFragmentManager, MoreListBottomSheet.TAG) .show(supportFragmentManager, MoreListBottomSheet.TAG)
} }
} }
setBottomAppBarColor(colorInt) 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 { override fun highlightSearchResults(search: String): Int {
var resultPos = 0 val resultPosCounter = AtomicInteger(0)
val alreadyNotifiedItemPos = mutableSetOf<Int>() val alreadyNotifiedItemPos = mutableSetOf<Int>()
adapter?.clearHighlights() adapter?.clearHighlights()
adapterChecked?.clearHighlights()
val amount = val amount =
items items.highlightSearch(search, adapter, resultPosCounter, alreadyNotifiedItemPos) +
.mapIndexed { idx, item -> (itemsChecked?.highlightSearch(
val occurrences = item.body.findAllOccurrences(search) search,
occurrences.onEach { (startIdx, endIdx) -> adapterChecked,
adapter?.highlightText( resultPosCounter,
ListItemAdapter.ListItemHighlight( alreadyNotifiedItemPos,
idx, ) ?: 0)
resultPos++,
startIdx,
endIdx,
false,
)
)
}
if (occurrences.isNotEmpty()) {
alreadyNotifiedItemPos.add(idx)
}
occurrences.size
}
.sum()
items.indices items.indices
.filter { !alreadyNotifiedItemPos.contains(it) } .filter { !alreadyNotifiedItemPos.contains(it) }
.forEach { adapter?.notifyItemChanged(it) } .forEach { adapter?.notifyItemChanged(it) }
itemsChecked
?.indices
?.filter { !alreadyNotifiedItemPos.contains(it) }
?.forEach { adapter?.notifyItemChanged(it) }
return amount return amount
} }
override fun selectSearchResult(resultPos: Int) { override fun selectSearchResult(resultPos: Int) {
val selectedItemPos = adapter!!.selectHighlight(resultPos) var selectedItemPos = adapter!!.selectHighlight(resultPos)
if (selectedItemPos != -1) { if (selectedItemPos == -1 && adapterChecked != null) {
binding.RecyclerView.post { selectedItemPos = adapterChecked!!.selectHighlight(resultPos)
binding.RecyclerView.findViewHolderForAdapterPosition(selectedItemPos) if (selectedItemPos != -1) {
?.itemView binding.CheckedListView.scrollToItemPosition(selectedItemPos)
?.let { binding.ScrollView.scrollTo(0, binding.RecyclerView.top + it.top) } }
} 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 val elevation = resources.displayMetrics.density * 2
listManager = listManager =
ListManager( ListManager(
binding.RecyclerView, binding.MainListView,
changeHistory, changeHistory,
preferences, preferences,
inputMethodManager, inputMethodManager,
@ -156,25 +253,39 @@ class EditListActivity : EditActivity(Type.LIST), MoreListActions {
elevation, elevation,
NotallyXPreferences.getInstance(application), NotallyXPreferences.getInstance(application),
listManager, listManager,
false,
binding.ScrollView,
) )
val sortCallback = val initializedItems = notallyModel.items.init(true)
when (preferences.listItemSorting.value) { if (preferences.autoSortByCheckedEnabled) {
ListItemSort.AUTO_SORT_BY_CHECKED -> ListItemSortedByCheckedCallback(adapter) val (checkedItems, uncheckedItems) = initializedItems.splitByChecked()
else -> ListItemNoSortCallback(adapter) adapter?.submitList(uncheckedItems.toMutableList())
} adapterChecked =
items = ListItemSortedList(sortCallback) CheckedListItemAdapter(
if (sortCallback is ListItemSortedByCheckedCallback) { colorInt,
sortCallback.setList(items) 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) listManager.init(adapter!!, itemsChecked, adapterChecked)
adapter?.setList(items) binding.MainListView.adapter = adapter
binding.RecyclerView.adapter = adapter
listManager.adapter = adapter!!
listManager.initList(items)
savedInstanceState?.let { savedInstanceState?.let {
val itemPos = it.getInt(EXTRA_ITEM_POS, -1) val itemPos = it.getInt(EXTRA_ITEM_POS, -1)
if (itemPos > -1) { if (itemPos > -1) {
binding.RecyclerView.apply { binding.MainListView.apply {
post { post {
scrollToPosition(itemPos) scrollToPosition(itemPos)
val viewHolder = findViewHolderForLayoutPosition(itemPos) val viewHolder = findViewHolderForLayoutPosition(itemPos)
@ -196,6 +307,7 @@ class EditListActivity : EditActivity(Type.LIST), MoreListActions {
override fun setColor() { override fun setColor() {
super.setColor() super.setColor()
adapter?.setBackgroundColor(colorInt) adapter?.setBackgroundColor(colorInt)
adapterChecked?.setBackgroundColor(colorInt)
} }
companion object { companion object {

View file

@ -21,10 +21,12 @@ import android.widget.LinearLayout
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.philkes.notallyx.R 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.Type
import com.philkes.notallyx.data.model.createNoteUrl import com.philkes.notallyx.data.model.createNoteUrl
import com.philkes.notallyx.data.model.getNoteIdFromUrl 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.activity.note.PickNoteActivity.Companion.EXTRA_PICKED_NOTE_TYPE
import com.philkes.notallyx.presentation.add import com.philkes.notallyx.presentation.add
import com.philkes.notallyx.presentation.addIconButton import com.philkes.notallyx.presentation.addIconButton
import com.philkes.notallyx.presentation.createBoldSpan
import com.philkes.notallyx.presentation.dp import com.philkes.notallyx.presentation.dp
import com.philkes.notallyx.presentation.hideKeyboard
import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews
import com.philkes.notallyx.presentation.setOnNextAction import com.philkes.notallyx.presentation.setOnNextAction
import com.philkes.notallyx.presentation.showKeyboard import com.philkes.notallyx.presentation.showKeyboard
@ -65,8 +69,6 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
override fun configureUI() { override fun configureUI() {
binding.EnterTitle.setOnNextAction { binding.EnterBody.requestFocus() } binding.EnterTitle.setOnNextAction { binding.EnterBody.requestFocus() }
setupEditor()
if (notallyModel.isNewNote) { if (notallyModel.isNewNote) {
binding.EnterBody.requestFocus() binding.EnterBody.requestFocus()
} }
@ -78,6 +80,17 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
setupActivityResultLaunchers() 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) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
outState.apply { outState.apply {
@ -172,78 +185,8 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
private fun setupEditor() { private fun setupEditor() {
setupMovementMethod() setupMovementMethod()
binding.EnterBody.customSelectionActionModeCallback = binding.EnterBody.customSelectionActionModeCallback =
object : ActionMode.Callback { if (canEdit) {
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 =
object : ActionMode.Callback { object : ActionMode.Callback {
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?) = false override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?) = false
@ -255,8 +198,54 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
// ActionMode implementation // ActionMode implementation
try { try {
menu?.apply { menu?.apply {
add(R.string.link_note, 0, order = Menu.CATEGORY_CONTAINER + 1) { add(
linkNote(pickNoteNewActivityResultLauncher) 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() mode?.finish()
} }
} }
@ -270,26 +259,69 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
binding.EnterBody.isActionModeOn = false 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 (canEdit) {
if (selEnd - selStart > 0) { binding.EnterBody.setOnSelectionChange { selStart, selEnd ->
if (!textFormatMenu.isEnabled) { if (selEnd - selStart > 0) {
initBottomTextFormattingMenu() 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.ContentLayout.setOnClickListener {
binding.EnterBody.apply { binding.EnterBody.apply {
requestFocus() requestFocus()
setSelection(length()) if (canEdit) {
showKeyboard(this) setSelection(length())
showKeyboard(this)
}
} }
} }
} }
@ -326,7 +358,7 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
updateLayoutParams<LinearLayout.LayoutParams> { updateLayoutParams<LinearLayout.LayoutParams> {
marginEnd = 0 marginEnd = 0
marginStart = 10.dp(context) marginStart = 10.dp
} }
setControlsContrastColorForAllViews(extractColor) setControlsContrastColorForAllViews(extractColor)
setBackgroundColor(0) setBackgroundColor(0)
@ -340,7 +372,7 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
} }
requestLayout() requestLayout()
val layout = BottomTextFormattingMenuBinding.inflate(layoutInflater, this, false) val layout = BottomTextFormattingMenuBinding.inflate(layoutInflater, this, false)
layout.RecyclerView.apply { layout.MainListView.apply {
textFormattingAdapter = textFormattingAdapter =
TextFormattingAdapter(this@EditNoteActivity, binding.EnterBody, colorInt) TextFormattingAdapter(this@EditNoteActivity, binding.EnterBody, colorInt)
adapter = textFormattingAdapter adapter = textFormattingAdapter
@ -366,19 +398,23 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
val movementMethod = LinkMovementMethod { span -> val movementMethod = LinkMovementMethod { span ->
val items = val items =
if (span.url.isNoteUrl()) { if (span.url.isNoteUrl()) {
arrayOf( if (canEdit) {
getString(R.string.remove_link), arrayOf(
getString(R.string.change_note), getString(R.string.open_note),
getString(R.string.edit), getString(R.string.remove_link),
getString(R.string.open_note), getString(R.string.change_note),
) getString(R.string.edit),
)
} else arrayOf(getString(R.string.open_note))
} else { } else {
arrayOf( if (canEdit) {
getString(R.string.remove_link), arrayOf(
getString(R.string.copy), getString(R.string.open_link),
getString(R.string.edit), getString(R.string.copy),
getString(R.string.open_link), getString(R.string.remove_link),
) getString(R.string.edit),
)
} else arrayOf(getString(R.string.open_link), getString(R.string.copy))
} }
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this)
.setTitle( .setTitle(
@ -390,35 +426,16 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
) )
.setItems(items) { _, which -> .setItems(items) { _, which ->
when (which) { when (which) {
0 -> { 0 -> openLink(span)
binding.EnterBody.removeSpanWithHistory(
span,
span.url.isNoteUrl() ||
span.url == binding.EnterBody.getSpanText(span),
)
}
1 -> 1 ->
if (span.url.isNoteUrl()) { if (span.url.isNoteUrl()) {
selectedSpan = span removeLink(span)
linkNote(pickNoteUpdateActivityResultLauncher) } else copyLink(span)
} else { 2 ->
copyToClipBoard(span.url) if (span.url.isNoteUrl()) {
showToast(R.string.copied_link) changeNoteLink(span)
} } else removeLink(span)
3 -> editLink(span)
2 -> {
binding.EnterBody.showEditDialog(span)
}
3 -> {
span.url?.let {
if (it.isNoteUrl()) {
span.navigateToNote()
} else {
openLink(span.url)
}
}
}
} }
} }
.show() .show()
@ -426,6 +443,37 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
binding.EnterBody.movementMethod = movementMethod 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) { private fun openLink(url: String) {
val uri = Uri.parse(url) val uri = Uri.parse(url)
val intent = Intent(Intent.ACTION_VIEW, uri).wrapWithChooser(this) val intent = Intent(Intent.ACTION_VIEW, uri).wrapWithChooser(this)

View file

@ -51,14 +51,15 @@ open class PickNoteActivity : LockedActivity<ActivityPickNoteBinding>(), ItemLis
maxItems.value, maxItems.value,
maxLines.value, maxLines.value,
maxTitle.value, maxTitle.value,
labelsHiddenInOverview.value, labelTagsHiddenInOverview.value,
imagesHiddenInOverview.value,
), ),
application.getExternalImagesDirectory(), application.getExternalImagesDirectory(),
this@PickNoteActivity, this@PickNoteActivity,
) )
} }
binding.RecyclerView.apply { binding.MainListView.apply {
adapter = this@PickNoteActivity.adapter adapter = this@PickNoteActivity.adapter
setHasFixedSize(true) setHasFixedSize(true)
layoutManager = layoutManager =
@ -71,6 +72,7 @@ open class PickNoteActivity : LockedActivity<ActivityPickNoteBinding>(), ItemLis
val pinned = Header(getString(R.string.pinned)) val pinned = Header(getString(R.string.pinned))
val others = Header(getString(R.string.others)) val others = Header(getString(R.string.others))
val archived = Header(getString(R.string.archived))
database.observe(this) { database.observe(this) {
lifecycleScope.launch { lifecycleScope.launch {
@ -78,7 +80,7 @@ open class PickNoteActivity : LockedActivity<ActivityPickNoteBinding>(), ItemLis
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val raw = val raw =
it.getBaseNoteDao().getAllNotes().filter { it.id != excludedNoteId } it.getBaseNoteDao().getAllNotes().filter { it.id != excludedNoteId }
BaseNoteModel.transform(raw, pinned, others) BaseNoteModel.transform(raw, pinned, others, archived)
} }
adapter.submitList(notes) adapter.submitList(notes)
binding.EmptyView.visibility = binding.EmptyView.visibility =

View file

@ -3,7 +3,6 @@ package com.philkes.notallyx.presentation.activity.note
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.activity.viewModels
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder 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.showAndFocus
import com.philkes.notallyx.presentation.showToast import com.philkes.notallyx.presentation.showToast
import com.philkes.notallyx.presentation.view.main.label.SelectableLabelAdapter import com.philkes.notallyx.presentation.view.main.label.SelectableLabelAdapter
import com.philkes.notallyx.presentation.viewmodel.LabelModel
class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() { class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
private val model: LabelModel by viewModels()
private lateinit var selectedLabels: ArrayList<String> private lateinit var selectedLabels: ArrayList<String>
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -88,7 +84,7 @@ class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
} }
} }
binding.RecyclerView.apply { binding.MainListView.apply {
setHasFixedSize(true) setHasFixedSize(true)
adapter = labelAdapter adapter = labelAdapter
addItemDecoration( addItemDecoration(

View file

@ -66,15 +66,15 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
currentImage = savedImage currentImage = savedImage
} }
binding.RecyclerView.apply { binding.MainListView.apply {
setHasFixedSize(true) setHasFixedSize(true)
layoutManager = layoutManager =
LinearLayoutManager(this@ViewImageActivity, RecyclerView.HORIZONTAL, false) LinearLayoutManager(this@ViewImageActivity, RecyclerView.HORIZONTAL, false)
PagerSnapHelper().attachToRecyclerView(binding.RecyclerView) PagerSnapHelper().attachToRecyclerView(binding.MainListView)
} }
val initial = intent.getIntExtra(EXTRA_POSITION, 0) val initial = intent.getIntExtra(EXTRA_POSITION, 0)
binding.RecyclerView.scrollToPosition(initial) binding.MainListView.scrollToPosition(initial)
val database = NotallyDatabase.getDatabase(application) val database = NotallyDatabase.getDatabase(application)
val id = intent.getLongExtra(EXTRA_SELECTED_BASE_NOTE, 0) val id = intent.getLongExtra(EXTRA_SELECTED_BASE_NOTE, 0)
@ -88,7 +88,7 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
val mediaRoot = application.getExternalImagesDirectory() val mediaRoot = application.getExternalImagesDirectory()
val adapter = ImageAdapter(mediaRoot, images) val adapter = ImageAdapter(mediaRoot, images)
binding.RecyclerView.adapter = adapter binding.MainListView.adapter = adapter
setupToolbar(binding, adapter) setupToolbar(binding, adapter)
} }
} }
@ -112,7 +112,7 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
private fun setupToolbar(binding: ActivityViewImageBinding, adapter: ImageAdapter) { private fun setupToolbar(binding: ActivityViewImageBinding, adapter: ImageAdapter) {
binding.Toolbar.setNavigationOnClickListener { finish() } binding.Toolbar.setNavigationOnClickListener { finish() }
val layoutManager = binding.RecyclerView.layoutManager as LinearLayoutManager val layoutManager = binding.MainListView.layoutManager as LinearLayoutManager
adapter.registerAdapterDataObserver( adapter.registerAdapterDataObserver(
object : RecyclerView.AdapterDataObserver() { object : RecyclerView.AdapterDataObserver() {
@ -123,7 +123,7 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
} }
) )
binding.RecyclerView.addOnScrollListener( binding.MainListView.addOnScrollListener(
object : RecyclerView.OnScrollListener() { object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {

View file

@ -102,7 +102,7 @@ class RemindersActivity : LockedActivity<ActivityRemindersBinding>(), ReminderLi
private fun setupRecyclerView() { private fun setupRecyclerView() {
reminderAdapter = ReminderAdapter(this) reminderAdapter = ReminderAdapter(this)
binding.RecyclerView.apply { binding.MainListView.apply {
initListView(this@RemindersActivity) initListView(this@RemindersActivity)
adapter = reminderAdapter 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.data.model.Item
import com.philkes.notallyx.databinding.RecyclerBaseNoteBinding import com.philkes.notallyx.databinding.RecyclerBaseNoteBinding
import com.philkes.notallyx.databinding.RecyclerHeaderBinding 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.BaseNoteCreationDateSort
import com.philkes.notallyx.presentation.view.main.sorting.BaseNoteModifiedDateSort import com.philkes.notallyx.presentation.view.main.sorting.BaseNoteModifiedDateSort
import com.philkes.notallyx.presentation.view.main.sorting.BaseNoteTitleSort import com.philkes.notallyx.presentation.view.main.sorting.BaseNoteTitleSort
@ -99,7 +100,9 @@ class BaseNoteAdapter(
NotesSortBy.TITLE -> BaseNoteTitleSort(this@BaseNoteAdapter, sortDirection) NotesSortBy.TITLE -> BaseNoteTitleSort(this@BaseNoteAdapter, sortDirection)
NotesSortBy.MODIFIED_DATE -> NotesSortBy.MODIFIED_DATE ->
BaseNoteModifiedDateSort(this@BaseNoteAdapter, sortDirection) 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>) { private fun replaceSortCallback(sortCallback: SortedListAdapterCallback<Item>) {

View file

@ -6,7 +6,6 @@ import android.view.View.GONE
import android.view.View.VISIBLE import android.view.View.VISIBLE
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.view.children import androidx.core.view.children
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
@ -21,7 +20,6 @@ import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target import com.bumptech.glide.request.target.Target
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.BaseNote 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.FileAttachment
import com.philkes.notallyx.data.model.ListItem import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.SpanRepresentation 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.displayFormattedTimestamp
import com.philkes.notallyx.presentation.dp import com.philkes.notallyx.presentation.dp
import com.philkes.notallyx.presentation.extractColor import com.philkes.notallyx.presentation.extractColor
import com.philkes.notallyx.presentation.getColorFromAttr
import com.philkes.notallyx.presentation.getQuantityString import com.philkes.notallyx.presentation.getQuantityString
import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews
import com.philkes.notallyx.presentation.view.misc.ItemListener import com.philkes.notallyx.presentation.view.misc.ItemListener
@ -48,6 +45,7 @@ data class BaseNoteVHPreferences(
val maxLines: Int, val maxLines: Int,
val maxTitleLines: Int, val maxTitleLines: Int,
val hideLabels: Boolean, val hideLabels: Boolean,
val hideImages: Boolean,
) )
class BaseNoteVH( class BaseNoteVH(
@ -83,22 +81,11 @@ class BaseNoteVH(
} }
} }
fun updateCheck(checked: Boolean, color: Color) { fun updateCheck(checked: Boolean, color: String) {
if (binding.root.isChecked != checked) { if (checked) {
if (checked) { binding.root.strokeWidth = 3.dp
binding.root.apply { } else {
strokeColor = context.getColorFromAttr(androidx.appcompat.R.attr.colorPrimary) binding.root.strokeWidth = if (color == BaseNote.COLOR_DEFAULT) 1.dp else 0
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)
}
}
} }
binding.root.isChecked = checked binding.root.isChecked = checked
} }
@ -108,7 +95,7 @@ class BaseNoteVH(
when (baseNote.type) { when (baseNote.type) {
Type.NOTE -> bindNote(baseNote.body, baseNote.spans, baseNote.title.isEmpty()) 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) = val (date, datePrefixResId) =
when (sortBy) { when (sortBy) {
@ -127,8 +114,7 @@ class BaseNoteVH(
isVisible = baseNote.title.isNotEmpty() isVisible = baseNote.title.isNotEmpty()
updatePadding( updatePadding(
bottom = bottom =
if (baseNote.hasNoContents() || shouldOnlyDisplayTitle(baseNote)) 0 if (baseNote.hasNoContents() || shouldOnlyDisplayTitle(baseNote)) 0 else 8.dp
else 8.dp(context)
) )
setCompoundDrawablesWithIntrinsicBounds( setCompoundDrawablesWithIntrinsicBounds(
if (baseNote.type == Type.LIST && preferences.maxItems < 1) 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 { binding.apply {
Note.visibility = GONE Note.visibility = GONE
if (items.isEmpty()) { if (items.isEmpty()) {
LinearLayout.visibility = GONE LinearLayout.visibility = GONE
} else { } else {
LinearLayout.visibility = VISIBLE 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 -> LinearLayout.children.forEachIndexed { index, view ->
if (view.id != R.id.ItemsRemaining) { if (view.id != R.id.ItemsRemaining) {
if (index < filteredList.size) { if (index < filteredList.size) {
@ -193,9 +180,12 @@ class BaseNoteVH(
visibility = VISIBLE visibility = VISIBLE
if (item.isChild) { if (item.isChild) {
updateLayoutParams<LinearLayout.LayoutParams> { updateLayoutParams<LinearLayout.LayoutParams> {
marginStart = 20.dp(context) marginStart = 20.dp
} }
} }
if (index == filteredList.lastIndex) {
updatePadding(bottom = 0)
}
} }
} else view.visibility = GONE } else view.visibility = GONE
} }
@ -211,26 +201,17 @@ class BaseNoteVH(
} }
} }
private fun setColor(color: Color) { private fun setColor(color: String) {
binding.root.apply { binding.root.apply {
if (color == Color.DEFAULT) { val colorInt = context.extractColor(color)
val stroke = ContextCompat.getColorStateList(context, R.color.chip_stroke) setCardBackgroundColor(colorInt)
setStrokeColor(stroke) setControlsContrastColorForAllViews(colorInt)
setCardBackgroundColor(0)
setControlsContrastColorForAllViews(context.getColorFromAttr(R.attr.colorSurface))
} else {
strokeColor = 0
val colorInt = context.extractColor(color)
setCardBackgroundColor(colorInt)
setControlsContrastColorForAllViews(colorInt)
}
} }
} }
private fun setImages(images: List<FileAttachment>, mediaRoot: File?) { private fun setImages(images: List<FileAttachment>, mediaRoot: File?) {
binding.apply { binding.apply {
if (images.isNotEmpty()) { if (images.isNotEmpty() && !preferences.hideImages) {
ImageView.visibility = VISIBLE ImageView.visibility = VISIBLE
Message.visibility = GONE Message.visibility = GONE

View file

@ -3,19 +3,20 @@ package com.philkes.notallyx.presentation.view.main
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.data.model.Color
import com.philkes.notallyx.databinding.RecyclerColorBinding import com.philkes.notallyx.databinding.RecyclerColorBinding
import com.philkes.notallyx.presentation.view.misc.ItemListener import com.philkes.notallyx.presentation.view.misc.ItemListener
class ColorAdapter(private val listener: ItemListener) : RecyclerView.Adapter<ColorVH>() { class ColorAdapter(
private val colors: List<String>,
private val colors = Color.entries.toTypedArray() private val selectedColor: String?,
private val listener: ItemListener,
) : RecyclerView.Adapter<ColorVH>() {
override fun getItemCount() = colors.size override fun getItemCount() = colors.size
override fun onBindViewHolder(holder: ColorVH, position: Int) { override fun onBindViewHolder(holder: ColorVH, position: Int) {
val color = colors[position] val color = colors[position]
holder.bind(color) holder.bind(color, color == selectedColor)
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ColorVH { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ColorVH {

View file

@ -1,9 +1,15 @@
package com.philkes.notallyx.presentation.view.main package com.philkes.notallyx.presentation.view.main
import android.content.res.ColorStateList
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView 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.databinding.RecyclerColorBinding
import com.philkes.notallyx.presentation.dp
import com.philkes.notallyx.presentation.extractColor 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 import com.philkes.notallyx.presentation.view.misc.ItemListener
class ColorVH(private val binding: RecyclerColorBinding, listener: ItemListener) : class ColorVH(private val binding: RecyclerColorBinding, listener: ItemListener) :
@ -11,11 +17,40 @@ class ColorVH(private val binding: RecyclerColorBinding, listener: ItemListener)
init { init {
binding.CardView.setOnClickListener { listener.onClick(absoluteAdapterPosition) } binding.CardView.setOnClickListener { listener.onClick(absoluteAdapterPosition) }
binding.CardView.setOnLongClickListener {
listener.onLongClick(absoluteAdapterPosition)
true
}
} }
fun bind(color: Color) { fun bind(color: String, isSelected: Boolean) {
val value = binding.root.context.extractColor(color) val showAddIcon = color == BaseNote.COLOR_NEW
binding.CardView.setCardBackgroundColor(value) val context = binding.root.context
binding.CardView.contentDescription = color.name 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 import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection
class BaseNoteCreationDateSort(adapter: RecyclerView.Adapter<*>?, sortDirection: SortDirection) : class BaseNoteCreationDateSort(adapter: RecyclerView.Adapter<*>?, sortDirection: SortDirection) :
BaseNoteSort(adapter, sortDirection) { ItemSort(adapter, sortDirection) {
override fun compare(note1: BaseNote, note2: BaseNote, sortDirection: SortDirection): Int { 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 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 import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection
class BaseNoteModifiedDateSort(adapter: RecyclerView.Adapter<*>?, sortDirection: SortDirection) : class BaseNoteModifiedDateSort(adapter: RecyclerView.Adapter<*>?, sortDirection: SortDirection) :
BaseNoteSort(adapter, sortDirection) { ItemSort(adapter, sortDirection) {
override fun compare(note1: BaseNote, note2: BaseNote, sortDirection: SortDirection): Int { 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 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 import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection
class BaseNoteTitleSort(adapter: RecyclerView.Adapter<*>?, sortDirection: SortDirection) : class BaseNoteTitleSort(adapter: RecyclerView.Adapter<*>?, sortDirection: SortDirection) :
BaseNoteSort(adapter, sortDirection) { ItemSort(adapter, sortDirection) {
override fun compare(note1: BaseNote, note2: BaseNote, sortDirection: SortDirection): Int { 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 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.data.model.Item
import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection
abstract class BaseNoteSort( abstract class ItemSort(
adapter: RecyclerView.Adapter<*>?, adapter: RecyclerView.Adapter<*>?,
private val sortDirection: SortDirection, private val sortDirection: SortDirection,
) : SortedListAdapterCallback<Item>(adapter) { ) : SortedListAdapterCallback<Item>(adapter) {

View file

@ -1,17 +1,21 @@
package com.philkes.notallyx.presentation.view.misc package com.philkes.notallyx.presentation.view.misc
import android.content.Context import android.content.Context
import android.os.Build
import android.text.Editable import android.text.Editable
import android.text.TextWatcher import android.text.TextWatcher
import android.text.method.KeyListener
import android.util.AttributeSet import android.util.AttributeSet
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import androidx.appcompat.widget.AppCompatEditText import androidx.appcompat.widget.AppCompatEditText
import com.philkes.notallyx.presentation.clone import com.philkes.notallyx.presentation.clone
import com.philkes.notallyx.presentation.showKeyboard
open class EditTextWithWatcher(context: Context, attrs: AttributeSet) : open class EditTextWithWatcher(context: Context, attrs: AttributeSet) :
AppCompatEditText(context, attrs) { AppCompatEditText(context, attrs) {
var textWatcher: TextWatcher? = null var textWatcher: TextWatcher? = null
private var onSelectionChange: ((selStart: Int, selEnd: Int) -> Unit)? = null private var onSelectionChange: ((selStart: Int, selEnd: Int) -> Unit)? = null
private var keyListenerInstance: KeyListener? = null
override fun onSelectionChanged(selStart: Int, selEnd: Int) { override fun onSelectionChanged(selStart: Int, selEnd: Int) {
super.onSelectionChanged(selStart, selEnd) super.onSelectionChanged(selStart, selEnd)
@ -30,33 +34,60 @@ open class EditTextWithWatcher(context: Context, attrs: AttributeSet) :
super.setText(text, BufferType.EDITABLE) super.setText(text, BufferType.EDITABLE)
} }
fun setCanEdit(value: Boolean) {
if (!value) {
clearFocus()
}
keyListener?.let { keyListenerInstance = it }
keyListener = if (value) keyListenerInstance else null // Disables text editing
isCursorVisible = true
isFocusable = value
isFocusableInTouchMode = value
setTextIsSelectable(true)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) {
setOnClickListener {
if (value) {
context.showKeyboard(this)
}
}
setOnFocusChangeListener { v, hasFocus ->
if (hasFocus && value) {
context.showKeyboard(this)
}
}
}
}
@Deprecated( @Deprecated(
"You should not access text Editable directly, use other member functions to edit/read text properties.", "You should not access text Editable directly, use other member functions to edit/read text properties.",
replaceWith = ReplaceWith("changeText/applyWithoutTextWatcher/..."), replaceWith = ReplaceWith("changeText/applyWithoutTextWatcher/..."),
) )
override fun getText(): Editable? { override fun getText(): Editable? {
return super.getText() return getTextSafe()
} }
fun getTextClone(): Editable { fun getTextClone(): Editable {
return super.getText()!!.clone() return getTextSafe().clone()
} }
fun applyWithoutTextWatcher( fun applyWithoutTextWatcher(
callback: EditTextWithWatcher.() -> Unit callback: EditTextWithWatcher.() -> Unit
): Pair<Editable, Editable> { ): Pair<Editable, Editable> {
val textBefore = super.getText()!!.clone() val textBefore = getTextClone()
val editTextWatcher = textWatcher val editTextWatcher = textWatcher
editTextWatcher?.let { removeTextChangedListener(it) } editTextWatcher?.let { removeTextChangedListener(it) }
callback() callback()
editTextWatcher?.let { addTextChangedListener(it) } editTextWatcher?.let { addTextChangedListener(it) }
return Pair(textBefore, super.getText()!!.clone()) return Pair(textBefore, getTextClone())
} }
fun changeText(callback: (text: Editable) -> Unit): Pair<Editable, Editable> { 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( fun focusAndSelect(
start: Int = selectionStart, start: Int = selectionStart,
end: Int = selectionEnd, end: Int = selectionEnd,

View file

@ -24,9 +24,9 @@ class TextWithIconAdapter<T>(
} }
val item = getItem(position)!! val item = getItem(position)!!
setCompoundDrawablesRelativeWithIntrinsicBounds(getIconResId(item), 0, 0, 0) setCompoundDrawablesRelativeWithIntrinsicBounds(getIconResId(item), 0, 0, 0)
setPaddingRelative(30.dp(context), paddingTop, paddingEnd, paddingBottom) setPaddingRelative(30.dp, paddingTop, paddingEnd, paddingBottom)
text = getText(item) 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) ContextCompat.getDrawable(context, R.drawable.checkbox_partial)
init { init {
compoundDrawablePadding = 4.dp(context) compoundDrawablePadding = 4.dp
buttonDrawable = getCurrentDrawable() buttonDrawable = getCurrentDrawable()
setOnClickListener { toggleState() } setOnClickListener { toggleState() }
} }

View file

@ -21,7 +21,7 @@ fun MaterialAlertDialogBuilder.setMultiChoiceTriStateItems(
val recyclerView = val recyclerView =
RecyclerView(context).apply { RecyclerView(context).apply {
layoutManager = LinearLayoutManager(context) layoutManager = LinearLayoutManager(context)
setPadding(0, 8.dp(context), 0, 0) setPadding(0, 8.dp, 0, 0)
this.adapter = adapter this.adapter = adapter
} }
setView(recyclerView) setView(recyclerView)

View file

@ -8,6 +8,7 @@ import android.text.style.TypefaceSpan
import android.text.style.URLSpan import android.text.style.URLSpan
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.presentation.createBoldSpan
import com.philkes.notallyx.presentation.view.misc.StylableEditTextWithHistory import com.philkes.notallyx.presentation.view.misc.StylableEditTextWithHistory
class TextFormattingAdapter( class TextFormattingAdapter(
@ -35,7 +36,7 @@ class TextFormattingAdapter(
private val bold: Toggle = private val bold: Toggle =
Toggle(R.string.bold, R.drawable.format_bold, false) { Toggle(R.string.bold, R.drawable.format_bold, false) {
if (!it.checked) { if (!it.checked) {
editText.applySpan(StyleSpan(Typeface.BOLD)) editText.applySpan(createBoldSpan())
} else { } else {
editText.clearFormatting(type = StylableEditTextWithHistory.TextStyleType.BOLD) editText.clearFormatting(type = StylableEditTextWithHistory.TextStyleType.BOLD)
} }

View file

@ -8,10 +8,12 @@ import android.view.ViewGroup
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat 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.BottomSheetBehavior.STATE_HIDDEN
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.philkes.notallyx.R
import com.philkes.notallyx.databinding.BottomSheetActionBinding import com.philkes.notallyx.databinding.BottomSheetActionBinding
import com.philkes.notallyx.presentation.dp import com.philkes.notallyx.presentation.dp
import com.philkes.notallyx.presentation.getColorFromAttr import com.philkes.notallyx.presentation.getColorFromAttr
@ -25,41 +27,52 @@ open class ActionBottomSheet(
@ColorInt private val color: Int? = null, @ColorInt private val color: Int? = null,
) : BottomSheetDialogFragment() { ) : BottomSheetDialogFragment() {
lateinit var layout: LinearLayout
lateinit var inflater: LayoutInflater
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle?, savedInstanceState: Bundle?,
): View { ): 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 { LinearLayout(context).apply {
layoutParams = layoutParams =
LinearLayout.LayoutParams( LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT,
) )
orientation = LinearLayout.VERTICAL 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 -> actions.forEach { action ->
if (action.showDividerAbove) { if (action.showDividerAbove) {
val divider = val divider =
View(context).apply { View(context).apply {
layoutParams = layoutParams =
LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 1) LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 1)
.apply { .apply { setMargins(8.dp, 0, 8.dp, 8.dp) }
setMargins(8.dp(context), 0, 8.dp(context), 8.dp(context))
}
setBackgroundColor( setBackgroundColor(
context.getColorFromAttr( context.getColorFromAttr(
com.google.android.material.R.attr.colorOnSurfaceVariant com.google.android.material.R.attr.colorOnSurfaceVariant
) )
) )
} }
view.addView(divider) layout.addView(divider)
} }
val textView = val textView =
BottomSheetActionBinding.inflate(inflater, view, false).root.apply { BottomSheetActionBinding.inflate(inflater, layout, false).root.apply {
text = getString(action.labelResId) text = getString(action.labelResId)
setCompoundDrawablesWithIntrinsicBounds( setCompoundDrawablesWithIntrinsicBounds(
ContextCompat.getDrawable(context, action.drawableResId), ContextCompat.getDrawable(context, action.drawableResId),
@ -68,34 +81,62 @@ open class ActionBottomSheet(
null, null,
) )
setOnClickListener { setOnClickListener {
action.callback() if (action.callback(this@ActionBottomSheet)) {
hide() dismiss()
}
} }
} }
view.addView(textView) layout.addView(textView)
color?.let {
view.apply {
setBackgroundColor(it)
setControlsContrastColorForAllViews(it, overwriteBackground = false)
}
}
} }
return view color?.let {
layout.apply {
setBackgroundColor(it)
setControlsContrastColorForAllViews(it, overwriteBackground = false)
}
}
return scrollView
} }
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { 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 { color?.let {
dialog.window?.apply { dialog.window?.apply {
navigationBarColor = it navigationBarColor = it
setLightStatusAndNavBar(it.isLightColor()) 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 return dialog
} }
private fun BottomSheetDialogFragment.hide() { fun hide() {
(dialog as? BottomSheetDialog)?.behavior?.state = STATE_HIDDEN (dialog as? BottomSheetDialog)?.behavior?.state = STATE_HIDDEN
} }
} }
@ -104,5 +145,10 @@ data class Action(
val labelResId: Int, val labelResId: Int,
val drawableResId: Int, val drawableResId: Int,
val showDividerAbove: Boolean = false, 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) = fun createActions(callbacks: AddActions) =
listOf( listOf(
Action(R.string.add_images, R.drawable.add_images) { callbacks.addImages() }, Action(R.string.add_images, R.drawable.add_images) { _ ->
Action(R.string.attach_file, R.drawable.text_file) { callbacks.attachFiles() }, callbacks.addImages()
true
},
Action(R.string.attach_file, R.drawable.text_file) { _ ->
callbacks.attachFiles()
true
},
) + ) +
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
listOf( listOf(
Action(R.string.record_audio, R.drawable.record_audio) { Action(R.string.record_audio, R.drawable.record_audio) { _ ->
callbacks.recordAudio() callbacks.recordAudio()
true
} }
) )
else listOf() else listOf()

View file

@ -12,7 +12,12 @@ class AddNoteBottomSheet(callbacks: AddNoteActions, @ColorInt color: Int?) :
fun createActions(callbacks: AddNoteActions) = fun createActions(callbacks: AddNoteActions) =
AddBottomSheet.createActions(callbacks) + 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.string.delete_checked_items,
R.drawable.delete_all, R.drawable.delete_all,
showDividerAbove = true, showDividerAbove = true,
) { ) { _ ->
callbacks.deleteChecked() 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() 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() callbacks.uncheckAll()
true
}, },
) )
} }

View file

@ -2,6 +2,8 @@ package com.philkes.notallyx.presentation.view.note.action
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import com.philkes.notallyx.R 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. */ /** BottomSheet inside list-note for all common note actions. */
class MoreNoteBottomSheet( class MoreNoteBottomSheet(
@ -15,12 +17,38 @@ class MoreNoteBottomSheet(
internal fun createActions(callbacks: MoreActions, additionalActions: Collection<Action>) = internal fun createActions(callbacks: MoreActions, additionalActions: Collection<Action>) =
listOf( listOf(
Action(R.string.share, R.drawable.share) { callbacks.share() }, Action(R.string.share, R.drawable.share) { _ ->
Action(R.string.change_color, R.drawable.change_color) { callbacks.changeColor() }, callbacks.share()
Action(R.string.reminders, R.drawable.notifications) { true
callbacks.changeReminders() },
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 ) + additionalActions
} }
} }
@ -28,6 +56,8 @@ class MoreNoteBottomSheet(
interface MoreActions { interface MoreActions {
fun share() fun share()
fun export(mimeType: ExportMimeType)
fun changeColor() fun changeColor()
fun changeReminders() 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 import com.philkes.notallyx.data.model.ListItem
/** ItemTouchHelper.Callback that allows dragging ListItem with its children. */ /** 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() { ItemTouchHelper.Callback() {
private var lastState = ItemTouchHelper.ACTION_STATE_IDLE private var lastState = ItemTouchHelper.ACTION_STATE_IDLE
private var lastIsCurrentlyActive = false private var lastIsCurrentlyActive = false
private var childViewHolders: List<ViewHolder> = mutableListOf() private var childViewHolders: List<ViewHolder> = mutableListOf()
private var draggedItem: ListItem? = null private var stateBefore: ListState? = null
private var itemsBefore: List<ListItem>? = null
private var positionFrom: Int? = null private var positionFrom: Int? = null
private var parentBefore: ListItem? = null
private var itemCount: Int? = null
private var positionTo: Int? = null private var positionTo: Int? = null
private var newPosition: Int? = null private var newPosition: Int? = null
override fun isLongPressDragEnabled() = false 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 { internal fun move(from: Int, to: Int): Boolean {
if (positionFrom == null) { if (positionFrom == null) {
draggedItem = listManager.getItem(from).clone() as ListItem positionFrom = from
itemsBefore = listManager.getItems() 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) val (positionTo, itemCount) = listManager.move(from, to)
if (swapped != null) { if (positionTo != -1) {
if (positionFrom == null) { this.itemCount = itemCount
positionFrom = from this.positionTo = positionTo
}
positionTo = to
newPosition = swapped
} }
return swapped != null return positionTo != -1
} }
override fun onSelectedChanged(viewHolder: ViewHolder?, actionState: Int) { override fun onSelectedChanged(viewHolder: ViewHolder?, actionState: Int) {
if ( if (lastState != actionState && actionState == ItemTouchHelper.ACTION_STATE_IDLE) {
lastState != actionState &&
actionState == ItemTouchHelper.ACTION_STATE_IDLE &&
positionTo != -1
) {
onDragEnd() onDragEnd()
} }
lastState = actionState lastState = actionState
@ -98,8 +95,7 @@ class ListItemDragCallback(private val elevation: Float, private val listManager
childViewHolders.forEach { animateFadeIn(it) } childViewHolders.forEach { animateFadeIn(it) }
} }
internal fun onDragStart(viewHolder: ViewHolder, recyclerView: RecyclerView) { private fun onDragStart(viewHolder: ViewHolder, recyclerView: RecyclerView) {
Log.d(TAG, "onDragStart")
reset() reset()
if (viewHolder.absoluteAdapterPosition == -1) { if (viewHolder.absoluteAdapterPosition == -1) {
return 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() { internal fun reset() {
positionFrom = null positionFrom = null
positionTo = null positionTo = null
newPosition = null newPosition = null
draggedItem = null stateBefore = 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,
)
}
} }
private fun animateFadeOut(viewHolder: ViewHolder) { private fun animateFadeOut(viewHolder: ViewHolder) {
@ -152,6 +142,6 @@ class ListItemDragCallback(private val elevation: Float, private val listManager
} }
companion object { 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 package com.philkes.notallyx.presentation.view.note.listitem
import android.text.TextWatcher
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.EditText import android.widget.EditText
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.data.model.ListItem 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.data.model.plus
import com.philkes.notallyx.presentation.view.note.listitem.sorting.ListItemSortedList import com.philkes.notallyx.data.model.shouldParentBeChecked
import com.philkes.notallyx.presentation.view.note.listitem.sorting.cloneList import com.philkes.notallyx.data.model.shouldParentBeUnchecked
import com.philkes.notallyx.presentation.view.note.listitem.sorting.deleteItem import com.philkes.notallyx.presentation.view.note.listitem.adapter.CheckedListItemAdapter
import com.philkes.notallyx.presentation.view.note.listitem.sorting.filter import com.philkes.notallyx.presentation.view.note.listitem.adapter.ListItemAdapter
import com.philkes.notallyx.presentation.view.note.listitem.sorting.findById import com.philkes.notallyx.presentation.view.note.listitem.adapter.ListItemVH
import com.philkes.notallyx.presentation.view.note.listitem.sorting.findParent import com.philkes.notallyx.presentation.view.note.listitem.sorting.SortedItemsList
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.presentation.viewmodel.preference.NotallyXPreferences 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.ChangeCheckedForAllChange
import com.philkes.notallyx.utils.changehistory.ChangeHistory import com.philkes.notallyx.utils.changehistory.ChangeHistory
import com.philkes.notallyx.utils.changehistory.DeleteCheckedChange import com.philkes.notallyx.utils.changehistory.DeleteCheckedChange
import com.philkes.notallyx.utils.changehistory.EditTextState import com.philkes.notallyx.utils.changehistory.EditTextState
import com.philkes.notallyx.utils.changehistory.ListAddChange 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.ListCheckedChange
import com.philkes.notallyx.utils.changehistory.ListDeleteChange import com.philkes.notallyx.utils.changehistory.ListDeleteChange
import com.philkes.notallyx.utils.changehistory.ListEditTextChange import com.philkes.notallyx.utils.changehistory.ListEditTextChange
import com.philkes.notallyx.utils.changehistory.ListIsChildChange import com.philkes.notallyx.utils.changehistory.ListIsChildChange
import com.philkes.notallyx.utils.changehistory.ListMoveChange 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 * 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)?, private val endSearch: (() -> Unit)?,
val refreshSearch: ((refocusView: View?) -> Unit)?, val refreshSearch: ((refocusView: View?) -> Unit)?,
) { ) {
lateinit var adapter: ListItemAdapter
var checkedAdapter: CheckedListItemAdapter? = null
private var nextItemId: Int = 0 private var nextItemId: Int = 0
private lateinit var items: ListItemSortedList private val items: MutableList<ListItem>
internal lateinit var adapter: RecyclerView.Adapter<ListItemVH> 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( fun add(
position: Int = items.size(), position: Int = items.size,
item: ListItem = defaultNewItem(position), item: ListItem = defaultNewItem(position.coerceAtMost(items.size)),
pushChange: Boolean = true, pushChange: Boolean = true,
) { ) {
endSearch?.invoke() val stateBefore = getState()
(item + item.children).forEach { setIdIfUnset(it) } (item + item.children).forEach { setIdIfUnset(it) }
val itemBeforeInsert = item.clone() as ListItem
items.beginBatchedUpdates() val insertOrder =
for ((idx, newItem) in (item + item.children).withIndex()) { if (position < 1) {
addItem(position + idx, newItem) 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) { 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 { recyclerView.post {
val viewHolder = val viewHolder = recyclerView.findViewHolderForAdapterPosition(insertPos) as ListItemVH?
recyclerView.findViewHolderForAdapterPosition(positionAfterAdd) as ListItemVH?
if (!item.checked && viewHolder != null) { if (!item.checked && viewHolder != null) {
inputMethodManager?.let { viewHolder.focusEditText(inputMethodManager = it) } inputMethodManager?.let { viewHolder.focusEditText(inputMethodManager = it) }
} }
@ -93,228 +164,192 @@ class ListManager(
*/ */
fun delete( fun delete(
position: Int = items.lastIndex, position: Int = items.lastIndex,
inCheckedList: Boolean = false,
force: Boolean = true, force: Boolean = true,
childrenToDelete: List<ListItem>? = null,
pushChange: Boolean = true, pushChange: Boolean = true,
allowFocusChange: Boolean = true, allowFocusChange: Boolean = true,
): ListItem? { ): Boolean {
endSearch?.invoke() // TODO
if (position < 0 || position > items.lastIndex) { // endSearch?.invoke()
return null 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) { 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 (!force && allowFocusChange) {
if (position > 0) { if (position > 0) {
this.moveFocusToNext(position - 2) this.moveFocusToNext(position - 2)
} else if (items.size() > 1) { } else if (items.size > 1) {
this.moveFocusToNext(position) this.moveFocusToNext(position)
} }
} }
if (item != null && pushChange) { if (pushChange && result) {
changeHistory.push(ListDeleteChange(item.order!!, item, this)) changeHistory.push(ListDeleteChange(stateBefore, getState(), this))
} }
return item return result
} }
fun deleteById( /** @return position of the moved item afterwards and the moved item count. */
itemId: Int, fun move(positionFrom: Int, positionTo: Int): Pair<Int, Int> {
force: Boolean = true, val itemsCheckedBefore = itemsChecked?.toMutableList()?.cloneList()
childrenToDelete: List<ListItem>? = null, val list = items.toMutableList()
pushChange: Boolean = true, val movedItem = list[positionFrom]
allowFocusChange: Boolean = true, // Do not allow to move parent into its own children
): ListItem? { if (
return delete( !movedItem.isChild &&
items.findById(itemId)!!.first, positionTo in (positionFrom..positionFrom + movedItem.children.size)
force, ) {
childrenToDelete, return Pair(-1, -1)
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
} }
val checkChildPosition = if (positionTo < positionFrom) positionTo - 1 else positionTo
val forceIsChild = val itemCount = 1 + movedItem.children.size
when { val isMoveUpwards = positionFrom < positionTo
isDrag -> null
positionTo == 0 && itemFrom.isChild -> false val fromOrder = list[positionFrom].order!!
itemFrom.isChild -> true // if child is moved parent could change val toOrder = list[positionTo].order!!
updateChildren && checkChildPosition.isBeforeChildItemOfOtherParent -> true val insertOrder = if (isMoveUpwards) toOrder - itemCount + 1 else toOrder
else -> null 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 = list.removeFromParent(movedItem)
items.moveItemRange( list.removeWithChildren(movedItem)
positionFrom,
itemFrom.itemCount,
positionTo,
forceIsChild = forceIsChild,
) ?: return null
finishMove( (movedItem + movedItem.children).forEachIndexed { index, item ->
positionFrom, item.order = insertOrder + index
positionTo, }
newPosition, val (insertIdx, count) = list.addWithChildren(movedItem)
itemsBeforeMove, adapter.submitList(list)
updateIsChild = false, return Pair(insertIdx, count)
updateChildren = false,
pushChange,
)
return newPosition
} }
/** Finishes a drag movement by updating [ListItem.isChild] accordingly. */
fun finishMove( fun finishMove(
positionFrom: Int,
positionTo: Int, positionTo: Int,
newPosition: Int, count: Int,
itemsBeforeMove: List<ListItem>, parentBefore: ListItem?,
updateIsChild: Boolean, stateBefore: ListState,
updateChildren: Boolean,
pushChange: Boolean, pushChange: Boolean,
) { ) {
if (updateIsChild) { val item = items[positionTo]
if (newPosition.isBeforeChildItemOfOtherParent) { val itemBelow = items.getOrNull(positionTo + count)
items.setIsChild(newPosition, isChild = true, forceOnChildren = true) val forceIsChild = itemBelow?.isChild == true && !item.isChild
} else if (newPosition == 0) { val positionFrom = stateBefore.items.indexOfFirst { it.id == item.id }
items.setIsChild(newPosition, false) 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 (positionFrom == 0) {
if (updateChildren) { adapter.notifyItemChanged(0)
val forceValue = item.isChild isChildChanged = true
items.forceItemIsChild(item, forceValue, resetBefore = true) }
items.updateItemAt(items.findById(item.id)!!.first, item)
} else if (item.isChild && newPosition > 0) { if (item.isChild) {
items.removeChildFromParent(item) items.refreshParent(positionTo)?.updateParentChecked()
items.updateChildInParent(newPosition, item) }
parentBefore?.updateParentChecked()
if (isChildChanged) {
adapter.notifyItemChanged(positionTo)
} }
if (pushChange) { if (pushChange) {
changeHistory.push(ListMoveChange(positionFrom, itemsBeforeMove, getItems(), this)) changeHistory.push(ListMoveChange(stateBefore, getState(), this))
} }
} }
fun setItems(items: List<ListItem>) { fun changeText(position: Int, value: EditTextState, pushChange: Boolean = true) {
this.items.init(items) val stateBefore = getState()
}
fun changeText(
editText: EditText,
listener: TextWatcher,
position: Int,
value: EditTextState,
before: EditTextState? = null,
pushChange: Boolean = true,
) {
// if(!pushChange) { // if(!pushChange) {
endSearch?.invoke() endSearch?.invoke()
// } // }
val item = items[position] val item = items[position]
item.body = value.text.toString() item.body = value.text.toString()
if (pushChange) { if (pushChange) {
changeHistory.push( changeHistory.push(ListEditTextChange(stateBefore, getState(), this))
ListEditTextChange(editText, position, before!!, value, listener, this)
)
// TODO: fix focus change // TODO: fix focus change
// refreshSearch?.invoke(editText) // refreshSearch?.invoke(editText)
} }
} }
fun changeChecked(position: Int, checked: Boolean, pushChange: Boolean = true) { fun changeChecked(
val before = getItems() position: Int,
val item = items[position] checked: Boolean,
inCheckedList: Boolean = false,
pushChange: Boolean = true,
) {
val beforeState = getState()
val item = getItem(position, inCheckedList)
if (item.checked == checked) { if (item.checked == checked) {
return return
} }
if (item.isChild) { if (item.isChild) {
changeCheckedForChild(checked, item, pushChange, position, before) changeCheckedChild(position, item, checked, inCheckedList)
return } else {
} changeCheckedParent(item, checked, changeChildren = true)
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)
} }
if (pushChange) { if (pushChange) {
changeHistory.push(ListCheckedChange(before, getItems(), this)) changeHistory.push(ListCheckedChange(beforeState, getState(), this))
} }
} }
fun changeCheckedForAll(checked: Boolean, pushChange: Boolean = true) { fun changeCheckedForAll(checked: Boolean, pushChange: Boolean = true) {
val parentIds = mutableListOf<Int>() val stateBefore = getState()
val changedIds = mutableListOf<Int>() val parents =
items items.findParentsByChecked(!checked) +
.reversed() // have to start from the bottom upwards, otherwise sort order will be wrong (itemsChecked?.findParentsByChecked(!checked) ?: listOf())
.forEach { item -> parents.forEach { parent -> changeCheckedParent(parent, checked, true) }
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)
}
if (pushChange) { 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) { 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) { 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) { fun deleteCheckedItems(pushChange: Boolean = true) {
endSearch?.invoke() endSearch?.invoke()
val itemsToDelete = val stateBefore = getState()
items.filter { it.checked }.map { it.clone() as ListItem }.sortedBy { it.isChild } items.deleteCheckedItems().forEach { adapter.notifyItemRemoved(it) }
items.beginBatchedUpdates() itemsChecked?.deleteCheckedItems()
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()
if (pushChange) { if (pushChange) {
changeHistory.push(DeleteCheckedChange(deletedItems, this)) changeHistory.push(DeleteCheckedChange(stateBefore, getState(), this))
} }
} }
fun initList(items: ListItemSortedList) { fun findParent(item: ListItem) = items.findParent(item) ?: itemsChecked?.findParent(item)
this.items = items
nextItemId = this.items.size() internal fun startBatchChange(cursorPos: Int? = null) {
Log.d(TAG, "initList:\n${this.items.toReadableString()}") batchChangeBeforeState = getState()
cursorPos?.let { batchChangeBeforeState = batchChangeBeforeState!!.copy(cursorPos = it) }
} }
internal fun getItem(position: Int): ListItem { internal fun finishBatchChange(focusedItemPos: Int? = null) {
return items[position] 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) = internal fun defaultNewItem(position: Int) =
ListItem( ListItem(
"", "",
false, false,
items.isNotEmpty() && items.isNotEmpty() &&
((position < items.size() && items[position].isChild) || ((position < items.size && items[position].isChild) ||
(position > 0 && items[position - 1].isChild)), (position > 0 && items[position - 1].isChild)),
null, null,
mutableListOf(), mutableListOf(),
nextItemId++, nextItemId++,
) )
private fun check( private fun changeCheckedParent(
parent: ListItem,
checked: Boolean, checked: Boolean,
positions: Collection<Int>, changeChildren: Boolean,
recalcChildrenPositions: Boolean = false, items: MutableList<ListItem> = this@ListManager.items,
): Pair<List<Int>, List<Int>> { ) {
return items.setChecked(positions, checked, recalcChildrenPositions) 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) { private fun changeCheckedChild(
setIdIfUnset(newItem) position: Int,
items.shiftItemOrders(position until items.size(), 1) child: ListItem,
newItem.order = position checked: Boolean,
val forceIsChild = inCheckedList: Boolean,
when { ) {
position == 0 -> false if (checked) {
(position - 1).isBeforeChildItemOfOtherParent -> true child.checked = true
newItem.isChild && items.findParent(newItem) == null -> true adapter.notifyItemChanged(position)
else -> null 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) { private fun setIdIfUnset(newItem: ListItem) {
@ -401,32 +534,36 @@ class ListManager(
} }
} }
private fun isAutoSortByCheckedEnabled() = /** Adds [valueToAdd] to all [ListItem.order] that are higher than [threshold] */
preferences.listItemSorting.value == ListItemSort.AUTO_SORT_BY_CHECKED private fun shiftItemOrdersHigher(
threshold: Int,
private val Int.isBeforeChildItemOfOtherParent: Boolean valueToAdd: Int,
get() { items: List<ListItem> = this.items,
if (this < 0) { ) {
return false items.shiftItemOrdersHigher(threshold, valueToAdd)
} itemsChecked?.shiftItemOrdersHigher(threshold, valueToAdd)
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)
} }
private fun ListItem.isChildOf(otherPosition: Int): Boolean { /** Adds [valueToAdd] to all [ListItem.order] that are in [orderRange] */
return isChildOf(items[otherPosition]) 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 { 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.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.ColorInt 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 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.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.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.TextSize 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, @ColorInt var backgroundColor: Int,
private val textSize: TextSize, private val textSize: TextSize,
elevation: Float, elevation: Float,
private val preferences: NotallyXPreferences, private val preferences: NotallyXPreferences,
private val listManager: ListManager, 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 callback = ListItemDragCallback(elevation, listManager)
private val touchHelper = ItemTouchHelper(callback) private val touchHelper = NestedScrollViewItemTouchHelper(callback, scrollView)
private val highlights = mutableMapOf<Int, MutableList<ListItemHighlight>>() private val highlights = mutableMapOf<Int, MutableList<ListItemHighlight>>()
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
touchHelper.attachToRecyclerView(recyclerView) touchHelper.attachToRecyclerView(recyclerView)
} }
override fun getItemCount() = list.size() fun onBindViewHolder(holder: ListItemVH, position: Int, viewMode: NoteViewMode) {
val item = getItem(position)
override fun onBindViewHolder(holder: ListItemVH, position: Int) {
val item = list[position]
holder.bind( holder.bind(
backgroundColor, backgroundColor,
item, item,
position, position,
highlights.get(position), highlights[position],
preferences.listItemSorting.value, 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 inflater = LayoutInflater.from(parent.context)
val binding = RecyclerListItemBinding.inflate(inflater, parent, false) val binding = RecyclerListItemBinding.inflate(inflater, parent, false)
binding.root.background = parent.background binding.root.background = parent.background
return ListItemVH(binding, listManager, touchHelper, textSize) return ListItemVH(binding, listManager, touchHelper, textSize, isCheckedListAdapter)
} }
internal fun setBackgroundColor(@ColorInt color: Int) { internal fun setBackgroundColor(@ColorInt color: Int) {
backgroundColor = color backgroundColor = color
notifyDataSetChanged() adapter.notifyDataSetChanged()
}
internal fun setList(list: ListItemSortedList) {
this.list = list
} }
internal fun clearHighlights(): Set<Int> { internal fun clearHighlights(): Set<Int> {
val highlightedItemPos = val highlightedItemPos =
highlights.entries.flatMap { (_, value) -> value.map { it.itemPos } }.toSet() highlights.entries.flatMap { (_, value) -> value.map { it.itemPos } }.toSet()
highlights.clear() highlights.clear()
highlightedItemPos.forEach { adapter.notifyItemChanged(it) }
return highlightedItemPos return highlightedItemPos
// itemPos.forEach { notifyItemChanged(it) }
} }
internal fun highlightText(highlight: ListItemHighlight) { fun highlightText(highlight: ListItemHighlight) {
if (highlights.containsKey(highlight.itemPos)) { if (highlights.containsKey(highlight.itemPos)) {
highlights[highlight.itemPos]!!.add(highlight) highlights[highlight.itemPos]!!.add(highlight)
} else { } else {
highlights[highlight.itemPos] = mutableListOf(highlight) highlights[highlight.itemPos] = mutableListOf(highlight)
} }
notifyItemChanged(highlight.itemPos) adapter.notifyItemChanged(highlight.itemPos)
} }
internal fun selectHighlight(pos: Int): Int { internal fun selectHighlight(pos: Int): Int {
@ -81,7 +91,7 @@ class ListItemAdapter(
val isSelected = it.selected val isSelected = it.selected
it.selected = it.resultPos == pos it.selected = it.resultPos == pos
if (isSelected != it.selected) { if (isSelected != it.selected) {
notifyItemChanged(it.itemPos) adapter.notifyItemChanged(it.itemPos)
} }
if (it.selected) { if (it.selected) {
selectedItemPos = it.itemPos selectedItemPos = it.itemPos
@ -90,12 +100,4 @@ class ListItemAdapter(
} }
return selectedItemPos 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.util.TypedValue
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View.GONE
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.CompoundButton.OnCheckedChangeListener import android.widget.CompoundButton.OnCheckedChangeListener
import android.widget.EditText
import android.widget.TextView.INVISIBLE import android.widget.TextView.INVISIBLE
import android.widget.TextView.VISIBLE import android.widget.TextView.VISIBLE
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_POSITION
import cn.leaqi.drawer.SwipeDrawer.DIRECTION_LEFT import cn.leaqi.drawer.SwipeDrawer.DIRECTION_LEFT
import cn.leaqi.drawer.SwipeDrawer.STATE_CLOSE import cn.leaqi.drawer.SwipeDrawer.STATE_CLOSE
import cn.leaqi.drawer.SwipeDrawer.STATE_OPEN import cn.leaqi.drawer.SwipeDrawer.STATE_OPEN
import com.philkes.notallyx.data.imports.txt.extractListItems import com.philkes.notallyx.data.imports.txt.extractListItems
import com.philkes.notallyx.data.imports.txt.findListSyntaxRegex import com.philkes.notallyx.data.imports.txt.findListSyntaxRegex
import com.philkes.notallyx.data.model.ListItem import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.databinding.RecyclerListItemBinding import com.philkes.notallyx.databinding.RecyclerListItemBinding
import com.philkes.notallyx.presentation.clone
import com.philkes.notallyx.presentation.createListTextWatcherWithHistory import com.philkes.notallyx.presentation.createListTextWatcherWithHistory
import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews
import com.philkes.notallyx.presentation.setOnNextAction import com.philkes.notallyx.presentation.setOnNextAction
import com.philkes.notallyx.presentation.view.misc.EditTextAutoClearFocus 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.ListItemSort
import com.philkes.notallyx.presentation.viewmodel.preference.TextSize import com.philkes.notallyx.presentation.viewmodel.preference.TextSize
import com.philkes.notallyx.utils.changehistory.EditTextState
import com.philkes.notallyx.utils.copyToClipBoard
class ListItemVH( class ListItemVH(
val binding: RecyclerListItemBinding, val binding: RecyclerListItemBinding,
val listManager: ListManager, val listManager: ListManager,
touchHelper: ItemTouchHelper, touchHelper: ItemTouchHelper,
textSize: TextSize, textSize: TextSize,
private val inCheckedList: Boolean,
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
private var dragHandleInitialY: Float = 0f private var dragHandleInitialY: Float = 0f
@ -38,11 +50,6 @@ class ListItemVH(
binding.EditText.apply { binding.EditText.apply {
setTextSize(TypedValue.COMPLEX_UNIT_SP, body) setTextSize(TypedValue.COMPLEX_UNIT_SP, body)
setOnNextAction {
val position = absoluteAdapterPosition + 1
listManager.add(position)
}
textWatcher = textWatcher =
createListTextWatcherWithHistory( createListTextWatcherWithHistory(
listManager, listManager,
@ -54,10 +61,6 @@ class ListItemVH(
false false
} }
} }
setOnFocusChangeListener { _, hasFocus ->
binding.Delete.visibility = if (hasFocus) VISIBLE else INVISIBLE
}
} }
binding.DragHandle.setOnTouchListener { _, event -> binding.DragHandle.setOnTouchListener { _, event ->
@ -86,22 +89,23 @@ class ListItemVH(
@ColorInt backgroundColor: Int, @ColorInt backgroundColor: Int,
item: ListItem, item: ListItem,
position: Int, position: Int,
highlights: List<ListItemAdapter.ListItemHighlight>?, highlights: List<ListItemHighlight>?,
autoSort: ListItemSort, autoSort: ListItemSort,
viewMode: NoteViewMode,
) { ) {
updateEditText(item, position) updateEditText(item, position, viewMode)
updateCheckBox(item, position) 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 { binding.DragHandle.apply {
visibility = visibility =
if (item.checked && autoSort == ListItemSort.AUTO_SORT_BY_CHECKED) { when {
INVISIBLE viewMode != NoteViewMode.EDIT -> GONE
} else { item.checked && autoSort == ListItemSort.AUTO_SORT_BY_CHECKED -> INVISIBLE
VISIBLE else -> VISIBLE
} }
contentDescription = "Drag$position" contentDescription = "Drag$position"
} }
@ -123,18 +127,65 @@ class ListItemVH(
binding.EditText.focusAndSelect(selectionStart, selectionEnd, inputMethodManager) 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 { binding.Delete.apply {
visibility = if (item.checked) VISIBLE else INVISIBLE visibility =
setOnClickListener { listManager.delete(absoluteAdapterPosition) } when {
viewMode != NoteViewMode.EDIT -> GONE
item.checked -> VISIBLE
else -> INVISIBLE
}
setOnClickListener {
listManager.delete(absoluteAdapterPosition, inCheckedList = inCheckedList)
}
contentDescription = "Delete$position" contentDescription = "Delete$position"
} }
} }
private fun updateEditText(item: ListItem, position: Int) { private fun updateEditText(item: ListItem, position: Int, viewMode: NoteViewMode) {
binding.EditText.apply { binding.EditText.apply {
setText(item.body) 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 -> setOnKeyListener { _, keyCode, event ->
if ( if (
event.action == KeyEvent.ACTION_DOWN && 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 // TODO: when there are multiple checked items above it does not jump to the
// last // last
// unchecked item but always re-adds a new item // unchecked item but always re-adds a new item
listManager.delete(absoluteAdapterPosition, false) != null listManager.delete(
absoluteAdapterPosition,
inCheckedList = inCheckedList,
force = false,
)
} else { } else {
false false
} }
} }
contentDescription = "EditText$position"
} }
} }
@ -157,10 +211,12 @@ class ListItemVH(
private fun updateCheckBox(item: ListItem, position: Int) { private fun updateCheckBox(item: ListItem, position: Int) {
if (checkBoxListener == null) { if (checkBoxListener == null) {
checkBoxListener = OnCheckedChangeListener { buttonView, isChecked -> checkBoxListener = OnCheckedChangeListener { _, isChecked ->
buttonView!!.setOnCheckedChangeListener(null) binding.CheckBox.setOnCheckedChangeListener(null)
listManager.changeChecked(absoluteAdapterPosition, isChecked) if (absoluteAdapterPosition != NO_POSITION) {
buttonView.setOnCheckedChangeListener(checkBoxListener) listManager.changeChecked(absoluteAdapterPosition, isChecked, inCheckedList)
}
binding.CheckBox.setOnCheckedChangeListener(checkBoxListener)
} }
} }
binding.CheckBox.apply { binding.CheckBox.apply {
@ -197,20 +253,31 @@ class ListItemVH(
.findListSyntaxRegex(checkContains = true, plainNewLineAllowed = true) .findListSyntaxRegex(checkContains = true, plainNewLineAllowed = true)
?.let { listSyntaxRegex -> ?.let { listSyntaxRegex ->
val items = changedText.extractListItems(listSyntaxRegex) val items = changedText.extractListItems(listSyntaxRegex)
if (text.trim().length > count) { if (items.isNotEmpty()) {
editText.setText(text.substring(0, start) + text.substring(start + count)) listManager.startBatchChange(start)
} else { val position = absoluteAdapterPosition
listManager.delete(absoluteAdapterPosition, pushChange = false) val itemHadTextBefore = text.trim().length > count
} val firstPastedItemBody = items.firstBodyOrEmptyString()
items.forEachIndexed { idx, it -> val updatedText =
listManager.add(absoluteAdapterPosition + idx + 1, it, pushChange = true) 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 return containsLines
} }
fun getSelection(): Pair<Int, Int> { private fun EditText.changeText(position: Int, after: CharSequence) {
return Pair(binding.EditText.selectionStart, binding.EditText.selectionEnd) 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.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.widget.Toast import android.os.Build
import androidx.annotation.RequiresApi
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.room.withTransaction import androidx.room.withTransaction
import com.google.android.material.dialog.MaterialAlertDialogBuilder 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.Attachment
import com.philkes.notallyx.data.model.Audio import com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.data.model.BaseNote 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.Content
import com.philkes.notallyx.data.model.Converters import com.philkes.notallyx.data.model.Converters
import com.philkes.notallyx.data.model.FileAttachment 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.Label
import com.philkes.notallyx.data.model.SearchResult import com.philkes.notallyx.data.model.SearchResult
import com.philkes.notallyx.data.model.toNoteIdReminders 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.getQuantityString
import com.philkes.notallyx.presentation.restartApplication
import com.philkes.notallyx.presentation.setCancelButton import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.showToast import com.philkes.notallyx.presentation.showToast
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
import com.philkes.notallyx.presentation.view.misc.Progress import com.philkes.notallyx.presentation.view.misc.Progress
import com.philkes.notallyx.presentation.viewmodel.preference.BasePreference 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
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.EMPTY_PATH 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.ActionMode
import com.philkes.notallyx.utils.Cache import com.philkes.notallyx.utils.Cache
import com.philkes.notallyx.utils.MIME_TYPE_JSON 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.getExternalImagesDirectory
import com.philkes.notallyx.utils.log import com.philkes.notallyx.utils.log
import com.philkes.notallyx.utils.scheduleNoteReminders 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 java.util.concurrent.atomic.AtomicInteger
import javax.crypto.Cipher
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -91,11 +102,13 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
val folder = NotNullLiveData(Folder.NOTES) val folder = NotNullLiveData(Folder.NOTES)
var currentLabel: String? = CURRENT_LABEL_EMPTY
var keyword = String() var keyword = String()
set(value) { set(value) {
if (field != value || searchResults?.value?.isEmpty() == true) { if (field != value || searchResults?.value?.isEmpty() == true) {
field = value 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 pinned = Header(app.getString(R.string.pinned))
private val others = Header(app.getString(R.string.others)) private val others = Header(app.getString(R.string.others))
private val archived = Header(app.getString(R.string.archived))
val preferences = NotallyXPreferences.getInstance(app) val preferences = NotallyXPreferences.getInstance(app)
@ -114,9 +128,14 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
val actionMode = ActionMode() val actionMode = ActionMode()
internal var showRefreshBackupsFolderAfterThemeChange = false
private var labelsHiddenObserver: Observer<Set<String>>? = null
init { init {
NotallyDatabase.getDatabase(app).observeForever(::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) { private fun init(database: NotallyDatabase) {
@ -126,6 +145,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
commonDao = database.getCommonDao() commonDao = database.getCommonDao()
labels = labelDao.getAll() labels = labelDao.getAll()
// colors = baseNoteDao.getAllColorsAsync()
reminders = baseNoteDao.getAllRemindersAsync() reminders = baseNoteDao.getAllRemindersAsync()
allNotes?.removeObserver(allNotesObserver!!) allNotes?.removeObserver(allNotesObserver!!)
@ -133,11 +153,12 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
allNotes = baseNoteDao.getAllAsync() allNotes = baseNoteDao.getAllAsync()
allNotes!!.observeForever(allNotesObserver!!) allNotes!!.observeForever(allNotesObserver!!)
if (baseNotes == null) { labelsHiddenObserver?.let { preferences.labelsHidden.removeObserver(it) }
baseNotes = Content(baseNoteDao.getFrom(Folder.NOTES), ::transform) labelsHiddenObserver = Observer { labelsHidden ->
} else { baseNotes = null
baseNotes!!.setObserver(baseNoteDao.getFrom(Folder.NOTES)) initBaseNotes(labelsHidden)
} }
preferences.labelsHidden.observeForever(labelsHiddenObserver!!)
if (deletedNotes == null) { if (deletedNotes == null) {
deletedNotes = Content(baseNoteDao.getFrom(Folder.DELETED), ::transform) 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 { fun getNotesByLabel(label: String): Content {
if (labelCache[label] == null) { if (labelCache[label] == null) {
labelCache[label] = Content(baseNoteDao.getBaseNotesByLabel(label), ::transform) labelCache[label] = Content(baseNoteDao.getBaseNotesByLabel(label), ::transform)
@ -178,7 +211,11 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
return requireNotNull(labelCache[label]) 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() { fun disableBackups() {
val value = preferences.backupsFolder.value val value = preferences.backupsFolder.value
@ -204,39 +241,67 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
} }
savePreference(preferences.backupsFolder, newBackupsFolder) savePreference(preferences.backupsFolder, newBackupsFolder)
} }
showRefreshBackupsFolderAfterThemeChange = false
} }
fun enableDataInPublic() { fun enableDataInPublic(callback: (() -> Unit)? = null) {
viewModelScope.launch { viewModelScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val database = NotallyDatabase.getDatabase(app, observePreferences = false).value val database = NotallyDatabase.getDatabase(app, observePreferences = false).value
database.checkpoint() database.checkpoint()
NotallyDatabase.getInternalDatabaseFile(app) val directory = NotallyDatabase.getExternalDatabaseFile(app).parentFile
.copyTo(NotallyDatabase.getExternalDatabaseFile(app), overwrite = true) NotallyDatabase.getInternalDatabaseFiles(app).forEach {
it.copyTo(File(directory, it.name), overwrite = true)
}
// database.close()
} }
savePreference(preferences.dataInPublicFolder, true) savePreference(preferences.dataInPublicFolder, true)
callback?.invoke()
} }
} }
fun disableDataInPublic() { fun disableDataInPublic(callback: (() -> Unit)? = null) {
viewModelScope.launch { viewModelScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val database = NotallyDatabase.getDatabase(app, observePreferences = false).value val database = NotallyDatabase.getDatabase(app, observePreferences = false).value
database.checkpoint() database.checkpoint()
NotallyDatabase.getExternalDatabaseFile(app) val directory = NotallyDatabase.getInternalDatabaseFile(app).parentFile
.copyTo(NotallyDatabase.getInternalDatabaseFile(app), overwrite = true) val oldFiles = NotallyDatabase.getExternalDatabaseFiles(app)
NotallyDatabase.getExternalDatabaseFiles(app).forEach { oldFiles.forEach { it.copyTo(File(directory, it.name), overwrite = true) }
// database.close()
oldFiles.forEach {
if (it.exists()) { if (it.exists()) {
it.delete() it.delete()
} }
} }
} }
savePreference(preferences.dataInPublicFolder, false) 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) { 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) { fun importZipBackup(uri: Uri, password: String) {
val exceptionHandler = CoroutineExceptionHandler { _, throwable -> val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
app.log(TAG, throwable = 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() val backupDir = app.getBackupDir()
@ -282,7 +347,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
fun importXmlBackup(uri: Uri) { fun importXmlBackup(uri: Uri) {
val exceptionHandler = CoroutineExceptionHandler { _, throwable -> val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
app.log(TAG, throwable = 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) { viewModelScope.launch(exceptionHandler) {
@ -299,19 +364,16 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
} }
fun importFromOtherApp(uri: Uri, importSource: ImportSource) { fun importFromOtherApp(uri: Uri, importSource: ImportSource) {
val database = NotallyDatabase.getDatabase(app).value val database = NotallyDatabase.getDatabase(app, observePreferences = false).value
val exceptionHandler = CoroutineExceptionHandler { _, throwable -> 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) 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) { viewModelScope.launch(exceptionHandler) {
val importedNotes = val importedNotes =
withContext(Dispatchers.IO) { 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 -> val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
app.log(TAG, throwable = throwable) app.log(TAG, throwable = throwable)
actionMode.close(true) actionMode.close(true)
@ -341,7 +403,6 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
app.showToast(R.string.something_went_wrong) app.showToast(R.string.something_went_wrong)
} }
viewModelScope.launch(exceptionHandler) { viewModelScope.launch(exceptionHandler) {
val notes = actionMode.selectedNotes.values
val counter = AtomicInteger(0) val counter = AtomicInteger(0)
for (note in notes) { for (note in notes) {
exportProgress.postValue(Progress(total = notes.size)) 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) { fun pinBaseNotes(pinned: Boolean) {
val id = actionMode.selectedIds.toLongArray() val id = actionMode.selectedIds.toLongArray()
actionMode.close(true) 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() val ids = actionMode.selectedIds.toLongArray()
actionMode.close(true) 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 { fun moveBaseNotes(folder: Folder): LongArray {
@ -398,7 +467,9 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
} }
fun moveBaseNotes(ids: LongArray, folder: Folder) { fun moveBaseNotes(ids: LongArray, folder: Folder) {
executeAsync { viewModelScope.launch(
Dispatchers.IO
) { // Only reminders of notes in NOTES folder are active
baseNoteDao.move(ids, folder) baseNoteDao.move(ids, folder)
val notes = baseNoteDao.getByIds(ids).toNoteIdReminders() val notes = baseNoteDao.getByIds(ids).toNoteIdReminders()
// Only reminders of notes in NOTES folder are active // 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) { fun updateBaseNoteLabels(labels: List<String>, id: Long) {
actionMode.close(true) actionMode.close(true)
executeAsync { baseNoteDao.updateLabels(id, labels) } viewModelScope.launch(Dispatchers.IO) { baseNoteDao.updateLabels(id, labels) }
} }
fun deleteSelectedBaseNotes() { fun deleteSelectedBaseNotes() {
@ -427,6 +498,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
app.cancelNoteReminders(noteReminders) app.cancelNoteReminders(noteReminders)
deleteBaseNotes(ids) deleteBaseNotes(ids)
withContext(Dispatchers.IO) { labelDao.deleteAll() } withContext(Dispatchers.IO) { labelDao.deleteAll() }
savePreference(preferences.startView, START_VIEW_DEFAULT)
app.showToast(R.string.cleared_data) 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() } suspend fun getAllLabels() = withContext(Dispatchers.IO) { labelDao.getArrayOfAll() }
fun deleteLabel(value: String) { fun deleteLabel(value: String) {
executeAsync { commonDao.deleteLabel(value) } viewModelScope.launch(Dispatchers.IO) { commonDao.deleteLabel(value) }
val labelsHiddenPreference = preferences.labelsHiddenInNavigation val labelsHiddenPreference = preferences.labelsHidden
val labelsHidden = labelsHiddenPreference.value.toMutableSet() val labelsHidden = labelsHiddenPreference.value.toMutableSet()
if (labelsHidden.contains(value)) { if (labelsHidden.contains(value)) {
labelsHidden.remove(value) labelsHidden.remove(value)
savePreference(labelsHiddenPreference, labelsHidden) savePreference(labelsHiddenPreference, labelsHidden)
} }
if (preferences.startView.value == value) {
savePreference(preferences.startView, START_VIEW_DEFAULT)
}
} }
fun insertLabel(label: Label, onComplete: (success: Boolean) -> Unit) = 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) { fun updateLabel(oldValue: String, newValue: String, onComplete: (success: Boolean) -> Unit) {
executeAsyncWithCallback({ commonDao.updateLabel(oldValue, newValue) }, onComplete) executeAsyncWithCallback({ commonDao.updateLabel(oldValue, newValue) }, onComplete)
val labelsHiddenPreference = preferences.labelsHiddenInNavigation val labelsHiddenPreference = preferences.labelsHidden
val labelsHidden = labelsHiddenPreference.value.toMutableSet() val labelsHidden = labelsHiddenPreference.value.toMutableSet()
if (labelsHidden.contains(oldValue)) { if (labelsHidden.contains(oldValue)) {
labelsHidden.remove(oldValue) labelsHidden.remove(oldValue)
@ -500,91 +575,200 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
} }
} }
fun closeDatabase() { fun resetPreferences(callback: (restartRequired: Boolean) -> Unit) {
database.close()
}
private fun executeAsync(function: suspend () -> Unit) {
viewModelScope.launch(Dispatchers.IO) { function() }
}
fun resetPreferences() {
val backupsFolder = preferences.backupsFolder.value 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() preferences.reset()
refreshDataInPublicFolder()
if (backupsFolder != EMPTY_PATH) { if (backupsFolder != EMPTY_PATH) {
clearPersistedUriPermissions(backupsFolder) clearPersistedUriPermissions(backupsFolder)
} }
callback()
app.restartApplication(R.id.Settings)
} }
fun importPreferences( fun importPreferences(
context: Context, context: Context,
uri: Uri, uri: Uri,
askForUriPermissions: (uri: Uri) -> Unit, askForUriPermissions: (uri: Uri) -> Unit,
): Boolean { onSuccess: () -> Unit,
onFailure: () -> Unit,
) {
val oldBackupsFolder = preferences.backupsFolder.value 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) val success = preferences.import(context, uri)
refreshDataInPublicFolder()
val backupFolder = preferences.backupsFolder.getFreshValue() val dataInPublicFolder = preferences.dataInPublicFolder.getFreshValue()
if (oldBackupsFolder != backupFolder) { if (dataInPublicFolderBefore != dataInPublicFolder) {
refreshBackupsFolder(context, backupFolder, askForUriPermissions) refreshDataInPublicFolder(dataInPublicFolder) {
} preferences.dataInPublicFolder.refresh()
return success 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, 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, askForUriPermissions: (uri: Uri) -> Unit,
) { ) {
try { try {
val backupFolderUri = backupFolder.toUri() val backupFolderUri = backupFolder.toUri()
MaterialAlertDialogBuilder(context) MaterialAlertDialogBuilder(context)
.setMessage(R.string.auto_backups_folder_rechoose) .setMessage(R.string.auto_backups_folder_rechoose)
.setCancelButton() .setCancelButton { _, _ -> showRefreshBackupsFolderAfterThemeChange = false }
.setOnDismissListener { showRefreshBackupsFolderAfterThemeChange = false }
.setPositiveButton(R.string.choose_folder) { _, _ -> .setPositiveButton(R.string.choose_folder) { _, _ ->
askForUriPermissions(backupFolderUri) askForUriPermissions(backupFolderUri)
} }
.show() .show()
} catch (e: Exception) { } catch (e: Exception) {
showRefreshBackupsFolderAfterThemeChange = false
disableBackups() disableBackups()
} }
} }
private fun refreshDataInPublicFolder() { private fun refreshDataInPublicFolder(dataInPublicFolder: Boolean, callback: () -> Unit) {
val dataInPublicFolderBefore = preferences.dataInPublicFolder.value if (dataInPublicFolder) {
val dataInPublicFolderAfter = preferences.dataInPublicFolder.getFreshValue() enableDataInPublic(callback)
if (dataInPublicFolderBefore != dataInPublicFolderAfter) { } else {
if (dataInPublicFolderAfter) { disableDataInPublic(callback)
enableDataInPublic() }
} else { }
disableDataInPublic()
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 { companion object {
private const val TAG = "BaseNoteModel" 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()) { if (list.isEmpty()) {
return list return list
} else { } else {
val firstNote = list[0] val firstPinnedNote = list.indexOfFirst { baseNote -> baseNote.pinned }
return if (firstNote.pinned) { val firstUnpinnedNote =
val newList = ArrayList<Item>(list.size + 2) list.indexOfFirst { baseNote ->
newList.add(pinned) !baseNote.pinned && baseNote.folder != Folder.ARCHIVED
val firstUnpinnedNote = list.indexOfFirst { baseNote -> !baseNote.pinned }
list.forEachIndexed { index, baseNote ->
if (index == firstUnpinnedNote) {
newList.add(others)
}
newList.add(baseNote)
} }
newList val mutableList: MutableList<Item> = list.toMutableList()
} else list 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.NotallyDatabase
import com.philkes.notallyx.data.dao.BaseNoteDao import com.philkes.notallyx.data.dao.BaseNoteDao
import com.philkes.notallyx.data.dao.NoteIdReminder 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.Audio
import com.philkes.notallyx.data.model.BaseNote 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.FileAttachment
import com.philkes.notallyx.data.model.Folder import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.data.model.ListItem 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.Reminder
import com.philkes.notallyx.data.model.SpanRepresentation import com.philkes.notallyx.data.model.SpanRepresentation
import com.philkes.notallyx.data.model.Type 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.Cache
import com.philkes.notallyx.utils.Event import com.philkes.notallyx.utils.Event
import com.philkes.notallyx.utils.FileError 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.importAudio
import com.philkes.notallyx.utils.backup.importFile import com.philkes.notallyx.utils.backup.importFile
import com.philkes.notallyx.utils.cancelNoteReminders import com.philkes.notallyx.utils.cancelNoteReminders
@ -73,7 +75,7 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
var id = 0L var id = 0L
var folder = Folder.NOTES var folder = Folder.NOTES
var color = Color.DEFAULT var color = BaseNote.COLOR_DEFAULT
var title = String() var title = String()
var pinned = false var pinned = false
@ -85,10 +87,13 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
var body: Editable = SpannableStringBuilder() var body: Editable = SpannableStringBuilder()
val items = ArrayList<ListItem>() val items = ArrayList<ListItem>()
val images = NotNullLiveData<List<FileAttachment>>(emptyList()) val images = NotNullLiveData<List<FileAttachment>>(emptyList())
val files = NotNullLiveData<List<FileAttachment>>(emptyList()) val files = NotNullLiveData<List<FileAttachment>>(emptyList())
val audios = NotNullLiveData<List<Audio>>(emptyList()) val audios = NotNullLiveData<List<Audio>>(emptyList())
val reminders = NotNullLiveData<List<Reminder>>(emptyList()) val reminders = NotNullLiveData<List<Reminder>>(emptyList())
val viewMode = NotNullLiveData(NoteViewMode.EDIT)
val addingFiles = MutableLiveData<Progress>() val addingFiles = MutableLiveData<Progress>()
val eventBus = MutableLiveData<Event<List<FileError>>>() val eventBus = MutableLiveData<Event<List<FileError>>>()
@ -97,7 +102,7 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
var audioRoot = app.getExternalAudioDirectory() var audioRoot = app.getExternalAudioDirectory()
var filesRoot = app.getExternalFilesDirectory() var filesRoot = app.getExternalFilesDirectory()
private lateinit var originalNote: BaseNote var originalNote: BaseNote? = null
init { init {
database.observeForever { baseNoteDao = it.getBaseNoteDao() } database.observeForever { baseNoteDao = it.getBaseNoteDao() }
@ -247,6 +252,7 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
files.value = baseNote.files files.value = baseNote.files
audios.value = baseNote.audios audios.value = baseNote.audios
reminders.value = baseNote.reminders reminders.value = baseNote.reminders
viewMode.value = baseNote.viewMode
} else { } else {
originalNote = createBaseNote() originalNote = createBaseNote()
app.showToast(R.string.cant_find_note) 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) } withContext(Dispatchers.IO) { app.deleteAttachments(attachments) }
} }
if (checkAutoSave) { 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) this.items.addAll(items)
} }
suspend fun saveNote(): Long { suspend fun saveNote(checkBackupOnSave: Boolean = true): Long {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val note = getBaseNote() val note = getBaseNote()
val id = baseNoteDao.insert(note) val id = baseNoteDao.insert(note)
app.checkAutoSave( if (checkBackupOnSave) {
preferences, checkBackupOnSave(note)
note = note, }
forceFullBackup = originalNote.attachmentsDifferFrom(note), originalNote = note.deepCopy()
)
return@withContext id return@withContext id
} }
} }
suspend fun checkBackupOnSave(note: BaseNote = getBaseNote()) {
app.checkBackupOnSave(
preferences,
note = note,
forceFullBackup = originalNote?.attachmentsDifferFrom(note) == true,
)
}
fun isEmpty(): Boolean { fun isEmpty(): Boolean {
return title.isEmpty() && return title.isEmpty() &&
body.isEmpty() && body.isEmpty() &&
@ -316,7 +329,7 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
withContext(Dispatchers.IO) { baseNoteDao.updateAudios(id, audios.value) } withContext(Dispatchers.IO) { baseNoteDao.updateAudios(id, audios.value) }
} }
private fun getBaseNote(): BaseNote { fun getBaseNote(): BaseNote {
val spans = getFilteredSpans(body) val spans = getFilteredSpans(body)
val body = this.body.toString() val body = this.body.toString()
val nonEmptyItems = this.items.filter { item -> item.body.isNotEmpty() } val nonEmptyItems = this.items.filter { item -> item.body.isNotEmpty() }
@ -337,6 +350,7 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
files.value, files.value,
audios.value, audios.value,
reminders.value, reminders.value,
viewMode.value,
) )
} }
@ -435,6 +449,34 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
withContext(Dispatchers.IO) { baseNoteDao.updateReminders(id, updatedReminders) } 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 { enum class FileType {
IMAGE, IMAGE,
ANY, 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 theme = createEnumPreference(preferences, "theme", Theme.FOLLOW_SYSTEM, R.string.theme)
val useDynamicColors = BooleanPreference("useDynamicColors", preferences, false)
val textSize = val textSize =
createEnumPreference(preferences, "textSize", TextSize.MEDIUM, R.string.text_size) createEnumPreference(preferences, "textSize", TextSize.MEDIUM, R.string.text_size)
val dateFormat = 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 notesView = createEnumPreference(preferences, "view", NotesView.LIST, R.string.view)
val notesSorting = NotesSortPreference(preferences) val notesSorting = NotesSortPreference(preferences)
val startView =
StringPreference("startView", preferences, START_VIEW_DEFAULT, R.string.start_view)
val listItemSorting = val listItemSorting =
createEnumPreference( createEnumPreference(
preferences, preferences,
"checkedListItemSorting", "listItemSorting",
ListItemSort.NO_AUTO_SORT, ListItemSort.AUTO_SORT_BY_CHECKED,
R.string.checked_list_item_sorting, R.string.list_item_auto_sort,
) )
val maxItems = val maxItems =
@ -73,21 +76,27 @@ class NotallyXPreferences private constructor(private val context: Context) {
10, 10,
R.string.max_lines_to_display_title, R.string.max_lines_to_display_title,
) )
val labelsHiddenInNavigation = val labelsHidden = StringSetPreference("labelsHiddenInNavigation", preferences, setOf())
StringSetPreference("labelsHiddenInNavigation", preferences, setOf()) val labelTagsHiddenInOverview =
val labelsHiddenInOverview =
BooleanPreference( BooleanPreference(
"labelsHiddenInOverview", "labelsHiddenInOverview",
preferences, preferences,
false, false,
R.string.labels_hidden_in_overview_title, R.string.labels_hidden_in_overview_title,
) )
val imagesHiddenInOverview =
BooleanPreference(
"imagesHiddenInOverview",
preferences,
false,
R.string.images_hidden_in_overview_title,
)
val maxLabels = val maxLabels =
IntPreference( IntPreference(
"maxLabelsInNavigation", "maxLabelsInNavigation",
preferences, preferences,
5, 5,
1, 0,
20, 20,
R.string.max_labels_to_display, 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 = val biometricLock =
createEnumPreference( createEnumPreference(
preferences, preferences,
@ -123,6 +142,8 @@ class NotallyXPreferences private constructor(private val context: Context) {
val fallbackDatabaseEncryptionKey by lazy { val fallbackDatabaseEncryptionKey by lazy {
ByteArrayPreference("fallback_database_encryption_key", encryptedPreferences, ByteArray(0)) ByteArrayPreference("fallback_database_encryption_key", encryptedPreferences, ByteArray(0))
} }
val secureFlag =
BooleanPreference("secureFlag", preferences, false, R.string.disallow_screenshots)
val dataInPublicFolder = val dataInPublicFolder =
BooleanPreference("dataOnExternalStorage", preferences, false, R.string.data_in_public) 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() } context.importPreferences(uri, preferences.edit()).also { reload() }
fun reset() { fun reset() {
preferences.edit().clear().apply() preferences.edit().clear().commit()
encryptedPreferences.edit().clear().apply() encryptedPreferences.edit().clear().apply()
backupsFolder.refresh() backupsFolder.refresh()
dataInPublicFolder.refresh()
theme.refresh()
reload() reload()
startView.refresh()
} }
private fun reload() { private fun reload() {
setOf( setOf(
theme,
textSize, textSize,
dateFormat, dateFormat,
applyDateFormatInNoteView, applyDateFormatInNoteView,
@ -209,12 +232,15 @@ class NotallyXPreferences private constructor(private val context: Context) {
maxItems, maxItems,
maxLines, maxLines,
maxTitle, maxTitle,
labelsHiddenInNavigation, secureFlag,
labelsHiddenInOverview, labelsHidden,
labelTagsHiddenInOverview,
maxLabels, maxLabels,
periodicBackups, periodicBackups,
backupPassword, backupPassword,
biometricLock, backupOnSave,
autoSaveAfterIdleTime,
imagesHiddenInOverview,
) )
.forEach { it.refresh() } .forEach { it.refresh() }
} }
@ -222,6 +248,8 @@ class NotallyXPreferences private constructor(private val context: Context) {
companion object { companion object {
private const val TAG = "NotallyXPreferences" private const val TAG = "NotallyXPreferences"
const val EMPTY_PATH = "emptyPath" 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 @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) { enum class NotesSortBy(val textResId: Int, val iconResId: Int, val value: String) {
TITLE(R.string.title, R.drawable.sort_by_alpha, "autoSortByTitle"), TITLE(R.string.title, R.drawable.sort_by_alpha, "autoSortByTitle"),
CREATION_DATE(R.string.creation_date, R.drawable.calendar_add_on, "autoSortByCreationDate"), 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 { companion object {
fun fromValue(value: String): NotesSortBy? { fun fromValue(value: String): NotesSortBy? {

View file

@ -5,6 +5,7 @@ import android.content.SharedPreferences
import android.os.Build import android.os.Build
import androidx.core.content.edit import androidx.core.content.edit
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import com.philkes.notallyx.R import com.philkes.notallyx.R
@ -66,6 +67,14 @@ abstract class BasePreference<T>(
return getData().merge(other.getData()) 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>) { fun observeForever(observer: Observer<T>) {
getData().observeForever(observer) getData().observeForever(observer)
} }

View file

@ -1,6 +1,6 @@
package com.philkes.notallyx.presentation.widget package com.philkes.notallyx.presentation.widget
import android.content.Intent import android.content.res.ColorStateList
import android.graphics.Paint import android.graphics.Paint
import android.os.Build import android.os.Build
import android.util.TypedValue import android.util.TypedValue
@ -11,9 +11,13 @@ import com.philkes.notallyx.NotallyXApplication
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.data.NotallyDatabase import com.philkes.notallyx.data.NotallyDatabase
import com.philkes.notallyx.data.model.BaseNote 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.data.model.Type
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences 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( class WidgetFactory(
private val app: NotallyXApplication, private val app: NotallyXApplication,
@ -75,10 +79,12 @@ class WidgetFactory(
setViewVisibility(R.id.Note, View.VISIBLE) setViewVisibility(R.id.Note, View.VISIBLE)
} else setViewVisibility(R.id.Note, View.GONE) } else setViewVisibility(R.id.Note, View.GONE)
val intent = Intent(WidgetProvider.ACTION_OPEN_NOTE) setOnClickFillInIntent(R.id.ChangeNote, getWidgetSelectNoteIntent(widgetId))
setOnClickFillInIntent(R.id.LinearLayout, intent) 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, preferences.textSize.value.displayTitleSize,
) )
setTextViewText(R.id.Title, list.title) 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) val (_, controlsColor) = app.extractWidgetColors(list.color, preferences)
setOnClickFillInIntent(R.id.LinearLayout, intent) setTextViewsTextColor(listOf(R.id.Title), controlsColor)
setImageViewColor(R.id.ChangeNote, controlsColor)
setOnClickFillInIntent(R.id.ChangeNote, getSelectNoteIntent(widgetId))
} }
} }
@ -102,57 +111,68 @@ class WidgetFactory(
val item = list.items[index] val item = list.items[index]
val view = val view =
if (item.isChild) { 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) RemoteViews(app.packageName, R.layout.widget_list_child_item)
} else { } else {
RemoteViews(app.packageName, R.layout.widget_list_item) RemoteViews(app.packageName, R.layout.widget_list_item)
} }
return view.apply { return view.apply {
setTextViewTextSize( val (_, controlsColor) = app.extractWidgetColors(list.color, preferences)
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
},
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
setListItemTextView(item, R.id.CheckBox, controlsColor)
setCompoundButtonChecked(R.id.CheckBox, item.checked) setCompoundButtonChecked(R.id.CheckBox, item.checked)
val intent = Intent(WidgetProvider.ACTION_CHECKED_CHANGED) val checkIntent = getWidgetCheckedChangeIntent(list.id, index)
intent.putExtra(WidgetProvider.EXTRA_POSITION, index) setOnCheckedChangeResponse(
val response = RemoteViews.RemoteResponse.fromFillInIntent(intent) R.id.CheckBox,
setOnCheckedChangeResponse(R.id.CheckBox, response) RemoteViews.RemoteResponse.fromFillInIntent(checkIntent),
)
setColorStateList(
R.id.CheckBox,
"setButtonTintList",
ColorStateList.valueOf(controlsColor),
)
} else { } else {
val intent = Intent(WidgetProvider.ACTION_OPEN_LIST) setListItemTextView(item, R.id.CheckBoxText, controlsColor)
if (item.checked) { setImageViewResource(
setTextViewCompoundDrawablesRelative( R.id.CheckBox,
R.id.CheckBox, if (item.checked) R.drawable.checkbox_fill else R.drawable.checkbox_outline,
R.drawable.checkbox_fill, )
0, setOnClickFillInIntent(
0, R.id.LinearLayout,
0, getWidgetCheckedChangeIntent(list.id, index),
) )
} else setImageViewColor(R.id.CheckBox, controlsColor)
setTextViewCompoundDrawablesRelative(
R.id.CheckBox,
R.drawable.checkbox_outline,
0,
0,
0,
)
setOnClickFillInIntent(R.id.CheckBox, intent)
} }
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 getViewTypeCount() = 3
override fun hasStableIds(): Boolean { override fun hasStableIds(): Boolean {

View file

@ -5,41 +5,48 @@ import android.app.PendingIntent
import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider import android.appwidget.AppWidgetProvider
import android.content.Context import android.content.Context
import android.content.ContextWrapper
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.widget.RemoteViews import android.widget.RemoteViews
import androidx.core.content.ContextCompat
import com.philkes.notallyx.NotallyXApplication
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.data.NotallyDatabase import com.philkes.notallyx.data.NotallyDatabase
import com.philkes.notallyx.data.dao.BaseNoteDao import com.philkes.notallyx.data.dao.BaseNoteDao
import com.philkes.notallyx.data.model.BaseNote import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Type 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.ConfigureWidgetActivity
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_SELECTED_BASE_NOTE 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.EditListActivity
import com.philkes.notallyx.presentation.activity.note.EditNoteActivity 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.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.Theme
import com.philkes.notallyx.utils.embedIntentExtras import com.philkes.notallyx.utils.embedIntentExtras
import com.philkes.notallyx.utils.getOpenNotePendingIntent import com.philkes.notallyx.utils.getOpenNotePendingIntent
import com.philkes.notallyx.utils.isSystemInDarkMode
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class WidgetProvider : AppWidgetProvider() { class WidgetProvider : AppWidgetProvider() {
@OptIn(DelicateCoroutinesApi::class)
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent) super.onReceive(context, intent)
when (intent.action) { when (intent.action) {
ACTION_NOTES_MODIFIED -> { ACTION_NOTES_MODIFIED -> {
val app = context.applicationContext as NotallyXApplication
val noteIds = intent.getLongArrayExtra(EXTRA_MODIFIED_NOTES) val noteIds = intent.getLongArrayExtra(EXTRA_MODIFIED_NOTES)
if (noteIds != null) { if (noteIds != null) {
updateWidgets(context, noteIds) updateWidgets(context, noteIds, locked = app.locked.value)
} }
} }
ACTION_OPEN_NOTE -> openActivity(context, intent, EditNoteActivity::class.java) ACTION_OPEN_NOTE -> openActivity(context, intent, EditNoteActivity::class.java)
@ -53,10 +60,10 @@ class WidgetProvider : AppWidgetProvider() {
private fun checkChanged(intent: Intent, context: Context) { private fun checkChanged(intent: Intent, context: Context) {
val noteId = intent.getLongExtra(EXTRA_SELECTED_BASE_NOTE, 0) val noteId = intent.getLongExtra(EXTRA_SELECTED_BASE_NOTE, 0)
val position = intent.getIntExtra(EXTRA_POSITION, 0) val position = intent.getIntExtra(EXTRA_POSITION, 0)
val checked = var checked =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
intent.getBooleanExtra(RemoteViews.EXTRA_CHECKED, false) intent.getBooleanExtra(RemoteViews.EXTRA_CHECKED, false)
} else false } else null
val database = val database =
NotallyDatabase.getDatabase( NotallyDatabase.getDatabase(
context.applicationContext as Application, context.applicationContext as Application,
@ -70,14 +77,18 @@ class WidgetProvider : AppWidgetProvider() {
val baseNoteDao = database.getBaseNoteDao() val baseNoteDao = database.getBaseNoteDao()
val note = baseNoteDao.get(noteId)!! val note = baseNoteDao.get(noteId)!!
val item = note.items[position] val item = note.items[position]
if (checked == null) {
checked = !item.checked
}
if (item.isChild) { if (item.isChild) {
changeChildChecked(note, position, checked, baseNoteDao, noteId) changeChildChecked(note, position, checked!!, baseNoteDao, noteId)
} else { } else {
val childrenPositions = note.items.findChildrenPositions(position) val childrenPositions = note.items.findChildrenPositions(position)
baseNoteDao.updateChecked(noteId, childrenPositions + position, checked) baseNoteDao.updateChecked(noteId, childrenPositions + position, checked!!)
} }
} finally { } finally {
updateWidgets(context, longArrayOf(noteId)) val app = context.applicationContext as NotallyXApplication
updateWidgets(context, longArrayOf(noteId), locked = app.locked.value)
pendingResult.finish() pendingResult.finish()
} }
} }
@ -127,28 +138,19 @@ class WidgetProvider : AppWidgetProvider() {
appWidgetManager: AppWidgetManager, appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray, appWidgetIds: IntArray,
) { ) {
val app = context.applicationContext as Application val app = context.applicationContext as NotallyXApplication
val preferences = NotallyXPreferences.getInstance(app) val preferences = NotallyXPreferences.getInstance(app)
appWidgetIds.forEach { id -> appWidgetIds.forEach { id ->
val noteId = preferences.getWidgetData(id) val noteId = preferences.getWidgetData(id)
val noteType = preferences.getWidgetNoteType(id) val noteType = preferences.getWidgetNoteType(id) ?: return
updateWidget(context, appWidgetManager, id, noteId, noteType) 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 { 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 app = context.applicationContext as Application
val preferences = NotallyXPreferences.getInstance(app) val preferences = NotallyXPreferences.getInstance(app)
@ -157,7 +159,7 @@ class WidgetProvider : AppWidgetProvider() {
updatableWidgets.forEach { (id, noteId) -> updatableWidgets.forEach { (id, noteId) ->
updateWidget( updateWidget(
context, app,
manager, manager,
id, id,
noteId, noteId,
@ -168,7 +170,7 @@ class WidgetProvider : AppWidgetProvider() {
} }
fun updateWidget( fun updateWidget(
context: Context, context: ContextWrapper,
manager: AppWidgetManager, manager: AppWidgetManager,
id: Int, id: Int,
noteId: Long, noteId: Long,
@ -182,40 +184,144 @@ class WidgetProvider : AppWidgetProvider() {
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id) intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id)
intent.embedIntentExtras() intent.embedIntentExtras()
val view = MainScope().launch {
if (!locked) { val database = NotallyDatabase.getDatabase(context).value
RemoteViews(context.packageName, R.layout.widget).apply { val color =
setRemoteAdapter(R.id.ListView, intent) withContext(Dispatchers.IO) { database.getBaseNoteDao().getColorOfNote(noteId) }
setEmptyView(R.id.ListView, R.id.Empty) if (color == null) {
setOnClickFillInIntent(R.id.Empty, getSelectNoteIntent(id)) val app = context.applicationContext as Application
setPendingIntentTemplate( val preferences = NotallyXPreferences.getInstance(app)
R.id.ListView, preferences.deleteWidget(id)
noteType?.let { context.getOpenNotePendingIntent(noteId, it) }, val view =
) RemoteViews(context.packageName, R.layout.widget).apply {
} setRemoteAdapter(R.id.ListView, intent)
} else { setEmptyView(R.id.ListView, R.id.Empty)
RemoteViews(context.packageName, R.layout.widget_locked).apply { setOnClickPendingIntent(
noteType?.let { R.id.Empty,
val lockedPendingIntent = Intent(context, WidgetProvider::class.java)
context.getOpenNotePendingIntent(noteId, noteType) .apply {
setOnClickPendingIntent(R.id.Layout, lockedPendingIntent) action = ACTION_SELECT_NOTE
setOnClickPendingIntent(R.id.Text, lockedPendingIntent) 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, manager.updateAppWidget(id, view)
0, manager.notifyAppWidgetViewDataChanged(id, R.id.ListView)
R.drawable.lock_big, return@launch
0, }
0, 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<*>) { private fun openActivity(context: Context, originalIntent: Intent, clazz: Class<*>) {
val id = originalIntent.getLongExtra(EXTRA_SELECTED_BASE_NOTE, 0) val id = originalIntent.getLongExtra(EXTRA_SELECTED_BASE_NOTE, 0)
val widgetId = originalIntent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, 0) val widgetId = originalIntent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, 0)
@ -237,19 +343,18 @@ class WidgetProvider : AppWidgetProvider() {
return intent return intent
} }
fun sendBroadcast(context: Context, ids: LongArray) { fun sendBroadcast(context: Context, ids: LongArray) =
val intent = Intent(context, WidgetProvider::class.java) Intent(context, WidgetProvider::class.java).apply {
intent.action = ACTION_NOTES_MODIFIED action = ACTION_NOTES_MODIFIED
intent.putExtra(EXTRA_MODIFIED_NOTES, ids) putExtra(EXTRA_MODIFIED_NOTES, ids)
context.sendBroadcast(intent) context.sendBroadcast(this)
} }
fun getSelectNoteIntent(id: Int): Intent { fun getWidgetSelectNoteIntent(id: Int) =
return Intent(ACTION_SELECT_NOTE).apply { Intent(ACTION_SELECT_NOTE).apply {
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id) putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id)
data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME)) data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
} }
}
private const val EXTRA_MODIFIED_NOTES = "com.philkes.notallyx.EXTRA_MODIFIED_NOTES" private const val EXTRA_MODIFIED_NOTES = "com.philkes.notallyx.EXTRA_MODIFIED_NOTES"
private const val ACTION_NOTES_MODIFIED = "com.philkes.notallyx.ACTION_NOTE_MODIFIED" 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 { class ActionMode {
val enabled = NotNullLiveData(false) val enabled = NotNullLiveData(false)
val loading = NotNullLiveData(false)
val count = NotNullLiveData(0) val count = NotNullLiveData(0)
val selectedNotes = HashMap<Long, BaseNote>() val selectedNotes = HashMap<Long, BaseNote>()
val selectedIds = selectedNotes.keys 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 fun isEnabled() = enabled.value
// We assume selectedNotes.size is 1 // We assume selectedNotes.size is 1
fun getFirstNote() = selectedNotes.values.first() 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) intent.putExtra(ReminderReceiver.EXTRA_NOTE_ID, noteId)
return PendingIntent.getBroadcast( return PendingIntent.getBroadcast(
this, this,
0, (noteId.toString() + reminderId.toString()).toInt(),
intent, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
) )

View file

@ -1,7 +1,6 @@
package com.philkes.notallyx.utils package com.philkes.notallyx.utils
import android.app.Activity import android.app.Activity
import android.app.KeyguardManager
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
@ -12,10 +11,11 @@ import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.ContextWrapper import android.content.ContextWrapper
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.res.Configuration
import android.hardware.biometrics.BiometricManager import android.hardware.biometrics.BiometricManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.util.Log import android.util.Log
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
@ -32,16 +32,19 @@ import com.philkes.notallyx.BuildConfig
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.BaseNote import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Type 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.EditActivity.Companion.EXTRA_SELECTED_BASE_NOTE
import com.philkes.notallyx.presentation.activity.note.EditListActivity import com.philkes.notallyx.presentation.activity.note.EditListActivity
import com.philkes.notallyx.presentation.activity.note.EditNoteActivity import com.philkes.notallyx.presentation.activity.note.EditNoteActivity
import com.philkes.notallyx.presentation.showToast import com.philkes.notallyx.presentation.showToast
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
import java.io.BufferedReader
import java.io.File import java.io.File
import java.io.InputStreamReader
import java.io.OutputStreamWriter import java.io.OutputStreamWriter
import java.io.PrintWriter import java.io.PrintWriter
import java.lang.UnsupportedOperationException
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.text.DateFormat import java.text.DateFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date 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 } 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 { ContextCompat.getSystemService(this, ClipboardManager::class.java)?.let {
val clip = ClipData.newPlainText("label", text) val clip = ClipData.newPlainText("label", text)
it.setPrimaryClip(clip) it.setPrimaryClip(clip)
@ -120,30 +123,15 @@ fun Context.getFileName(uri: Uri): String? =
} }
fun Context.canAuthenticateWithBiometrics(): Int { fun Context.canAuthenticateWithBiometrics(): Int {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val biometricManager = androidx.biometric.BiometricManager.from(this)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { val authenticators =
val keyguardManager = ContextCompat.getSystemService(this, KeyguardManager::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val packageManager: PackageManager = this.packageManager androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG or
if (!packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)) { androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
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()
} else { } else {
val biometricManager: BiometricManager = androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
this.getSystemService(BiometricManager::class.java)
return biometricManager.canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
)
} }
} return biometricManager.canAuthenticate(authenticators)
return BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE
} }
fun Context.getUriForFile(file: File): Uri = 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) 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( fun ContextWrapper.log(
tag: String, tag: String,
msg: String? = null, msg: String? = null,
@ -165,8 +155,7 @@ fun ContextWrapper.log(
fun ContextWrapper.getLastExceptionLog(): String? { fun ContextWrapper.getLastExceptionLog(): String? {
val logFile = getLogFile() val logFile = getLogFile()
if (logFile.exists()) { if (logFile.exists()) {
val logContents = logFile.readText().substringAfterLast("[Start]") return logFile.readText().substringAfterLast("[Start]")
return URLEncoder.encode(logContents, StandardCharsets.UTF_8.toString())
} }
return null return null
} }
@ -182,8 +171,10 @@ fun Context.logToFile(
stackTrace: String? = null, stackTrace: String? = null,
) { ) {
msg?.let { msg?.let {
if (throwable != null || stackTrace != null) { if (throwable != null) {
Log.e(tag, it) Log.e(tag, it, throwable)
} else if (stackTrace != null) {
Log.e(tag, "$it: $stackTrace")
} else { } else {
Log.i(tag, it) Log.i(tag, it)
} }
@ -194,16 +185,24 @@ fun Context.logToFile(
val logFile = val logFile =
folder.findFile(fileName).let { folder.findFile(fileName).let {
if (it == null || !it.exists()) { 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)) { } else if (it.isLargerThanKb(MAX_LOGS_FILE_SIZE_KB)) {
it.delete() it.delete()
folder.createFile("text/plain", fileName) folder.createFile("text/plain", fileName.removeSuffix(".txt"))
} else it } else it
} }
logFile?.let { file -> logFile?.let { file ->
val contentResolver = contentResolver 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 -> outputStream?.use { output ->
val writer = PrintWriter(OutputStreamWriter(output, Charsets.UTF_8)) val writer = PrintWriter(OutputStreamWriter(output, Charsets.UTF_8))
@ -211,6 +210,7 @@ fun Context.logToFile(
val formatter = DateFormat.getDateTimeInstance() val formatter = DateFormat.getDateTimeInstance()
val time = formatter.format(System.currentTimeMillis()) val time = formatter.format(System.currentTimeMillis())
logFileContents?.let { writer.println(it) }
if (throwable != null || stackTrace != null) { if (throwable != null || stackTrace != null) {
writer.println("[Start]") 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?) { fun Context.reportBug(stackTrace: String?) {
catchNoBrowserInstalled { startActivity(createReportBugIntent(stackTrace)) } catchNoBrowserInstalled { startActivity(createReportBugIntent(stackTrace)) }
} }
@ -257,26 +268,53 @@ fun Context.createReportBugIntent(
title: String? = null, title: String? = null,
body: String? = null, body: String? = null,
): Intent { ): Intent {
fun String?.asQueryParam(paramName: String): String {
return this?.let { "&$paramName=${URLEncoder.encode(this, "UTF-8")}" } ?: ""
}
return Intent( return Intent(
Intent.ACTION_VIEW, Intent.ACTION_VIEW,
Uri.parse( 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) .take(2000)
), ),
) )
.wrapWithChooser(this) .wrapWithChooser(this)
} }
fun Context.shareNote(title: String, body: CharSequence) { fun ContextWrapper.shareNote(note: BaseNote) {
val text = body.truncate(150_000) 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 = val intent =
Intent(Intent.ACTION_SEND) Intent(if (imageUris.size > 1) Intent.ACTION_SEND_MULTIPLE else Intent.ACTION_SEND)
.apply { .apply {
type = "text/plain" type = if (imageUris.isEmpty()) "text/*" else "image/*"
putExtra(Intent.EXTRA_TEXT, text.toString()) putExtra(Intent.EXTRA_TEXT, text.toString())
putExtra(Intent.EXTRA_TITLE, title) putExtra(Intent.EXTRA_TITLE, title)
putExtra(Intent.EXTRA_SUBJECT, 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) .wrapWithChooser(this)
startActivity(intent) startActivity(intent)
@ -309,11 +347,16 @@ fun Context.getOpenNotePendingIntent(noteId: Long, noteType: Type): PendingInten
return PendingIntent.getActivity( return PendingIntent.getActivity(
this, this,
0, 0,
getOpenNoteIntent(noteId, noteType, addPendingFlags = true), getOpenNoteIntent(noteId, noteType, addPendingFlags = false),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, 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?) = fun ContentResolver.determineMimeTypeAndExtension(uri: Uri, proposedMimeType: String?) =
if (proposedMimeType != null && proposedMimeType.contains("/")) { if (proposedMimeType != null && proposedMimeType.contains("/")) {
Pair(proposedMimeType, ".${uri.lastPathSegment?.substringAfterLast(".")}") Pair(proposedMimeType, ".${uri.lastPathSegment?.substringAfterLast(".")}")
@ -374,3 +417,21 @@ fun Uri.toReadablePath(): String {
.replaceFirst("/tree/primary:", "Internal Storage/") .replaceFirst("/tree/primary:", "Internal Storage/")
.replaceFirst("/tree/.*:".toRegex(), "External 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 package com.philkes.notallyx.utils
import android.os.Bundle import android.os.Bundle
import android.widget.Button
import android.widget.ImageView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import cat.ereza.customactivityoncrash.CustomActivityOnCrash import cat.ereza.customactivityoncrash.CustomActivityOnCrash
import com.philkes.notallyx.R import com.philkes.notallyx.databinding.ActivityErrorBinding
import com.philkes.notallyx.presentation.dp
/** /**
* 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() { class ErrorActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView( val binding = ActivityErrorBinding.inflate(layoutInflater)
cat.ereza.customactivityoncrash.R.layout.customactivityoncrash_default_error_activity setContentView(binding.root)
) binding.apply {
findViewById<ImageView>( RestartButton.setOnClickListener {
cat.ereza.customactivityoncrash.R.id.customactivityoncrash_error_activity_image CustomActivityOnCrash.restartApplication(
) this@ErrorActivity,
.apply { CustomActivityOnCrash.getConfigFromIntent(intent)!!,
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
) )
setOnClickListener {
CustomActivityOnCrash.restartApplication(
this@ErrorActivity,
CustomActivityOnCrash.getConfigFromIntent(intent)!!,
)
}
} }
val stackTrace = CustomActivityOnCrash.getStackTraceFromIntent(intent)
stackTrace?.let { application.log(TAG, stackTrace = it) } val stackTrace = CustomActivityOnCrash.getStackTraceFromIntent(intent)
findViewById<Button>( stackTrace?.let {
cat.ereza.customactivityoncrash.R.id application.log(TAG, stackTrace = it)
.customactivityoncrash_error_activity_more_info_button Exception.text = stackTrace.lines().firstOrNull() ?: ""
)
.apply {
setText(R.string.report_bug)
setOnClickListener {
reportBug(CustomActivityOnCrash.getStackTraceFromIntent(intent))
}
} }
ReportButton.setOnClickListener { reportBug(stackTrace) }
}
} }
companion object { companion object {

View file

@ -103,6 +103,14 @@ fun File.toRelativePathFrom(baseFolderName: String): String {
return relativePath.trimStart(File.separatorChar) return relativePath.trimStart(File.separatorChar)
} }
fun File.recreateDir(): File {
if (exists()) {
deleteRecursively()
}
mkdirs()
return this
}
fun ContextWrapper.deleteAttachments( fun ContextWrapper.deleteAttachments(
attachments: Collection<Attachment>, attachments: Collection<Attachment>,
ids: LongArray? = null, ids: LongArray? = null,
@ -141,7 +149,7 @@ fun Context.getExportedPath() = getEmptyFolder("exported")
fun ContextWrapper.getLogsDir() = File(filesDir, "logs").also { it.mkdir() } 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 { fun ContextWrapper.getLogFile(): File {
return File(getLogsDir(), APP_LOG_FILE_NAME) 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 package com.philkes.notallyx.utils.backup
import android.content.Context import android.content.Context
import android.content.ContextWrapper
import androidx.work.Worker import androidx.work.Worker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
@ -8,6 +9,6 @@ class AutoBackupWorker(private val context: Context, params: WorkerParameters) :
Worker(context, params) { Worker(context, params) {
override fun doWork(): Result { 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.PdfPrintListener
import android.print.printPdf import android.print.printPdf
import android.util.Log import android.util.Log
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.lifecycleScope
import androidx.work.Data import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ListenableWorker.Result 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.toHtml
import com.philkes.notallyx.data.model.toJson import com.philkes.notallyx.data.model.toJson
import com.philkes.notallyx.data.model.toTxt 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.activity.main.MainActivity
import com.philkes.notallyx.presentation.view.misc.MenuDialog
import com.philkes.notallyx.presentation.view.misc.Progress import com.philkes.notallyx.presentation.view.misc.Progress
import com.philkes.notallyx.presentation.viewmodel.BackupFile import com.philkes.notallyx.presentation.viewmodel.BackupFile
import com.philkes.notallyx.presentation.viewmodel.ExportMimeType 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.getExternalAudioDirectory
import com.philkes.notallyx.utils.getExternalFilesDirectory import com.philkes.notallyx.utils.getExternalFilesDirectory
import com.philkes.notallyx.utils.getExternalImagesDirectory import com.philkes.notallyx.utils.getExternalImagesDirectory
import com.philkes.notallyx.utils.getUriForFile
import com.philkes.notallyx.utils.listZipFiles import com.philkes.notallyx.utils.listZipFiles
import com.philkes.notallyx.utils.log import com.philkes.notallyx.utils.log
import com.philkes.notallyx.utils.logToFile 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.removeTrailingParentheses
import com.philkes.notallyx.utils.security.decryptDatabase import com.philkes.notallyx.utils.security.decryptDatabase
import com.philkes.notallyx.utils.security.getInitializedCipherForDecryption import com.philkes.notallyx.utils.security.getInitializedCipherForDecryption
import com.philkes.notallyx.utils.wrapWithChooser
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.IOException import java.io.IOException
@ -69,6 +78,7 @@ import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import net.lingala.zip4j.ZipFile import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.exception.ZipException 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 ON_SAVE_BACKUP_FILE = "NotallyX_AutoBackup.zip"
private const val PERIODIC_BACKUP_FILE_PREFIX = "NotallyX_Backup_" private const val PERIODIC_BACKUP_FILE_PREFIX = "NotallyX_Backup_"
fun Context.createBackup(): Result { fun ContextWrapper.createBackup(): Result {
val app = applicationContext as Application val app = applicationContext as Application
val preferences = NotallyXPreferences.getInstance(app) val preferences = NotallyXPreferences.getInstance(app)
val (_, maxBackups) = preferences.periodicBackups.value val (_, maxBackups) = preferences.periodicBackups.value
@ -96,7 +106,11 @@ fun Context.createBackup(): Result {
if (path != EMPTY_PATH) { if (path != EMPTY_PATH) {
val uri = Uri.parse(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) { fun log(msg: String? = null, throwable: Throwable? = null, stackTrace: String? = null) {
logToFile( logToFile(
TAG, TAG,
@ -107,79 +121,60 @@ fun Context.createBackup(): Result {
stackTrace = stackTrace, stackTrace = stackTrace,
) )
} }
try {
if (folder.exists()) {
val formatter = SimpleDateFormat("yyyyMMdd-HHmmssSSS", Locale.ENGLISH) val formatter = SimpleDateFormat("yyyyMMdd-HHmmssSSS", Locale.ENGLISH)
val backupFilePrefix = PERIODIC_BACKUP_FILE_PREFIX val backupFilePrefix = PERIODIC_BACKUP_FILE_PREFIX
val name = "$backupFilePrefix${formatter.format(System.currentTimeMillis())}" val name = "$backupFilePrefix${formatter.format(System.currentTimeMillis())}.zip"
log(msg = "Creating '$uri/$name.zip'...") log(msg = "Creating '$uri/$name'...")
try { val zipUri = requireNotNull(folder.createFile(MIME_TYPE_ZIP, name)).uri
val zipUri = requireNotNull(folder.createFile(MIME_TYPE_ZIP, name)).uri val exportedNotes = app.exportAsZip(zipUri, password = preferences.backupPassword.value)
val exportedNotes = log(msg = "Exported $exportedNotes notes")
app.exportAsZip(zipUri, password = preferences.backupPassword.value) val backupFiles = folder.listZipFiles(backupFilePrefix)
log(msg = "Exported $exportedNotes notes") log(msg = "Found ${backupFiles.size} backups")
val backupFiles = folder.listZipFiles(backupFilePrefix) val backupsToBeDeleted = backupFiles.drop(maxBackups)
log(msg = "Found ${backupFiles.size} backups") if (backupsToBeDeleted.isNotEmpty()) {
val backupsToBeDeleted = backupFiles.drop(maxBackups) log(
if (backupsToBeDeleted.isNotEmpty()) { msg =
log( "Deleting ${backupsToBeDeleted.size} oldest backups (maxBackups: $maxBackups): ${backupsToBeDeleted.joinToString { "'${it.name.toString()}'" }}"
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()
) )
} }
} else { backupsToBeDeleted.forEach {
log(msg = "Folder '${folder.uri}' does not exist, therefore skipping auto-backup") 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() return Result.success()
} }
fun ContextWrapper.autoBackupOnSave(backupPath: String, password: String, savedNote: BaseNote?) { fun ContextWrapper.autoBackupOnSave(backupPath: String, password: String, savedNote: BaseNote?) {
val backupFolder = val folder =
try { requireBackupFolder(
DocumentFile.fromTreeUri(this, backupPath.toUri())!! backupPath,
} catch (e: Exception) { "Auto backup on note save (${savedNote?.let { "id: '${savedNote.id}, title: '${savedNote.title}'" }}) failed, because auto-backup path '$backupPath' is invalid",
log( ) ?: return
TAG, fun log(msg: String? = null, throwable: Throwable? = null) {
msg = logToFile(TAG, folder, NOTALLYX_BACKUP_LOGS_FILE, msg = msg, throwable = throwable)
"Auto backup on note save (${savedNote?.let {"id: '${savedNote.id}, title: '${savedNote.title}'" }}) failed, because auto-backup path is invalid", }
throwable = e,
)
return
}
try { try {
var backupFile = backupFolder.findFile(ON_SAVE_BACKUP_FILE) var backupFile = folder.findFile(ON_SAVE_BACKUP_FILE)
if (savedNote == null || backupFile == null || !backupFile.exists()) { 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) exportAsZip(backupFile!!.uri, password = password)
} else { } else {
NotallyDatabase.getDatabase(this, observePreferences = false).value.checkpoint() val (_, file) = copyDatabase()
val files = val files =
with(savedNote) { with(savedNote) {
images.map { images.map {
@ -197,26 +192,45 @@ fun ContextWrapper.autoBackupOnSave(backupPath: String, password: String, savedN
audios.map { audios.map {
BackupFile(SUBFOLDER_AUDIOS, File(getExternalAudioDirectory(), it.name)) BackupFile(SUBFOLDER_AUDIOS, File(getExternalAudioDirectory(), it.name))
} + } +
BackupFile( BackupFile(null, file)
null,
NotallyDatabase.getCurrentDatabaseFile(this@autoBackupOnSave),
)
} }
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) { } catch (e: Exception) {
logToFile( log(
TAG, "Auto backup on note save (${savedNote?.let { "id: '${savedNote.id}, title: '${savedNote.title}'" }}) failed",
backupFolder, e,
NOTALLYX_BACKUP_LOGS_FILE,
msg =
"Auto backup on note save (${savedNote?.let {"id: '${savedNote.id}, title: '${savedNote.title}'" }}) failed",
throwable = 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, preferences: NotallyXPreferences,
note: BaseNote? = null, note: BaseNote? = null,
forceFullBackup: Boolean = false, forceFullBackup: Boolean = false,
@ -227,7 +241,9 @@ fun ContextWrapper.checkAutoSave(
if (forceFullBackup) { if (forceFullBackup) {
deleteModifiedNoteBackup(backupPath) 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, backupProgress: MutableLiveData<Progress>? = null,
): Int { ): Int {
backupProgress?.postValue(Progress(indeterminate = true)) backupProgress?.postValue(Progress(indeterminate = true))
val tempFile = File.createTempFile("export", "tmp", cacheDir) val tempFile = File.createTempFile("export", "tmp", cacheDir)
val zipFile = try {
ZipFile(tempFile, if (password != PASSWORD_EMPTY) password.toCharArray() else null) val zipFile =
val zipParameters = ZipFile(tempFile, if (password != PASSWORD_EMPTY) password.toCharArray() else null)
ZipParameters().apply { val zipParameters =
isEncryptFiles = password != PASSWORD_EMPTY ZipParameters().apply {
if (!compress) { isEncryptFiles = password != PASSWORD_EMPTY
compressionLevel = CompressionLevel.NO_COMPRESSION if (!compress) {
compressionLevel = CompressionLevel.NO_COMPRESSION
}
encryptionMethod = EncryptionMethod.AES
} }
encryptionMethod = EncryptionMethod.AES
}
val (databaseOriginal, databaseCopy) = copyDatabase() val (databaseOriginal, databaseCopy) = copyDatabase()
zipFile.addFile(databaseCopy, zipParameters.copy(DATABASE_NAME)) zipFile.addFile(databaseCopy, zipParameters.copy(DATABASE_NAME))
databaseCopy.delete() databaseCopy.delete()
val imageRoot = getExternalImagesDirectory() val imageRoot = getExternalImagesDirectory()
val fileRoot = getExternalFilesDirectory() val fileRoot = getExternalFilesDirectory()
val audioRoot = getExternalAudioDirectory() val audioRoot = getExternalAudioDirectory()
val totalNotes = databaseOriginal.getBaseNoteDao().count() val totalNotes = databaseOriginal.getBaseNoteDao().count()
val images = databaseOriginal.getBaseNoteDao().getAllImages().toFileAttachments() val images = databaseOriginal.getBaseNoteDao().getAllImages().toFileAttachments()
val files = databaseOriginal.getBaseNoteDao().getAllFiles().toFileAttachments() val files = databaseOriginal.getBaseNoteDao().getAllFiles().toFileAttachments()
val audios = databaseOriginal.getBaseNoteDao().getAllAudios() val audios = databaseOriginal.getBaseNoteDao().getAllAudios()
val totalAttachments = images.count() + files.count() + audios.size val totalAttachments = images.count() + files.count() + audios.size
backupProgress?.postValue(Progress(0, totalAttachments)) backupProgress?.postValue(Progress(0, totalAttachments))
val counter = AtomicInteger(0) val counter = AtomicInteger(0)
images.export( images.export(
zipFile, zipFile,
zipParameters, zipParameters,
imageRoot, imageRoot,
SUBFOLDER_IMAGES, SUBFOLDER_IMAGES,
this, this,
backupProgress, backupProgress,
totalAttachments, totalAttachments,
counter, counter,
) )
files.export( files.export(
zipFile, zipFile,
zipParameters, zipParameters,
fileRoot, fileRoot,
SUBFOLDER_FILES, SUBFOLDER_FILES,
this, this,
backupProgress, backupProgress,
totalAttachments, totalAttachments,
counter, counter,
) )
audios audios
.asSequence() .asSequence()
.flatMap { string -> Converters.jsonToAudios(string) } .flatMap { string -> Converters.jsonToAudios(string) }
.forEach { audio -> .forEach { audio ->
try { try {
backupFile(zipFile, zipParameters, audioRoot, SUBFOLDER_AUDIOS, audio.name) backupFile(zipFile, zipParameters, audioRoot, SUBFOLDER_AUDIOS, audio.name)
} catch (exception: Exception) { } catch (exception: Exception) {
log(TAG, throwable = exception) log(TAG, throwable = exception)
} finally { } finally {
backupProgress?.postValue(Progress(counter.incrementAndGet(), totalAttachments)) backupProgress?.postValue(Progress(counter.incrementAndGet(), totalAttachments))
}
} }
}
zipFile.close() zipFile.close()
contentResolver.openOutputStream(fileUri)?.use { outputStream -> contentResolver.openOutputStream(fileUri)?.use { outputStream ->
FileInputStream(zipFile.file).use { inputStream -> FileInputStream(zipFile.file).use { inputStream ->
inputStream.copyTo(outputStream) inputStream.copyTo(outputStream)
outputStream.flush() 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( fun Context.exportToZip(
@ -328,29 +347,31 @@ fun Context.exportToZip(
files: List<BackupFile>, files: List<BackupFile>,
password: String = PASSWORD_EMPTY, password: String = PASSWORD_EMPTY,
): Boolean { ): Boolean {
val tempDir = File(cacheDir, "tempZip").apply { mkdirs() } val tempDir = File(cacheDir, "export").recreateDir()
val zipInputStream = contentResolver.openInputStream(zipUri) ?: return false try {
extractZipToDirectory(zipInputStream, tempDir, password) val zipInputStream = contentResolver.openInputStream(zipUri) ?: return false
files.forEach { file -> extractZipToDirectory(zipInputStream, tempDir, password)
val targetFile = File(tempDir, "${file.first?.let { "$it/" } ?: ""}${file.second.name}") files.forEach { file ->
file.second.copyTo(targetFile, overwrite = true) 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 return true
} }
private fun extractZipToDirectory(zipInputStream: InputStream, outputDir: File, password: String) { private fun extractZipToDirectory(zipInputStream: InputStream, outputDir: File, password: String) {
val tempZipFile = File.createTempFile("extractedZip", null, outputDir)
try { try {
val tempZipFile = File.createTempFile("tempZip", ".zip", outputDir)
tempZipFile.outputStream().use { zipOutputStream -> zipInputStream.copyTo(zipOutputStream) } tempZipFile.outputStream().use { zipOutputStream -> zipInputStream.copyTo(zipOutputStream) }
val zipFile = val zipFile =
ZipFile(tempZipFile, if (password != PASSWORD_EMPTY) password.toCharArray() else null) ZipFile(tempZipFile, if (password != PASSWORD_EMPTY) password.toCharArray() else null)
zipFile.extractAll(outputDir.absolutePath) zipFile.extractAll(outputDir.absolutePath)
} finally {
tempZipFile.delete() tempZipFile.delete()
} catch (e: ZipException) {
e.printStackTrace()
} }
} }
@ -360,10 +381,9 @@ private fun createZipFromDirectory(
password: String = PASSWORD_EMPTY, password: String = PASSWORD_EMPTY,
compress: Boolean = false, compress: Boolean = false,
) { ) {
val tempZipFile = File.createTempFile("tempZip", ".zip")
try { try {
val tempZipFile = File.createTempFile("tempZip", ".zip")
tempZipFile.deleteOnExit() tempZipFile.deleteOnExit()
val zipFile = val zipFile =
ZipFile(tempZipFile, if (password != PASSWORD_EMPTY) password.toCharArray() else null) ZipFile(tempZipFile, if (password != PASSWORD_EMPTY) password.toCharArray() else null)
val zipParameters = val zipParameters =
@ -377,9 +397,8 @@ private fun createZipFromDirectory(
} }
zipFile.addFolder(sourceDir, zipParameters) zipFile.addFolder(sourceDir, zipParameters)
tempZipFile.inputStream().use { inputStream -> inputStream.copyTo(zipOutputStream) } tempZipFile.inputStream().use { inputStream -> inputStream.copyTo(zipOutputStream) }
} finally {
tempZipFile.delete() 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 val database = NotallyDatabase.getDatabase(this, observePreferences = false).value
database.checkpoint() database.checkpoint()
val preferences = NotallyXPreferences.getInstance(this) val preferences = NotallyXPreferences.getInstance(this)
val databaseFile = NotallyDatabase.getCurrentDatabaseFile(this)
return if ( return if (
preferences.biometricLock.value == BiometricLock.ENABLED && preferences.biometricLock.value == BiometricLock.ENABLED &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M 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 cipher = getInitializedCipherForDecryption(iv = preferences.iv.value!!)
val passphrase = cipher.doFinal(preferences.databaseEncryptionKey.value) val passphrase = cipher.doFinal(preferences.databaseEncryptionKey.value)
val decryptedFile = File(cacheDir, DATABASE_NAME) val decryptedFile = File(cacheDir, DATABASE_NAME)
decryptDatabase( decryptDatabase(this, passphrase, databaseFile, decryptedFile)
this,
passphrase,
decryptedFile,
NotallyDatabase.getCurrentDatabaseName(this),
)
Pair(database, decryptedFile) Pair(database, decryptedFile)
} else { } else {
val dbFile = File(cacheDir, DATABASE_NAME) val dbFile = File(cacheDir, DATABASE_NAME)
NotallyDatabase.getCurrentDatabaseFile(this).copyTo(dbFile, overwrite = true) databaseFile.copyTo(dbFile, overwrite = true)
Pair(database, dbFile) Pair(database, dbFile)
} }
} }
@ -489,7 +504,7 @@ private fun ZipParameters.copy(fileNameInZip: String? = this.fileNameInZip): Zip
} }
fun exportPdfFile( fun exportPdfFile(
app: Application, app: Context,
note: BaseNote, note: BaseNote,
folder: DocumentFile, folder: DocumentFile,
fileName: String = note.title, fileName: String = note.title,
@ -499,13 +514,14 @@ fun exportPdfFile(
total: Int? = null, total: Int? = null,
duplicateFileCount: Int = 1, 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) { if (folder.findFile(filePath)?.exists() == true) {
return exportPdfFile( return exportPdfFile(
app, app,
note, note,
folder, folder,
"${fileName.removeTrailingParentheses()} ($duplicateFileCount)", "${validFileName.removeTrailingParentheses()} ($duplicateFileCount)",
pdfPrintListener, pdfPrintListener,
progress, progress,
counter, counter,
@ -539,7 +555,7 @@ fun exportPdfFile(
} }
suspend fun exportPlainTextFile( suspend fun exportPlainTextFile(
app: Application, app: Context,
note: BaseNote, note: BaseNote,
exportType: ExportMimeType, exportType: ExportMimeType,
folder: DocumentFile, folder: DocumentFile,
@ -563,8 +579,9 @@ suspend fun exportPlainTextFile(
) )
} }
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val validFileName = fileName.takeIf { it.isNotBlank() } ?: app.getString(R.string.note)
val file = val file =
folder.createFile(exportType.mimeType, fileName)?.let { folder.createFile(exportType.mimeType, validFileName)?.let {
app.contentResolver.openOutputStream(it.uri)?.use { stream -> app.contentResolver.openOutputStream(it.uri)?.use { stream ->
OutputStreamWriter(stream).use { writer -> OutputStreamWriter(stream).use { writer ->
writer.write( writer.write(
@ -606,52 +623,166 @@ fun Context.exportPreferences(preferences: NotallyXPreferences, uri: Uri): Boole
} }
} }
private fun Context.postErrorNotification(e: Throwable) { private fun Context.tryPostErrorNotification(e: Throwable) {
getSystemService<NotificationManager>()?.let { manager -> fun postErrorNotification(e: Throwable) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { getSystemService<NotificationManager>()?.let { manager ->
manager.createChannelIfNotExists(NOTIFICATION_CHANNEL_ID) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
} manager.createChannelIfNotExists(NOTIFICATION_CHANNEL_ID)
val notification = }
NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) val notification =
.setSmallIcon(R.drawable.error) NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle(getString(R.string.auto_backup_failed)) .setSmallIcon(R.drawable.error)
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setContentTitle(getString(R.string.auto_backup_failed))
.setStyle( .setPriority(NotificationCompat.PRIORITY_DEFAULT)
NotificationCompat.BigTextStyle() .setStyle(
.bigText( NotificationCompat.BigTextStyle()
getString( .bigText(
R.string.auto_backup_error_message, getString(
"${e.javaClass.simpleName}: ${e.localizedMessage}", R.string.auto_backup_error_message,
"${e.javaClass.simpleName}: ${e.localizedMessage}",
)
) )
) )
) .addAction(
.addAction( R.drawable.settings,
R.drawable.settings, getString(R.string.settings),
getString(R.string.settings), PendingIntent.getActivity(
PendingIntent.getActivity( this,
this, 0,
0, Intent(this, MainActivity::class.java).apply {
Intent(this, MainActivity::class.java).apply { putExtra(MainActivity.EXTRA_FRAGMENT_TO_OPEN, R.id.Settings)
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,
),
)
.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, )
), .addAction(
) R.drawable.error,
.build() getString(R.string.report_bug),
manager.notify(NOTIFICATION_ID, notification) 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