Compare commits

...

569 commits

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
PhilKes
c4d6cb0e63 Prompt user to choose backupsFolder on import 2025-01-26 18:13:35 +01:00
PhilKes
7e13bb5935 Update dataOnExternalStorage naming in BaseNoteModel 2025-01-26 16:08:48 +01:00
Phil
12b995f8fa
Merge pull request #308 from PhilKes/fix/import-settings
Reload settings after import
2025-01-26 16:06:29 +01:00
PhilKes
7442aec2fa Reload settings after import 2025-01-26 16:04:56 +01:00
PhilKes
96141b4c8a Bump versionCode 648 2025-01-26 15:09:47 +01:00
Phil
b4bb45deb8
Merge pull request #306 from PhilKes/translation/update
Update es/strings.xml
2025-01-26 15:09:19 +01:00
PhilKes
822762a336 Update es/strings.xml 2025-01-26 15:08:47 +01:00
Phil
667d8282f7
Merge pull request #305 from PhilKes/fix/remove-link
Increase input dialog sizes to fit large keyboards
2025-01-26 15:04:34 +01:00
PhilKes
19f22d0e91 Make Label dialogs use full size 2025-01-26 14:54:47 +01:00
PhilKes
9576051fe1 Remove link also removes text only for text note or full urls 2025-01-26 14:48:58 +01:00
PhilKes
39a9c15626 Remove background insets for text input dialogs 2025-01-26 14:42:44 +01:00
PhilKes
06a81256a1 Update fastlane documentation 2025-01-26 14:40:34 +01:00
Phil
c2642103fa
Merge pull request #301 from PhilKes/translation/update
Update french and czech translations
2025-01-26 10:48:04 +01:00
PhilKes
960de6a849 Update fr/strings.xml 2025-01-26 10:46:51 +01:00
PhilKes
5d49e52db7 Update cs/strings.xml 2025-01-26 10:45:59 +01:00
PhilKes
917d2a9b06 Update de/strings.xml 2025-01-25 16:00:39 +01:00
PhilKes
22c0e7dd97 Bump versionCode 647 2025-01-25 15:38:21 +01:00
Phil
50c74f8a1e
Merge pull request #297 from PhilKes/feat/reminders-navigation
Add Reminders View to list Notes with Reminders
2025-01-25 15:34:43 +01:00
PhilKes
24279e6ba0 Add RemindersFragment to list notes with reminders 2025-01-25 15:30:29 +01:00
PhilKes
0db04c9fed Show reminder icon next to note title 2025-01-25 13:22:34 +01:00
Phil
ef1de6261a
Merge pull request #296 from PhilKes/fix/cursor-on-orientation-change
Re-set cursor and scroll position on orientation change
2025-01-25 12:57:29 +01:00
PhilKes
403b2e712a Re-set cursor and scroll position on orientation change 2025-01-25 12:55:30 +01:00
Phil
2b4a51cd0a
Merge pull request #295 from PhilKes/translation/update
Update cs/strings.xml
2025-01-25 11:38:46 +01:00
PhilKes
eb416a81f5 Update cs/strings.xml 2025-01-25 11:38:06 +01:00
PhilKes
66197fe3eb Bump versionCode 646 2025-01-24 18:13:05 +01:00
Phil
846937dc24
Merge pull request #291 from PhilKes/fix/autobackup-empty-note
Fix do not checkAutoSave if empty note was deleted
2025-01-24 18:09:31 +01:00
PhilKes
b11b07f45b Fix do not checkAutoSave if empty note was deleted 2025-01-24 18:07:28 +01:00
Phil
fbd2e9d327
Merge pull request #290 from PhilKes/fix/listitem
Fix glitchy SwipeLayout and reload of note on orientation change
2025-01-24 18:07:06 +01:00
PhilKes
924d984dc8 Bump android-translations-converter v1.0.4 2025-01-24 17:58:17 +01:00
PhilKes
fa3a335ea7 Fix reloading state when EditActivity is recreated 2025-01-24 16:58:47 +01:00
PhilKes
eb9c598e4c Fix PeriodicBackupsMax disable when BackupsFolder is empty 2025-01-24 16:58:47 +01:00
PhilKes
81483f5f14 Simplify undo check ListItem since its too complex 2025-01-24 16:58:47 +01:00
PhilKes
54a68a76e0 Simplify undo move since its too complex 2025-01-24 16:58:47 +01:00
PhilKes
d9b486d4ce Fix ErrorActivity launch fails due to WorkManager uninitialized 2025-01-24 16:58:47 +01:00
PhilKes
2d9bc8d0d2 Fix ListItem undo move with children 2025-01-24 16:58:47 +01:00
PhilKes
0dcfc2bcff Fix ListItem children init when entering EditListActivity 2025-01-24 16:58:47 +01:00
PhilKes
e98645b736 Replace zerobranch.SwipeLayout with Leaqi.SwipeDrawer 2025-01-24 16:58:47 +01:00
Phil
8f3de20da0
Merge pull request #289 from PhilKes/translation/update
Update fr/strings.xml
2025-01-24 16:54:12 +01:00
PhilKes
3ae2dd2b0f Update fr/strings.xml 2025-01-24 16:53:33 +01:00
PhilKes
f05d45c019 Remove removed translations from cs/strings.xml 2025-01-23 19:34:07 +01:00
PhilKes
a5a0a26d63 Bump versionCode 643 2025-01-23 19:24:55 +01:00
Phil
94e7075f1d
Merge pull request #284 from PhilKes/feat/backup-on-save
Add option for auto backup on note save + refactor backup settings
2025-01-23 19:24:06 +01:00
PhilKes
1983262262 Add option for auto backup on note save 2025-01-23 18:09:10 +01:00
PhilKes
41ba1e7c9b Split backups folder preference from periodic backups 2025-01-23 18:00:13 +01:00
Phil
6bfde1bf32
Merge pull request #218 from PhilKes/feat/date-format-note-view
Add setting to optionally apply date format to note view
2025-01-23 17:57:51 +01:00
PhilKes
4a494a8ca2 Add setting to optionally apply date format to note view 2025-01-23 17:56:29 +01:00
Phil
fe69699b43
Merge pull request #283 from PhilKes/translation/update
Update french and czech translations
2025-01-23 17:34:38 +01:00
PhilKes
9ad85ed459 Update fr/strings.xml 2025-01-23 17:30:54 +01:00
PhilKes
0b02391209 Update cs/strings.xml 2025-01-23 17:30:00 +01:00
PhilKes
199f2d5bed Set output APK names for all build variants 2025-01-23 17:26:23 +01:00
PhilKes
d5f3846946 Fix WorkManager not initialized yet 2025-01-23 11:38:25 +01:00
Phil
8e4f3fe58e
Merge pull request #278 from PhilKes/feat/reminders
Add ability to set reminders for a note + AutoBackup fixes
2025-01-22 19:09:09 +01:00
PhilKes
b70f7b173a Fix Autobackup scheduling 2025-01-22 18:24:39 +01:00
PhilKes
e75c8b3ada Show notification if auto backup fails 2025-01-22 18:23:56 +01:00
PhilKes
61c851fad0 Add ability to set reminders for a note 2025-01-22 18:23:56 +01:00
PhilKes
da000ea914 Log how many notes exported in AutoBackupWorker 2025-01-22 18:03:44 +01:00
PhilKes
8fc493385a Remove gravity top from any Chips according to docs 2025-01-22 17:59:27 +01:00
Phil
abc82ec0da
Merge pull request #274 from PhilKes/fix/google-keep-import
Support Google Keep import in any languages
2025-01-21 21:09:56 +01:00
PhilKes
434ee5f3ca Update google_keep_help translations 2025-01-21 18:57:27 +01:00
PhilKes
7234c1711b Generify Google Keep import for any language 2025-01-21 18:57:27 +01:00
PhilKes
d0b05872bc Bump android-translations-converter v1.0.3 2025-01-21 18:05:59 +01:00
PhilKes
de3accd066 Add file logs to AutoBackupWorker 2025-01-21 18:01:27 +01:00
PhilKes
089403acb0 Kotlinify util classes and adjust intent extra names 2025-01-21 17:55:26 +01:00
PhilKes
f076888643 Fix translations formatting parameters 2025-01-21 17:51:26 +01:00
Phil
b62905d0e0
Merge pull request #272 from PhilKes/fix/links
Fix insert link save and use full width for Link dialog
2025-01-20 19:08:03 +01:00
PhilKes
41078d8bcc Fix insert link save and use full width for Link dialog 2025-01-20 19:04:24 +01:00
Phil
a6ba47e435
Merge pull request #271 from PhilKes/fix/refresh-labels-hidden
Add labelsHiddenInOverview to NotallyXPreferences.reload
2025-01-20 18:43:08 +01:00
PhilKes
b056eb862a Add labelsHiddenInOverview to NotallyXPreferences.reload 2025-01-20 18:40:25 +01:00
PhilKes
65795eaf07 Set disabled state colors in setControlsColorForAllViews 2025-01-20 18:15:03 +01:00
PhilKes
ff4ca9e733 Add beta BuildType 2025-01-19 15:11:31 +01:00
Phil
55a00e1185
Merge pull request #265 from PhilKes/feat/note-full-color
Display entire interface in note's color
2025-01-19 14:53:35 +01:00
PhilKes
fe66db4f56 Display entire interface in note's color 2025-01-19 14:45:28 +01:00
PhilKes
a70bc19807 Bump v6.4.1 2025-01-17 22:08:59 +01:00
Phil
05c4607b48
Merge pull request #258 from PhilKes/translation/update
Update fr/strings.xml
2025-01-17 22:00:33 +01:00
PhilKes
1aea96a8a8 Update fr/strings.xml 2025-01-17 22:00:16 +01:00
Phil
0809a3cd0a
Merge pull request #257 from PhilKes/fix/6.4.0-fixes
Fix undo/redo initialization + recover if biometric lock and databas encryption mismatch
2025-01-17 21:57:35 +01:00
PhilKes
6b6354f4ea Recover from biometric lock and database encryption mismatch 2025-01-17 21:52:40 +01:00
PhilKes
e1f12dfa47 Fix undo/redo initialization 2025-01-17 21:27:07 +01:00
PhilKes
bc3c1f7373 Bump v6.4.0 2025-01-17 18:41:33 +01:00
Phil
ee301bd60f
Merge pull request #253 from PhilKes/fix/share-large-text
Limit sharing to 150000 characters due not exceed builtin limit
2025-01-17 18:32:45 +01:00
PhilKes
49c85868dd Limit sharing to 150000 characters due not exceed builtin limit 2025-01-17 18:28:44 +01:00
Phil
5f9583868a
Merge pull request #252 from PhilKes/fix/search-text-changes
Fix search results when text changes in search mode
2025-01-17 18:28:22 +01:00
PhilKes
b6e9ad7db3 Fix search results when text changes in search mode 2025-01-17 18:25:44 +01:00
PhilKes
1ee00e685c Use android-translations-converter for excel translations.xlsx generation 2025-01-17 18:04:45 +01:00
PhilKes
b64566af2b Migrate build.gradle files to Kotlin 2025-01-16 22:31:04 +01:00
Phil
5d98773373
Merge pull request #239 from PhilKes/fix/dataonexternalstorage-on-import
Trigger moving database if dataOnExternalStorage changed by import
2025-01-16 22:09:43 +01:00
PhilKes
02e97768ce Trigger moving database if dataOnExternalStorage changed by import 2025-01-16 22:09:34 +01:00
Phil
b55c01c94a
Merge pull request #238 from PhilKes/fix/user-unenrolls-biometric
Handle user un-enrolling device biometrics while biometric lock is enabled
2025-01-16 22:09:19 +01:00
PhilKes
3d4e5ff672 Handle if user un-enrolls device biometrics while biometric lock is enabled 2025-01-14 19:07:41 +01:00
Phil
eb602cc482
Merge pull request #237 from PhilKes/translations/update
Update fr/strings.xml
2025-01-14 18:55:17 +01:00
PhilKes
1a80a7bcfc Update fr/strings.xml 2025-01-14 18:53:12 +01:00
Phil
864ab3a176 Update README.md 2025-01-14 18:05:13 +01:00
Phil
c19cb25aad
Merge pull request #235 from PhilKes/translations/helper
Add translation helpers
2025-01-14 17:50:06 +01:00
PhilKes
686125298c Fix app shortcut icons 2025-01-14 17:41:29 +01:00
PhilKes
8116b69631 Add translation github issue template 2025-01-14 17:41:29 +01:00
PhilKes
531755c542 Add Android-Excel-Converter for managing all translations 2025-01-14 17:41:29 +01:00
Phil
1fc8b4e40d
Merge pull request #233 from PhilKes/fix/overview-fastscroll
Use builtin fastscroll of RecyclerView for notes overview
2025-01-13 20:12:10 +01:00
PhilKes
30575e371a Use builtin fastscroll of RecyclerView for notes overview 2025-01-13 18:52:37 +01:00
Phil
6e0375c9f4
Merge pull request #232 from PhilKes/fix/intent-choosers
Add IntentChooser to all external app Intents
2025-01-13 18:52:06 +01:00
PhilKes
6238e3d0c1 Add IntentChooser to all external app Intents 2025-01-13 18:11:16 +01:00
Phil
85ed0d57ab
Merge pull request #226 from PhilKes/feat/plain-text-import-improvments
Import either single text file or entire folder + restrict plain text import mimetypes
2025-01-13 18:07:40 +01:00
PhilKes
dcf25f5cdd Restrict plain text import mimetypes 2025-01-13 18:07:09 +01:00
PhilKes
716538308b Choose either single file or folder for plain text import 2025-01-13 18:07:09 +01:00
Phil
5875d03a0a
Merge pull request #224 from PhilKes/feat/clarify-menus
Move top more menu actions to bottom in EditActivity
2025-01-13 18:06:11 +01:00
PhilKes
9000fdbd36 Move top more menu actions to bottom in EditActivity 2025-01-12 14:45:06 +01:00
Phil
3e37d5751f
Merge pull request #225 from PhilKes/feat/ellipsize-basenotevh
Add end-ellipsize for Title and Note in BaseNoteVH
2025-01-12 11:52:27 +01:00
PhilKes
54df38fa9d Add end-ellipsize for Title and Note in BaseNoteVH 2025-01-12 11:50:16 +01:00
PhilKes
991f2e5f75 Fix fr/strings escaping quotation marks 2025-01-12 11:46:40 +01:00
Phil
0a478fe7c7
Merge pull request #223 from PhilKes/fix/labels-dialog
Fix CheckBox clickable in Labels dialog
2025-01-12 11:19:30 +01:00
PhilKes
f0e1f22b3a Fix CheckBox clickable in Labels dialog 2025-01-12 11:19:08 +01:00
Phil
28e58bae77
Merge pull request #217 from andiandi13/main
[French translation] New strings + small adjustments
2025-01-12 11:15:24 +01:00
andiandi13
6820d8c7ff New strings + small adjustments 2025-01-12 11:15:07 +01:00
Phil
8356b9af7e
Merge pull request #222 from PhilKes/fix/link-dialog-height
Make DialogTextInput2 scrollable vertically
2025-01-12 11:14:27 +01:00
PhilKes
f4b6b948b8 Make DialogTextInput2 scrollable vertically 2025-01-12 11:12:40 +01:00
Phil
1db35539fc
Merge pull request #216 from PhilKes/fix/note-list-icon
Fix display of list icon in BaseNoteVH
2025-01-11 15:50:10 +01:00
PhilKes
38218476e0 Fix display of list icon in BaseNoteVH 2025-01-11 15:42:44 +01:00
PhilKes
1b3e5b0ebc Update README.md 2025-01-11 15:38:27 +01:00
PhilKes
6a57d32d1f Update .gitignore 2025-01-11 15:35:13 +01:00
PhilKes
4e267e0721 Bump v6.4.0-BETA 2025-01-11 14:49:33 +01:00
PhilKes
7f546b8662 Fix backup_password_hint key in fr/strings.xml 2025-01-11 14:27:50 +01:00
Phil
c0caf7fde2
Merge pull request #214 from PhilKes/feat/bottom-appbar-scaling
Fix bottom appbar cutoff + add setting to hide labels in notes overview
2025-01-11 14:20:51 +01:00
PhilKes
3f3f2cb521 Add setting to hide labels in overview 2025-01-11 13:03:25 +01:00
PhilKes
76fbf98437 Decrease bottom appbar height and icon paddings 2025-01-11 11:27:09 +01:00
Phil
3c3d5b51a6
Merge pull request #210 from PhilKes/feat/fastscroll
Add fast scrollbar to overview and EditActivity
2025-01-11 11:26:50 +01:00
PhilKes
03b3fd44c4 Add fast scrollbar to overview and EditActivity 2025-01-11 11:23:28 +01:00
Phil
cae0307d9b
Merge pull request #211 from PhilKes/feat/import-export-settings
Add import/export/reset settings + refactor SettingsFragment
2025-01-11 11:20:57 +01:00
PhilKes
204fe89b55 Add import/export/reset settings + refactor SettingsFragment 2025-01-10 17:26:35 +01:00
Phil
ec0880463b
Merge pull request #195 from PhilKes/feat/max-lines-0
Allow max lines and items to be set to 0
2025-01-10 16:58:36 +01:00
PhilKes
45fcbd4c41 Display checkbox before list title if no items should be displayed 2025-01-10 16:47:59 +01:00
PhilKes
b0a7d9927f Allow max lines and items to be set to 0 2025-01-10 16:47:59 +01:00
Phil
8a175bb3c1
Merge pull request #196 from PhilKes/fix/settings-slider
Fix settings Slider debounce
2025-01-10 16:45:21 +01:00
PhilKes
69fa420da4 Fix settings Slider debounce 2025-01-10 16:45:08 +01:00
Phil
6863608412
Merge pull request #205 from andiandi13/main
Full french translation
2025-01-10 16:44:49 +01:00
andiandi13
baebc3bfe6 Full french translation
Well, I guess I translated all missing strings (some of them are not use by the app so I didn't add them)
2025-01-10 16:44:33 +01:00
Phil
575db7ec53
Merge pull request #197 from PhilKes/feat/select-all-notes
Add select all notes menu option
2025-01-10 16:44:23 +01:00
PhilKes
6e4dc48213 Add select all notes menu option 2025-01-08 18:25:41 +01:00
Phil
f44a2a904b
Merge pull request #193 from PhilKes/fix/bottom-appbar
Fix BottomAppBar overlap in EditActivity
2025-01-08 18:08:25 +01:00
PhilKes
7480e47977 Fix BottomAppBar overlap in EditActivity 2025-01-08 18:08:13 +01:00
PhilKes
f5b28efe76 Fix it/strings.xml quotation mark escaping 2025-01-08 18:08:13 +01:00
Phil
d88dee636d
Merge pull request #191 from andiandi13/main
Update strings.xml
2025-01-08 16:54:01 +01:00
andiandi13
058bb1743d
Update strings.xml 2025-01-07 13:27:28 +01:00
Phil
4778d301c4
Merge pull request #179 from mosiser/main
Update Italian translation
2024-12-29 17:20:13 +01:00
mosiser
c9382c2733
Update Italian translation
Translated into Italian all new strings, and edit some of the existing ones
2024-12-29 12:00:58 +01:00
PhilKes
30d5ee0a9e Bump v6.3.0 2024-12-23 11:41:02 +01:00
Pranshul
4529e3ffa4
Fix biometric lock issue on devices running Android 10 or 9 (#173) 2024-12-23 11:31:20 +01:00
Phil
a8d79e15d8
Merge pull request #174 from PhilKes/feat/paste-with-multiple-urls
Add clickable spans when text containing urls is pasted
2024-12-23 10:52:36 +01:00
PhilKes
a10fb399cb Add clickable spans when text containing urls is pasted 2024-12-23 10:49:24 +01:00
Phil
9a936774dc
Update README.md 2024-12-22 17:29:34 +01:00
Phil
f73acbca3a
Merge pull request #172 from PhilKes/feat/link-unnamed-note
Show edit link dialog when note without title is linked
2024-12-22 17:17:27 +01:00
PhilKes
4b08daa976 Show edit link dialog when note without title is linked 2024-12-22 17:15:16 +01:00
Phil
1efc195417
Merge pull request #171 from PhilKes/chore/error-activity
Add cat.ereza:customactivityoncrash to handle app crashes
2024-12-22 16:35:11 +01:00
PhilKes
37254a4948 Add cat.ereza:customactivityoncrash to handle app crashes 2024-12-22 16:31:15 +01:00
Phil
8c45728fb3
Merge pull request #169 from PhilKes/chore/new-theme
Clean Material 3 Theme
2024-12-22 15:39:13 +01:00
PhilKes
b353bc07c7 Update phoneScreenshots and featureGraphic 2024-12-21 19:59:42 +01:00
PhilKes
23574e52f1 Fix NotallyModel.isModified if only ListItem body changes 2024-12-21 19:59:42 +01:00
PhilKes
ca03ece684 Create clean Material 3 theme 2024-12-21 19:59:42 +01:00
Phil
22adfb8061
Merge pull request #159 from PhilKes/feat/bottom-navigation
Bottom menu inside Note for one-handed usage
2024-12-21 12:06:46 +01:00
PhilKes
cc24b9befa Bottom menu inside Note for one-handed usage 2024-12-21 12:02:01 +01:00
Phil
5702ffd103
Merge pull request #139 from PhilKes/feat/search-in-note
Add Search action in EditNoteActivity
2024-12-14 11:49:37 +01:00
PhilKes
8c31acaca6 Add Search action in EditListActivity 2024-12-14 11:43:01 +01:00
PhilKes
b03bba608b Add Search action in EditNoteActivity 2024-12-14 11:31:30 +01:00
PhilKes
766aedb923 Bump v6.2.2 2024-12-09 19:03:23 +01:00
Phil
88274485e4
Merge pull request #154 from PhilKes/feat/paste-list-text
Parse list syntax on paste in list note
2024-12-09 18:52:55 +01:00
PhilKes
d453e48473 Parse list syntax on paste in list note 2024-12-09 18:39:56 +01:00
Phil
16201e6a37
Merge pull request #155 from PhilKes/fix/del-empty-list-item
Fix delete list item on DEL key pressed
2024-12-09 18:39:39 +01:00
PhilKes
e968d192bc Fix delete list item on DEL key pressed 2024-12-09 18:39:13 +01:00
PhilKes
adfa094444 Update german translations 2024-12-09 18:38:41 +01:00
Phil
583b8061c4
Merge pull request #153 from PhilKes/chore/update-chinese
Update chinese translations
2024-12-09 16:58:42 +01:00
PhilKes
6125d41e05 Update chinese translations 2024-12-09 16:57:48 +01:00
PhilKes
df2cd719f2 Add audio notes description to app's description 2024-12-09 16:39:50 +01:00
PhilKes
c228eb341d Bump v6.2.1 2024-12-06 18:51:55 +01:00
Phil
e47f928f43
Merge pull request #146 from PhilKes/chore/material3-theme
Migrate theme to Material 3
2024-12-06 18:44:24 +01:00
PhilKes
d31404220d Migrate theme to Material 3 2024-12-06 17:28:35 +01:00
Phil
f6824c94af
Merge pull request #145 from PhilKes/fix/note-export-name
Note export file name fixes
2024-12-06 17:28:11 +01:00
PhilKes
5c97517692 Do not overwrite existing files when exporting notes 2024-12-06 17:21:15 +01:00
PhilKes
f20312a4b6 Fix file name for single note export 2024-12-06 17:21:15 +01:00
Phil
f558392c6d
Merge pull request #141 from PhilKes/feat/change-color-note-view
Add change color action to EditNoteActivity
2024-12-06 17:20:23 +01:00
PhilKes
5ba26e824a Add change color action to EditNoteActivity 2024-12-06 17:20:13 +01:00
PhilKes
aa5a14d962 Bump v6.2.0 2024-12-03 17:47:59 +01:00
Phil
8ba6adcac5
Merge pull request #138 from PhilKes/feat/label-new-note
Allow adding new note in Label View
2024-12-01 13:37:41 +01:00
PhilKes
a185443cab Allow adding new note in Label View 2024-12-01 13:23:06 +01:00
Phil
3c2f3d9cfb
Merge pull request #137 from PhilKes/fix/link-note-order
Display Link Note after Select All option
2024-12-01 13:10:30 +01:00
PhilKes
eac4545b3d Display Link Note after Select All option 2024-12-01 13:08:35 +01:00
Phil
94f47f4395
Merge pull request #122 from PhilKes/feat/import-txt
Add plain text importer + export multiple notes
2024-12-01 12:42:35 +01:00
PhilKes
a0572b2d73 Add exports for multiple selected notes 2024-12-01 12:41:30 +01:00
PhilKes
21a75ff2f0 Add plain text importer 2024-12-01 12:41:30 +01:00
Phil
750d2c53c9
Merge pull request #121 from PhilKes/feat/data-external-storage
Add setting to store all data on external storage
2024-12-01 12:41:18 +01:00
PhilKes
9cf0756417 Add setting to store all data on external storage 2024-12-01 12:39:43 +01:00
PhilKes
b8a6cbf3bf Bump v6.1.2 2024-11-19 18:21:55 +01:00
Phil
69bd1e8eda
Merge pull request #127 from PhilKes/fix/edittext-changehistory
Save note if any BaseNote fields changed + fix cursor position on undo/redo
2024-11-16 12:39:11 +01:00
PhilKes
b9235d8486 Fix cursor position on undo/redo 2024-11-16 12:37:42 +01:00
PhilKes
eb176fa1cb Fix saving model if any contents or metadata changed instead of using ChangeHistory.canUndo 2024-11-16 12:37:42 +01:00
PhilKes
c7c0c44bb4 Update README.md 2024-11-16 12:25:36 +01:00
Phil
eae6f816d5
Update README.md 2024-11-16 12:19:32 +01:00
PhilKes
c60221a609 Ensure unit tests are run before each apk/bundle build 2024-11-15 16:31:28 +01:00
PhilKes
8c32180330 Add v6.1.1 changelogs 2024-11-14 17:30:22 +01:00
PhilKes
702ddf548e Bump v6.1.1 2024-11-14 17:27:57 +01:00
PhilKes
ab9f2496e2 Add missing content descriptions and min height for touch targets 2024-11-10 20:36:44 +01:00
PhilKes
5f84c6449b Bump v6.1 2024-11-07 18:30:41 +01:00
PhilKes
eb2168b38a Set model Folder.NOTES in DisplayLabelFragment 2024-11-07 18:11:51 +01:00
Phil
4e3f69220b
Merge pull request #119 from PhilKes/fix/delete-empty-notes
Fix delete empty notes
2024-11-07 17:37:24 +01:00
PhilKes
d9ecf4f8ea Fix delete selected notes ids 2024-11-07 17:35:38 +01:00
PhilKes
e877ffb78d Rearrange folders' action menu items 2024-11-07 17:32:31 +01:00
PhilKes
972277ecac Fix fastlane titles + german locale code to de-DE 2024-11-07 17:30:03 +01:00
PhilKes
3dff81c8ee Bump v6.1-RC4 2024-11-06 20:45:18 +01:00
Phil
b1e7158f06
Merge pull request #116 from PhilKes/fix/auto-backup
Dont observe BiometricLock in zip auto-backup
2024-11-06 20:42:10 +01:00
PhilKes
aa069cef9d Dont observe BiometricLock in zip auto-backup 2024-11-06 20:41:30 +01:00
Phil
2357e6dca7
Merge pull request #114 from PhilKes/fix/android-navbar-color
Theme adjusts Android navbar color
2024-11-06 18:03:01 +01:00
PhilKes
b9dbdd31f8 Theme adjusts Android navbar color 2024-11-05 18:51:24 +01:00
PhilKes
051bb4baec Use BundleCompat and IntentCompat instead of util methods 2024-11-05 18:17:49 +01:00
PhilKes
3406991f3a Add signingConfigs to build.gradle 2024-11-05 18:17:49 +01:00
Phil
92220e9367
Merge pull request #113 from CodeNomadHQ/main
Translate Dutch Language to v6.1
2024-11-05 17:53:23 +01:00
CodeNomad
014ce555b9
Update strings.xml 2024-11-05 14:44:36 +01:00
PhilKes
e6de38a9b4 Remove github from nl/strings.xml 2024-11-04 18:32:15 +01:00
PhilKes
5c980f94cd Bump v6.1-RC3 2024-11-04 18:27:10 +01:00
Phil
6a01e69d1e
Merge pull request #107 from PhilKes/feat/hide-labels
Ability to hide labels in Navigation
2024-11-04 18:22:53 +01:00
PhilKes
b5d92d05a3 Refactor send feedback via mail/github 2024-11-04 18:22:05 +01:00
PhilKes
f7bfa58300 Add max labels to display in navigation preference 2024-11-04 17:55:54 +01:00
PhilKes
f42ce85938 Add navigation visibility toggle for Labels 2024-11-04 17:54:17 +01:00
Phil
58b625002d
Merge pull request #106 from PhilKes/chore/fastlane-google-play
Chore/fastlane google play
2024-11-04 17:54:02 +01:00
PhilKes
e7415653e6 Add v6.1 changelogs 2024-11-04 13:48:58 +01:00
PhilKes
1ae2a41b40 Clear data also deletes all Labels 2024-11-04 17:47:58 +01:00
PhilKes
f6e623341c Migrate to targetSdk 34 2024-11-04 17:44:55 +01:00
PhilKes
4e7fb47c5f Add fastlane featureGraphic.png 2024-11-04 17:42:34 +01:00
PhilKes
921abb166c Fix fastlane icon size 2024-11-04 17:41:34 +01:00
PhilKes
8a9c6a2483 Add fastlane files to .gitignore 2024-11-04 13:44:34 +01:00
PhilKes
bd042d53f4 Fix AutoBackup scheduling at startup 2024-11-04 13:44:24 +01:00
PhilKes
bbcd2cd562 Correctly cancel AutoBackup if preference changed 2024-11-04 13:44:24 +01:00
Phil
426293177b
Merge pull request #99 from PhilKes/feat/labels-navigation
Add navigation items for every label + refactor editing labels
2024-11-03 12:35:08 +01:00
PhilKes
9fb1c73144 Add navigation items for every label + refactor editing labels 2024-11-03 12:34:56 +01:00
Phil
c22337a7a2
Merge pull request #98 from PhilKes/feat/clear-search
Clear previous search term
2024-11-03 10:56:18 +01:00
PhilKes
533d620624 Clear previous search term 2024-11-03 10:56:12 +01:00
Phil
bc870955bb
Merge pull request #97 from PhilKes/feat/multi-selection
Long click notes to multi-select
2024-11-03 10:56:00 +01:00
PhilKes
fb35b4bdd3 Long click notes to multi-select 2024-11-03 10:55:55 +01:00
Phil
c21c847a83
Add F-Droid download link 2024-11-03 10:52:07 +01:00
Phil
193022078e
Merge pull request #94 from PhilKes/feat/label-pin-multiple
Allow to pin/label multiple notes
2024-11-02 11:40:01 +01:00
PhilKes
6ef6daa8c1 Allow to pin/label multiple notes 2024-11-01 15:20:15 +01:00
Phil
037418237b
Merge pull request #93 from PhilKes/feat/search-visible-everywhere
Add Search to Deleted and Archived Tabs
2024-11-01 15:03:30 +01:00
PhilKes
4dc1fbde6b Add Search to Deleted and Archived Tabs 2024-11-01 11:13:08 +01:00
Phil
418e226e02
Merge pull request #92 from PhilKes/chore/preferences-refactor
Refactor Preferences from Strings into Enums
2024-11-01 11:10:50 +01:00
PhilKes
231089a7e5 Fix pre-commit git add after ktfmt 2024-11-01 11:10:21 +01:00
PhilKes
e38e14bdd4 Refactor Preferences from objects to Enums 2024-11-01 11:10:21 +01:00
Phil
87279902ca
Merge pull request #87 from PhilKes/feat/lock-widget
Lock widget if app is locked
2024-11-01 10:42:16 +01:00
PhilKes
82215860bf Lock widget if app is locked 2024-11-01 10:41:52 +01:00
Phil
54d971334e
Add Privacy-Policy.md 2024-10-30 23:01:53 +01:00
Phil
a61f27575d
Merge pull request #88 from PhilKes/feat/delete-empty-note
When exiting a note delete it if it's empty
2024-10-30 22:29:29 +01:00
PhilKes
e84a5409e4 When exiting a note delete it if it's empty 2024-10-30 22:29:19 +01:00
Phil
3a16d3bb6a
Merge pull request #89 from PhilKes/chore/deprecations
Migrate startActivityForResult and other minor deprecations
2024-10-30 22:28:22 +01:00
PhilKes
e9e2221510 Migrate startActivityForResult and other minor deprecations 2024-10-30 22:28:12 +01:00
Phil
611b571f89
Add FUNDING.yml 2024-10-30 17:17:51 +01:00
446 changed files with 323215 additions and 12450 deletions

1
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1 @@
ko_fi: philkes

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

10
.github/ISSUE_TEMPLATE/translation.md vendored Normal file
View file

@ -0,0 +1,10 @@
---
name: Translation Update
about: Update translations by uploading updated translations.xlsx
title: '<INSERT LANGUAGE HERE> translations update'
labels: translations
assignees: ''
---
Drag'n'drop your updated translations.xlsx file here 🙂

56
.github/workflows/deploy.yaml vendored Normal file
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

8
.gitignore vendored
View file

@ -7,4 +7,10 @@
/captures /captures
.externalNativeBuild .externalNativeBuild
.cxx .cxx
*/.attach_pid* */.attach_pid*
fastlane/*
!fastlane/join-testers.png
!fastlane/metadata
Gemfile*
*.sh
!generate-changelogs.sh

View file

@ -18,6 +18,8 @@ if [ $? -ne 0 ]; then
fi fi
# Re-stage only the initially staged Kotlin files # Re-stage only the initially staged Kotlin files
echo "$initial_staged_files" | xargs git add for file in $initial_staged_files; do
git add "$file"
done
echo "Kotlin files formatted" echo "Kotlin files formatted"

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

25
Privacy-Policy.md Normal file
View file

@ -0,0 +1,25 @@
## Privacy Policy
This privacy policy applies to the NotallyX app (hereby referred to as "Application") for mobile devices that was created as an Open Source service. This service is intended for use "AS IS".
### What information does the Application obtain and how is it used?
The Application does not obtain any information when you download and use it. Registration is not required to use the Application.
### Does the Application collect precise real time location information of the device?
This Application does not collect precise information about the location of your mobile device.
### Do third parties see and/or have access to information obtained by the Application?
Since the Application does not collect any information, no data is shared with third parties.
### Your Consent
By using the Application, you are consenting to this Privacy Policy now and as amended by the developer.
### Contact Us
If you have any questions regarding privacy while using the Application, or have questions about the practices, please contact us via email at philkeyplaystore@gmail.com.
This privacy policy is effective as of 2024-10-30

View file

@ -1 +0,0 @@
No user data is collected

View file

@ -2,6 +2,13 @@
<img src="fastlane/metadata/android/en-US/images/icon.png" alt="icon" width="90"/> <img src="fastlane/metadata/android/en-US/images/icon.png" alt="icon" width="90"/>
<br /> <br />
<b>NotallyX | Minimalistic note taking app</b> <b>NotallyX | Minimalistic note taking app</b>
<p>
<center>
<a href='https://play.google.com/store/apps/details?id=com.philkes.notallyx&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' src='https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png' height='80'/></a>
<a href="https://f-droid.org/en/packages/com.philkes.notallyx"><img alt='IzzyOnDroid' height='80' src='https://fdroid.gitlab.io/artwork/badge/get-it-on.png' /></a>
<a href="https://apt.izzysoft.de/fdroid/index/apk/com.philkes.notallyx"><img alt='F-Droid' height='80' src='https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png' /></a>
</center>
</p>
</h2> </h2>
<div style="display: flex; justify-content: space-between; width: 100%;"> <div style="display: flex; justify-content: space-between; width: 100%;">
@ -20,7 +27,8 @@
[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
* 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
* **Color, pin and label** your notes for quick organisation * **Color, pin and label** your notes for quick organisation
@ -29,6 +37,7 @@
* Use **Home Screen Widget** to access important notes fast * Use **Home Screen Widget** to access important notes fast
* **Lock your notes via Biometric/PIN** * **Lock your notes via Biometric/PIN**
* Configurable **auto-backups** * Configurable **auto-backups**
* Create quick audio notes
* Display the notes either in a **List or Grid** * Display the notes either in a **List or Grid**
* Quickly share notes by text * Quickly share notes by text
* Extensive preferences to adjust views to your liking * Extensive preferences to adjust views to your liking
@ -36,19 +45,42 @@
* Adaptive android app icon * Adaptive android app icon
* Support for Lollipop devices and up * Support for Lollipop devices and up
--- ---
### Translations ### Bug Reports / Feature-Requests
All translations are crowd sourced. To contribute, follow these [guidelines](https://m2.material.io/design/communication/writing.html) and open a pull request. If you find any bugs or want to propose a new Feature/Enhancement, feel free to [create a new Issue](https://github.com/PhilKes/NotallyX/issues/new/choose)
When using the app and an unknown error occurs, causing the app to crash you will see a dialog (see showcase video in https://github.com/PhilKes/NotallyX/pull/171) from which you can immediately create a bug report on Github with the crash details pre-filled.
#### Beta Releases
I occasionally release BETA versions of the app during development, since its very valuable for me to get feedback before publicly releasing a new version.
These BETA releases have another `applicationId` as the release versions, thats why when you install a BETA version it will show up on your device as a separate app called `NotallyX BETA`.
BETA versions also have their own data, they do not use the data of your NotallyX app
You can download the most recent BETA release [here on Github](https://github.com/PhilKes/NotallyX/releases/tag/beta)
### Translations
All translations are crowd sourced.
To contribute:
1. Download current [translations.xlsx](https://github.com/PhilKes/NotallyX/raw/refs/heads/main/app/translations.xlsx)
2. Open in Excel/LibreOffice and add missing translations
Notes:
- Missing translations are marked in red
- You can filter by key or any language column values
- Non-Translatable strings are hidden and marked in gray, do not add translations for them
- For plurals, some languages need/have more quantity strings than others, if a quantity string in the default language (english) is not needed the row is highlighted in yellow. If your language does not need that quantity string either, ignore them.
3. Open a [Update Translations Issue](https://github.com/PhilKes/NotallyX/issues/new?assignees=&labels=translations&projects=&template=translation.md&title=%3CINSERT+LANGUAGE+HERE%3E+translations+update)
4. I will create a Pull-Request to add your updated translations
See [Android Translations Converter](https://github.com/PhilKes/android-translations-converter-plugin) for more details
### Contributing ### Contributing
If you find any bugs or want to propose a new Feature/Enhancement, feel free to [create a new Issue](https://github.com/PhilKes/NotallyX/issues/new)
If you would like to contribute code yourself, just grab any open issue (that has no other developer assigned yet) and start working. If you would like to contribute code yourself, just grab any open issue (that has no other developer assigned yet), leave a comment that you want to work on it and start developing by forking this repo.
The project is a default Android project written in Kotlin.
Before submitting your proposed changes as a Pull-Request, make sure all tests are still working, and run `./gradlew ktfmtFormat` for common formatting. The project is a default Android project written in Kotlin, I highly recommend using Android Studio for development. Also be sure to test your changes with an Android device/emulator that uses the same Android SDK Version as defined in the `build.gradle` `targetSdk`.
Before submitting your proposed changes as a Pull-Request, make sure all tests are still working (`./gradlew test`), and run `./gradlew ktfmtFormat` for common formatting (also executed automatically as pre-commit hook).
### Attribution ### Attribution
The original Notally project was developed by [OmGodse](https://github.com/OmGodse) under the [GPL 3.0 License](https://github.com/OmGodse/Notally/blob/master/LICENSE.md). The original Notally project was developed by [OmGodse](https://github.com/OmGodse) under the [GPL 3.0 License](https://github.com/OmGodse/Notally/blob/master/LICENSE.md).

View file

@ -1,142 +0,0 @@
import com.ncorti.ktfmt.gradle.tasks.KtfmtFormatTask
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-parcelize'
id 'com.google.devtools.ksp'
id 'com.ncorti.ktfmt.gradle' version '0.20.1'
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.0'
}
android {
compileSdk 34
namespace 'com.philkes.notallyx'
defaultConfig {
applicationId 'com.philkes.notallyx'
minSdk 21
targetSdk 33
versionCode 601
versionName "6.1-RC1"
resourceConfigurations += ['en', 'ca', 'cs', 'da', 'de', 'el', 'es', 'fr', 'hu', 'in', 'it', 'ja', 'my', 'nb', 'nl', 'nn', 'pl', 'pt-rBR', 'pt-rPT', 'ro', 'ru', 'sk', 'sv', 'tl', 'tr', 'uk', 'vi', 'zh-rCN']
vectorDrawables.generatedDensities = []
}
ksp {
arg("room.generateKotlin", "true")
arg("room.schemaLocation", "$projectDir/schemas")
}
buildTypes {
debug {
applicationIdSuffix ".debug"
}
release {
crunchPngs false
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
}
}
dependenciesInfo {
// Disables dependency metadata when building APKs.
includeInApk = false
// Disables dependency metadata when building Android App Bundles.
includeInBundle = false
}
kotlinOptions { jvmTarget = "1.8" }
buildFeatures { viewBinding true }
packagingOptions.resources {
excludes += ["DebugProbesKt.bin", "META-INF/**.version", "kotlin/**.kotlin_builtins", "kotlin-tooling-metadata.json"]
}
testOptions {
unitTests {
includeAndroidResources true
}
}
}
tasks.withType(KotlinCompile).configureEach {
kotlinOptions.jvmTarget = "1.8"
}
ktfmt {
kotlinLangStyle()
}
tasks.register('ktfmtPrecommit', KtfmtFormatTask) {
source = project.fileTree(rootDir)
include("**/*.kt")
}
tasks.register('installLocalGitHooks', Copy) {
def scriptsDir = new File(rootProject.rootDir, '.scripts/')
def hooksDir = new File(rootProject.rootDir, '.git/hooks')
from(scriptsDir) {
include 'pre-commit', 'pre-commit.bat'
}
into { hooksDir }
inputs.files(file("${scriptsDir}/pre-commit"), file("${scriptsDir}/pre-commit.bat"))
outputs.dir(hooksDir)
fileMode 0775
}
preBuild.dependsOn installLocalGitHooks
dependencies {
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'junit:junit:4.13.2'
final def navVersion = "2.3.5"
final def roomVersion = "2.6.1"
ksp "androidx.room:room-compiler:$roomVersion"
implementation "androidx.room:room-ktx:$roomVersion"
implementation "androidx.room:room-runtime:$roomVersion"
implementation "net.zetetic:android-database-sqlcipher:4.5.3"
implementation "androidx.sqlite:sqlite-ktx:2.4.0"
implementation "androidx.security:security-crypto:1.1.0-alpha06"
implementation 'net.lingala.zip4j:zip4j:2.11.5'
implementation "androidx.work:work-runtime:2.9.1"
//noinspection GradleDependency
implementation "androidx.navigation:navigation-ui-ktx:$navVersion"
//noinspection GradleDependency
implementation "androidx.navigation:navigation-fragment-ktx:$navVersion"
implementation "org.ocpsoft.prettytime:prettytime:4.0.6.Final"
implementation "com.google.android.material:material:1.12.0"
implementation 'com.github.zerobranch:SwipeLayout:1.3.1'
implementation "com.github.bumptech.glide:glide:4.15.1"
implementation "com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0"
implementation "com.google.code.findbugs:jsr305:3.0.2"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
implementation("org.simpleframework:simple-xml:2.7.1") {
exclude group: 'xpp3', module: 'xpp3'
}
implementation 'org.jsoup:jsoup:1.18.1'
testImplementation "junit:junit:4.13.2"
testImplementation "androidx.test:core:1.6.1"
testImplementation "androidx.test:core-ktx:1.6.1"
testImplementation "org.mockito:mockito-core:5.13.0"
testImplementation "org.mockito.kotlin:mockito-kotlin:5.4.0"
testImplementation "io.mockk:mockk:1.13.12"
testImplementation "org.json:json:20180813"
testImplementation "org.assertj:assertj-core:3.24.2"
testImplementation "org.robolectric:robolectric:4.13"
androidTestImplementation "androidx.room:room-testing:$roomVersion"
androidTestImplementation "androidx.work:work-testing:2.9.1"
}

223
app/build.gradle.kts Normal file
View file

@ -0,0 +1,223 @@
import com.android.build.gradle.internal.tasks.factory.dependsOn
import com.ncorti.ktfmt.gradle.tasks.KtfmtFormatTask
import org.apache.commons.configuration2.PropertiesConfiguration
import org.apache.commons.configuration2.io.FileHandler
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.parcelize")
id("com.google.devtools.ksp")
id("com.ncorti.ktfmt.gradle") version "0.20.1"
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.0"
id("io.github.philkes.android-translations-converter") version "1.0.5"
}
android {
namespace = "com.philkes.notallyx"
compileSdk = 34
ndkVersion = "29.0.13113456"
defaultConfig {
applicationId = "com.philkes.notallyx"
minSdk = 21
targetSdk = 34
versionCode = project.findProperty("app.versionCode").toString().toInt()
versionName = project.findProperty("app.versionName").toString()
resourceConfigurations += listOf(
"en", "ca", "cs", "da", "de", "el", "es", "fr", "hu", "in", "it", "ja", "my", "nb", "nl", "nn", "pl", "pt-rBR", "pt-rPT", "ro", "ru", "sk", "sv", "tl", "tr", "uk", "vi", "zh-rCN", "zh-rTW"
)
vectorDrawables.generatedDensities?.clear()
ndk {
debugSymbolLevel= "FULL"
}
}
ksp {
arg("room.generateKotlin", "true")
arg("room.schemaLocation", "$projectDir/schemas")
}
signingConfigs {
create("release") {
storeFile = file(providers.gradleProperty("RELEASE_STORE_FILE").get())
storePassword = providers.gradleProperty("RELEASE_STORE_PASSWORD").get()
keyAlias = providers.gradleProperty("RELEASE_KEY_ALIAS").get()
keyPassword = providers.gradleProperty("RELEASE_KEY_PASSWORD").get()
}
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-DEBUG"
resValue("string", "app_name", "NotallyX DEBUG")
}
release {
isCrunchPngs = false
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("release")
}
create("beta"){
initWith(getByName("release"))
applicationIdSuffix = ".beta"
versionNameSuffix = "-BETA"
resValue("string", "app_name", "NotallyX BETA")
}
}
applicationVariants.all {
this.outputs
.map { it as com.android.build.gradle.internal.api.ApkVariantOutputImpl }
.forEach { output ->
output.outputFileName = "NotallyX-$versionName.apk"
}
}
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
kotlinOptions {
jvmTarget = "1.8"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
buildFeatures {
viewBinding = true
}
packaging {
resources.excludes += listOf(
"DebugProbesKt.bin",
"META-INF/**.version",
"kotlin/**.kotlin_builtins",
"kotlin-tooling-metadata.json"
)
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
ktfmt {
kotlinLangStyle()
}
tasks.register<KtfmtFormatTask>("ktfmtPrecommit") {
source = project.fileTree(rootDir)
include("**/*.kt")
}
tasks.register<Copy>("installLocalGitHooks") {
val scriptsDir = File(rootProject.rootDir, ".scripts/")
val hooksDir = File(rootProject.rootDir, ".git/hooks")
from(scriptsDir) {
include("pre-commit", "pre-commit.bat")
}
into(hooksDir)
inputs.files(file("${scriptsDir}/pre-commit"), file("${scriptsDir}/pre-commit.bat"))
outputs.dir(hooksDir)
fileMode = 509 // 0775 octal in decimal
// If this throws permission denied:
// chmod +rwx ./.git/hooks/pre-commit*
}
tasks.preBuild.dependsOn(tasks.named("installLocalGitHooks"), tasks.exportTranslationsToExcel)
tasks.register("generateChangelogs") {
doLast {
val githubToken = providers.gradleProperty("CHANGELOG_GITHUB_TOKEN").orNull
val command = mutableListOf(
"bash",
rootProject.file("generate-changelogs.sh").absolutePath,
"v${project.findProperty("app.lastVersionName").toString()}",
rootProject.file("CHANGELOG.md").absolutePath
)
if (!githubToken.isNullOrEmpty()) {
command.add(githubToken)
} else {
println("CHANGELOG_GITHUB_TOKEN not found, which limits the allowed amount of Github API calls")
}
exec {
commandLine(command)
standardOutput = System.out
errorOutput = System.err
}
val config = PropertiesConfiguration()
val fileHandler = FileHandler(config).apply {
file = rootProject.file("gradle.properties")
load()
}
val currentVersionName = config.getProperty("app.versionName")
config.setProperty("app.lastVersionName", currentVersionName)
fileHandler.save()
println("Updated app.lastVersionName to $currentVersionName")
}
}
afterEvaluate {
tasks.named("bundleRelease").configure {
dependsOn(tasks.named("testReleaseUnitTest"))
}
tasks.named("assembleRelease").configure {
dependsOn(tasks.named("testReleaseUnitTest"))
finalizedBy(tasks.named("generateChangelogs"))
}
}
dependencies {
val navVersion = "2.3.5"
val roomVersion = "2.6.1"
implementation("androidx.navigation:navigation-fragment-ktx:$navVersion")
implementation("androidx.navigation:navigation-ui-ktx:$navVersion")
implementation("androidx.preference:preference-ktx:1.2.1")
ksp("androidx.room:room-compiler:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("androidx.sqlite:sqlite-ktx:2.4.0")
implementation("androidx.work:work-runtime:2.9.1")
implementation("androidx.biometric:biometric:1.1.0")
implementation("cat.ereza:customactivityoncrash:2.4.0")
implementation("com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0")
implementation("com.github.bumptech.glide:glide:4.15.1")
implementation("cn.Leaqi:SwipeDrawer:1.6")
implementation("com.github.skydoves:colorpickerview:2.3.0")
implementation("com.google.android.material:material:1.12.0")
implementation("com.google.code.findbugs:jsr305:3.0.2")
implementation("me.zhanghai.android.fastscroll:library:1.3.0")
implementation("net.lingala.zip4j:zip4j:2.11.5")
implementation("net.zetetic:android-database-sqlcipher:4.5.3")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("org.jsoup:jsoup:1.18.1")
implementation("org.ocpsoft.prettytime:prettytime:4.0.6.Final")
implementation("org.simpleframework:simple-xml:2.7.1") {
exclude(group = "xpp3", module = "xpp3")
}
androidTestImplementation("androidx.room:room-testing:$roomVersion")
androidTestImplementation("androidx.work:work-testing:2.9.1")
testImplementation("androidx.arch.core:core-testing:2.2.0")
testImplementation("androidx.test:core-ktx:1.6.1")
testImplementation("androidx.test:core:1.6.1")
testImplementation("io.mockk:mockk:1.13.12")
testImplementation("junit:junit:4.13.2")
testImplementation("org.assertj:assertj-core:3.24.2")
testImplementation("org.json:json:20180813")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
testImplementation("org.mockito:mockito-core:5.13.0")
testImplementation("org.robolectric:robolectric:4.13")
}

271494
app/obfuscation/mapping.txt Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
# Add project specific ProGuard rules here. # Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the # You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle. # proguardFiles setting in build.gradle.kts.
# #
# For more details, see # For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html # http://developer.android.com/guide/developing/tools/proguard.html
@ -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
@ -46,4 +44,6 @@
} }
-keep class * implements org.simpleframework.xml.core.Extractor { -keep class * implements org.simpleframework.xml.core.Extractor {
public *; public *;
} }
-keep class * implements java.io.Serializable

View file

@ -2,11 +2,11 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 6, "version": 6,
"identityHash": "a0ebadcc625f8b49bf549975d7288f10", "identityHash": "3ac03ff6740f6a6bcb19de11c7b3d750",
"entities": [ "entities": [
{ {
"tableName": "BaseNote", "tableName": "BaseNote",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `folder` TEXT NOT NULL, `color` TEXT NOT NULL, `title` TEXT NOT NULL, `pinned` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `modifiedTimestamp` INTEGER NOT NULL, `labels` TEXT NOT NULL, `body` TEXT NOT NULL, `spans` TEXT NOT NULL, `items` TEXT NOT NULL, `images` TEXT NOT NULL, `files` TEXT NOT NULL, `audios` TEXT NOT NULL)", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `folder` TEXT NOT NULL, `color` TEXT NOT NULL, `title` TEXT NOT NULL, `pinned` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `modifiedTimestamp` INTEGER NOT NULL, `labels` TEXT NOT NULL, `body` TEXT NOT NULL, `spans` TEXT NOT NULL, `items` TEXT NOT NULL, `images` TEXT NOT NULL, `files` TEXT NOT NULL, `audios` TEXT NOT NULL, `reminders` TEXT NOT NULL)",
"fields": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@ -97,6 +97,12 @@
"columnName": "audios", "columnName": "audios",
"affinity": "TEXT", "affinity": "TEXT",
"notNull": true "notNull": true
},
{
"fieldPath": "reminders",
"columnName": "reminders",
"affinity": "TEXT",
"notNull": true
} }
], ],
"primaryKey": { "primaryKey": {
@ -146,7 +152,7 @@
"views": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a0ebadcc625f8b49bf549975d7288f10')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3ac03ff6740f6a6bcb19de11c7b3d750')"
] ]
} }
} }

View file

@ -0,0 +1,158 @@
{
"formatVersion": 1,
"database": {
"version": 7,
"identityHash": "3ac03ff6740f6a6bcb19de11c7b3d750",
"entities": [
{
"tableName": "BaseNote",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `folder` TEXT NOT NULL, `color` TEXT NOT NULL, `title` TEXT NOT NULL, `pinned` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `modifiedTimestamp` INTEGER NOT NULL, `labels` TEXT NOT NULL, `body` TEXT NOT NULL, `spans` TEXT NOT NULL, `items` TEXT NOT NULL, `images` TEXT NOT NULL, `files` TEXT NOT NULL, `audios` TEXT NOT NULL, `reminders` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "modifiedTimestamp",
"columnName": "modifiedTimestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "labels",
"columnName": "labels",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "spans",
"columnName": "spans",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "items",
"columnName": "items",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "images",
"columnName": "images",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "files",
"columnName": "files",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "audios",
"columnName": "audios",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "reminders",
"columnName": "reminders",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_BaseNote_id_folder_pinned_timestamp_labels",
"unique": false,
"columnNames": [
"id",
"folder",
"pinned",
"timestamp",
"labels"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_BaseNote_id_folder_pinned_timestamp_labels` ON `${TABLE_NAME}` (`id`, `folder`, `pinned`, `timestamp`, `labels`)"
}
],
"foreignKeys": []
},
{
"tableName": "Label",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`value` TEXT NOT NULL, PRIMARY KEY(`value`))",
"fields": [
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"value"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3ac03ff6740f6a6bcb19de11c7b3d750')"
]
}
}

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,12 +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_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" /> <uses-permission android:name="android.permission.USE_FINGERPRINT" />
<application <application
@ -40,8 +43,7 @@
<activity <activity
android:name=".presentation.activity.main.MainActivity" android:name=".presentation.activity.main.MainActivity"
android:exported="true" android:exported="true">
android:theme="@style/MainActivity">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@ -66,15 +68,38 @@
<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" />
<activity android:name=".presentation.activity.note.SelectLabelsActivity" /> <activity android:name=".presentation.activity.note.SelectLabelsActivity" />
<activity android:name=".presentation.activity.note.reminders.RemindersActivity" />
<activity <activity
android:name=".presentation.activity.note.RecordAudioActivity" android:name=".presentation.activity.note.RecordAudioActivity"
android:launchMode="singleTask" /> android:launchMode="singleTask" />
@ -96,6 +121,16 @@
android:exported="false"> android:exported="false">
</activity> </activity>
<activity
android:name=".utils.ErrorActivity"
android:exported="true"
android:label="@string/unknown_error"
android:process=":error_activity">
<intent-filter>
<action android:name="cat.ereza.customactivityoncrash.ERROR" />
</intent-filter>
</activity>
<receiver <receiver
android:name=".presentation.widget.WidgetProvider" android:name=".presentation.widget.WidgetProvider"
android:exported="false" android:exported="false"
@ -111,6 +146,14 @@
</receiver> </receiver>
<receiver android:name=".presentation.activity.note.reminders.ReminderReceiver" android:enabled="true" android:exported="true" >
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</receiver>
<service <service
android:name=".presentation.widget.WidgetService" android:name=".presentation.widget.WidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" /> android:permission="android.permission.BIND_REMOTEVIEWS" />
@ -122,7 +165,8 @@
<service <service
android:name=".utils.audio.AudioPlayService" android:name=".utils.audio.AudioPlayService"
android:exported="false" /> android:exported="false"
android:foregroundServiceType="mediaPlayback" />
</application> </application>

View file

@ -0,0 +1,83 @@
package android.print
import android.content.ContentResolver
import android.content.Context
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.documentfile.provider.DocumentFile
import com.philkes.notallyx.utils.nameWithoutExtension
/**
* Needs to be in android.print package to access the package private methods of
* [PrintDocumentAdapter]
*/
fun Context.printPdf(file: DocumentFile, content: String, pdfPrintListener: PdfPrintListener) {
val webView = WebView(this)
webView.loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
webView.webViewClient =
object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
val adapter = webView.createPrintDocumentAdapter(file.nameWithoutExtension!!)
contentResolver.printPdf(file, adapter, pdfPrintListener)
}
}
}
private fun ContentResolver.printPdf(
file: DocumentFile,
adapter: PrintDocumentAdapter,
pdfPrintListener: PdfPrintListener,
) {
val onLayoutResult =
object : PrintDocumentAdapter.LayoutResultCallback() {
override fun onLayoutFailed(error: CharSequence?) {
pdfPrintListener.onFailure(error)
}
override fun onLayoutFinished(info: PrintDocumentInfo?, changed: Boolean) {
this@printPdf.writeToFile(file, adapter, pdfPrintListener)
}
}
adapter.onLayout(null, createPrintAttributes(), null, onLayoutResult, null)
}
private fun ContentResolver.writeToFile(
file: DocumentFile,
adapter: PrintDocumentAdapter,
pdfPrintListener: PdfPrintListener,
) {
val onWriteResult =
object : PrintDocumentAdapter.WriteResultCallback() {
override fun onWriteFailed(error: CharSequence?) {
pdfPrintListener.onFailure(error)
}
override fun onWriteFinished(pages: Array<out PageRange>?) {
pdfPrintListener.onSuccess(file)
}
}
val pages = arrayOf(PageRange.ALL_PAGES)
val fileDescriptor = openFileDescriptor(file.uri, "rw")
adapter.onWrite(pages, fileDescriptor, null, onWriteResult)
}
private fun createPrintAttributes(): PrintAttributes {
return with(PrintAttributes.Builder()) {
setMediaSize(PrintAttributes.MediaSize.ISO_A4)
setMinMargins(PrintAttributes.Margins.NO_MARGINS)
setResolution(PrintAttributes.Resolution("Standard", "Standard", 100, 100))
build()
}
}
interface PdfPrintListener {
fun onSuccess(file: DocumentFile)
fun onFailure(message: CharSequence?)
}

View file

@ -1,79 +0,0 @@
package android.print
import android.content.Context
import android.os.ParcelFileDescriptor
import android.webkit.WebView
import android.webkit.WebViewClient
import java.io.File
/**
* This class needs to be in android.print package to access the package private methods of
* [PrintDocumentAdapter]
*/
object PostPDFGenerator {
fun create(file: File, content: String, context: Context, onResult: OnResult) {
val webView = WebView(context)
webView.loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
webView.webViewClient =
object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
val adapter = webView.createPrintDocumentAdapter(file.nameWithoutExtension)
print(file, adapter, onResult)
}
}
}
private fun print(file: File, adapter: PrintDocumentAdapter, onResult: OnResult) {
val onLayoutResult =
object : PrintDocumentAdapter.LayoutResultCallback() {
override fun onLayoutFailed(error: CharSequence?) {
onResult.onFailure(error)
}
override fun onLayoutFinished(info: PrintDocumentInfo?, changed: Boolean) {
writeToFile(file, adapter, onResult)
}
}
adapter.onLayout(null, getPrintAttributes(), null, onLayoutResult, null)
}
private fun writeToFile(file: File, adapter: PrintDocumentAdapter, onResult: OnResult) {
val onWriteResult =
object : PrintDocumentAdapter.WriteResultCallback() {
override fun onWriteFailed(error: CharSequence?) {
onResult.onFailure(error)
}
override fun onWriteFinished(pages: Array<out PageRange>?) {
onResult.onSuccess(file)
}
}
val pages = arrayOf(PageRange.ALL_PAGES)
if (!file.exists()) {
file.createNewFile()
}
val fileDescriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_WRITE)
adapter.onWrite(pages, fileDescriptor, null, onWriteResult)
}
private fun getPrintAttributes(): PrintAttributes {
val builder = PrintAttributes.Builder()
builder.setMediaSize(PrintAttributes.MediaSize.ISO_A4)
builder.setMinMargins(PrintAttributes.Margins.NO_MARGINS)
builder.setResolution(PrintAttributes.Resolution("Standard", "Standard", 100, 100))
return builder.build()
}
interface OnResult {
fun onSuccess(file: File)
fun onFailure(message: CharSequence?)
}
}

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,51 +1,191 @@
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.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
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 com.philkes.notallyx.presentation.view.misc.BiometricLock.enabled import androidx.work.WorkInfo
import com.philkes.notallyx.presentation.view.misc.Theme import androidx.work.WorkManager
import com.philkes.notallyx.utils.backup.Export.scheduleAutoBackup import com.google.android.material.color.DynamicColors
import com.philkes.notallyx.presentation.setEnabledSecureFlag
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.EMPTY_PATH
import com.philkes.notallyx.presentation.viewmodel.preference.Theme
import com.philkes.notallyx.presentation.widget.WidgetProvider
import com.philkes.notallyx.utils.backup.AUTO_BACKUP_WORK_NAME
import com.philkes.notallyx.utils.backup.autoBackupOnSave
import com.philkes.notallyx.utils.backup.cancelAutoBackup
import com.philkes.notallyx.utils.backup.containsNonCancelled
import com.philkes.notallyx.utils.backup.deleteModifiedNoteBackup
import com.philkes.notallyx.utils.backup.isEqualTo
import com.philkes.notallyx.utils.backup.modifiedNoteBackupExists
import com.philkes.notallyx.utils.backup.scheduleAutoBackup
import com.philkes.notallyx.utils.backup.updateAutoBackup
import com.philkes.notallyx.utils.observeOnce
import com.philkes.notallyx.utils.security.UnlockReceiver import com.philkes.notallyx.utils.security.UnlockReceiver
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class NotallyXApplication : Application() { class NotallyXApplication : Application(), Application.ActivityLifecycleCallbacks {
private lateinit var biometricLockObserver: Observer<String> private lateinit var biometricLockObserver: Observer<BiometricLock>
private lateinit var preferences: Preferences private lateinit var preferences: NotallyXPreferences
private var unlockReceiver: UnlockReceiver? = null private var unlockReceiver: UnlockReceiver? = null
var isLocked = true val locked = NotNullLiveData(true)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
registerActivityLifecycleCallbacks(this)
preferences = Preferences.getInstance(this) if (isTestRunner()) return
preferences.theme.observeForever { theme -> preferences = NotallyXPreferences.getInstance(this)
if (preferences.useDynamicColors.value) {
if (DynamicColors.isDynamicColorAvailable()) {
DynamicColors.applyToActivitiesIfAvailable(this)
}
} else {
setTheme(R.style.AppTheme)
}
preferences.theme.observeForeverWithPrevious { (oldTheme, theme) ->
when (theme) { when (theme) {
Theme.dark -> Theme.DARK ->
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
Theme.light ->
Theme.LIGHT ->
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
Theme.followSystem ->
Theme.FOLLOW_SYSTEM ->
AppCompatDelegate.setDefaultNightMode( AppCompatDelegate.setDefaultNightMode(
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
) )
} }
if (oldTheme != null) {
WidgetProvider.updateWidgets(this, locked = locked.value)
}
} }
scheduleAutoBackup(preferences.autoBackupPeriodDays.value.toLong(), this) preferences.backupsFolder.observeForeverWithPrevious { (backupFolderBefore, backupFolder) ->
checkUpdatePeriodicBackup(
backupFolderBefore,
backupFolder,
preferences.periodicBackups.value.periodInDays.toLong(),
)
}
preferences.periodicBackups.observeForever { value ->
val backupFolder = preferences.backupsFolder.value
checkUpdatePeriodicBackup(backupFolder, backupFolder, value.periodInDays.toLong())
}
val filter = IntentFilter().apply { addAction(Intent.ACTION_SCREEN_OFF) } val filter = IntentFilter().apply { addAction(Intent.ACTION_SCREEN_OFF) }
biometricLockObserver = Observer { biometricLockObserver = Observer { biometricLock ->
if (it == enabled) { if (biometricLock == BiometricLock.ENABLED) {
unlockReceiver = UnlockReceiver(this) unlockReceiver = UnlockReceiver(this)
registerReceiver(unlockReceiver, filter) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
} else if (unlockReceiver != null) { registerReceiver(unlockReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
unregisterReceiver(unlockReceiver) } else {
registerReceiver(unlockReceiver, filter)
}
} else {
unlockReceiver?.let { unregisterReceiver(it) }
if (locked.value) {
locked.postValue(false)
}
} }
} }
preferences.biometricLock.observeForever(biometricLockObserver) preferences.biometricLock.observeForever(biometricLockObserver)
locked.observeForever { isLocked -> WidgetProvider.updateWidgets(this, locked = isLocked) }
preferences.backupPassword.observeForeverWithPrevious {
(previousBackupPassword, backupPassword) ->
if (preferences.backupOnSave.value) {
val backupPath = preferences.backupsFolder.value
if (backupPath != EMPTY_PATH) {
if (
!modifiedNoteBackupExists(backupPath) ||
(previousBackupPassword != null &&
previousBackupPassword != backupPassword)
) {
deleteModifiedNoteBackup(backupPath)
MainScope().launch {
withContext(Dispatchers.IO) {
autoBackupOnSave(
backupPath,
savedNote = null,
password = backupPassword,
)
}
}
}
}
}
}
} }
private fun checkUpdatePeriodicBackup(
backupFolderBefore: String?,
backupFolder: String,
periodInDays: Long,
) {
val workManager = getWorkManagerSafe() ?: return
workManager.getWorkInfosForUniqueWorkLiveData(AUTO_BACKUP_WORK_NAME).observeOnce { workInfos
->
if (backupFolder == EMPTY_PATH || periodInDays < 1) {
if (workInfos?.containsNonCancelled() == true) {
workManager.cancelAutoBackup()
}
} else if (
workInfos.isNullOrEmpty() ||
workInfos.all { it.state == WorkInfo.State.CANCELLED } ||
(backupFolderBefore != null && backupFolderBefore != backupFolder)
) {
workManager.scheduleAutoBackup(this, periodInDays)
} else if (
workInfos.first().periodicityInfo?.isEqualTo(periodInDays, TimeUnit.DAYS) == false
) {
workManager.updateAutoBackup(workInfos, periodInDays)
}
}
}
private fun getWorkManagerSafe(): WorkManager? {
return try {
WorkManager.getInstance(this)
} catch (e: Exception) {
// TODO: Happens when ErrorActivity is launched
null
}
}
companion object {
private fun isTestRunner(): Boolean {
return Build.FINGERPRINT.equals("robolectric", ignoreCase = true)
}
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
activity.setEnabledSecureFlag(preferences.secureFlag.value)
}
override fun onActivityStarted(activity: Activity) {}
override fun onActivityResumed(activity: Activity) {}
override fun onActivityPaused(activity: Activity) {}
override fun onActivityStopped(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {}
} }

View file

@ -1,222 +0,0 @@
package com.philkes.notallyx
import android.app.Application
import android.os.Build
import android.preference.PreferenceManager
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.philkes.notallyx.data.model.toPreservedByteArray
import com.philkes.notallyx.data.model.toPreservedString
import com.philkes.notallyx.presentation.view.misc.AutoBackup
import com.philkes.notallyx.presentation.view.misc.AutoBackupMax
import com.philkes.notallyx.presentation.view.misc.AutoBackupPeriodDays
import com.philkes.notallyx.presentation.view.misc.BackupPassword
import com.philkes.notallyx.presentation.view.misc.BetterLiveData
import com.philkes.notallyx.presentation.view.misc.BiometricLock
import com.philkes.notallyx.presentation.view.misc.DateFormat
import com.philkes.notallyx.presentation.view.misc.ListInfo
import com.philkes.notallyx.presentation.view.misc.ListItemSorting
import com.philkes.notallyx.presentation.view.misc.MaxItems
import com.philkes.notallyx.presentation.view.misc.MaxLines
import com.philkes.notallyx.presentation.view.misc.MaxTitle
import com.philkes.notallyx.presentation.view.misc.NotesSorting
import com.philkes.notallyx.presentation.view.misc.SeekbarInfo
import com.philkes.notallyx.presentation.view.misc.SortDirection
import com.philkes.notallyx.presentation.view.misc.TextInfo
import com.philkes.notallyx.presentation.view.misc.TextSize
import com.philkes.notallyx.presentation.view.misc.Theme
import com.philkes.notallyx.presentation.view.misc.View
import java.security.SecureRandom
import javax.crypto.Cipher
private const val DATABASE_ENCRYPTION_KEY = "database_encryption_key"
private const val ENCRYPTION_IV = "encryption_iv"
/**
* Custom implementation of androidx.preference library Way faster, simpler and smaller, logic of
* storing preferences has been decoupled from their UI. It is backed by SharedPreferences but it
* should be trivial to shift to another source if needed.
*/
class Preferences private constructor(app: Application) {
private val preferences = PreferenceManager.getDefaultSharedPreferences(app)
private val editor = preferences.edit()
private val encryptedPreferences by lazy {
EncryptedSharedPreferences.create(
app,
"secret_shared_prefs",
MasterKey.Builder(app).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
// Main thread (unfortunately)
val view = BetterLiveData(getListPref(View))
val theme = BetterLiveData(getListPref(Theme))
val dateFormat = BetterLiveData(getListPref(DateFormat))
val notesSorting = BetterLiveData(getNotesSorting(NotesSorting))
val textSize = BetterLiveData(getListPref(TextSize))
val listItemSorting = BetterLiveData(getListPref(ListItemSorting))
var maxItems = getSeekbarPref(MaxItems)
var maxLines = getSeekbarPref(MaxLines)
var maxTitle = getSeekbarPref(MaxTitle)
val autoBackupPath = BetterLiveData(getTextPref(AutoBackup))
var autoBackupPeriodDays = BetterLiveData(getSeekbarPref(AutoBackupPeriodDays))
var autoBackupMax = getSeekbarPref(AutoBackupMax)
val backupPassword by lazy { BetterLiveData(getEncryptedTextPref(BackupPassword)) }
val biometricLock = BetterLiveData(getListPref(BiometricLock))
var iv: ByteArray?
get() = preferences.getString(ENCRYPTION_IV, null)?.toPreservedByteArray
set(value) {
editor.putString(ENCRYPTION_IV, value?.toPreservedString)
editor.commit()
}
fun getDatabasePassphrase(): ByteArray {
val string = preferences.getString(DATABASE_ENCRYPTION_KEY, "")!!
return string.toPreservedByteArray
}
fun generatePassphrase(cipher: Cipher): ByteArray {
val random =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
SecureRandom.getInstanceStrong()
} else {
SecureRandom()
}
val result = ByteArray(64)
random.nextBytes(result)
// filter out zero byte values, as SQLCipher does not like them
while (result.contains(0)) {
random.nextBytes(result)
}
val encryptedPassphrase = cipher.doFinal(result)
editor.putString(DATABASE_ENCRYPTION_KEY, encryptedPassphrase.toPreservedString)
editor.commit()
return result
}
private fun getListPref(info: ListInfo) =
requireNotNull(preferences.getString(info.key, info.defaultValue))
private fun getNotesSorting(info: NotesSorting): Pair<String, SortDirection> {
val sortBy = requireNotNull(preferences.getString(info.key, info.defaultValue))
val sortDirection =
requireNotNull(preferences.getString(info.directionKey, info.defaultValueDirection))
return Pair(sortBy, SortDirection.valueOf(sortDirection))
}
private fun getTextPref(info: TextInfo) =
requireNotNull(preferences.getString(info.key, info.defaultValue))
private fun getEncryptedTextPref(info: TextInfo) =
requireNotNull(encryptedPreferences!!.getString(info.key, info.defaultValue))
private fun getSeekbarPref(info: SeekbarInfo) =
requireNotNull(preferences.getInt(info.key, info.defaultValue))
fun getWidgetData(id: Int) = preferences.getLong("widget:$id", 0)
fun deleteWidget(id: Int) {
editor.remove("widget:$id")
editor.commit()
}
fun updateWidget(id: Int, noteId: Long) {
editor.putLong("widget:$id", noteId)
editor.commit()
}
fun getUpdatableWidgets(noteIds: LongArray): List<Pair<Int, Long>> {
val updatableWidgets = ArrayList<Pair<Int, Long>>()
val pairs = preferences.all
pairs.keys.forEach { key ->
val token = "widget:"
if (key.startsWith(token)) {
val end = key.substringAfter(token)
val id = end.toIntOrNull()
if (id != null) {
val value = pairs[key] as? Long
if (value != null) {
if (noteIds.contains(value)) {
updatableWidgets.add(Pair(id, value))
}
}
}
}
}
return updatableWidgets
}
fun savePreference(info: SeekbarInfo, value: Int) {
editor.putInt(info.key, value)
editor.commit()
when (info) {
MaxItems -> maxItems = getSeekbarPref(MaxItems)
MaxLines -> maxLines = getSeekbarPref(MaxLines)
MaxTitle -> maxTitle = getSeekbarPref(MaxTitle)
AutoBackupMax -> autoBackupMax = getSeekbarPref(AutoBackupMax)
AutoBackupPeriodDays ->
autoBackupPeriodDays.postValue(getSeekbarPref(AutoBackupPeriodDays))
}
}
fun savePreference(info: NotesSorting, sortBy: String, sortDirection: SortDirection) {
editor.putString(info.key, sortBy)
editor.putString(info.directionKey, sortDirection.name)
editor.commit()
notesSorting.postValue(getNotesSorting(info))
}
fun savePreference(info: ListInfo, value: String) {
editor.putString(info.key, value)
editor.commit()
when (info) {
View -> view.postValue(getListPref(info))
Theme -> theme.postValue(getListPref(info))
DateFormat -> dateFormat.postValue(getListPref(info))
TextSize -> textSize.postValue(getListPref(info))
ListItemSorting -> listItemSorting.postValue(getListPref(info))
BiometricLock -> biometricLock.postValue(getListPref(info))
else -> return
}
}
fun savePreference(info: TextInfo, value: String) {
val editor = if (info is BackupPassword) encryptedPreferences!!.edit() else this.editor
editor.putString(info.key, value)
editor.commit()
when (info) {
AutoBackup -> autoBackupPath.postValue(getTextPref(info))
BackupPassword -> backupPassword.postValue(getEncryptedTextPref(info))
}
}
fun showDateCreated(): Boolean {
return dateFormat.value != DateFormat.none
}
companion object {
@Volatile private var instance: Preferences? = null
fun getInstance(app: Application): Preferences {
return instance
?: synchronized(this) {
val instance = Preferences(app)
Companion.instance = instance
return instance
}
}
}
}

View file

@ -1,222 +0,0 @@
package com.philkes.notallyx.data
import android.app.Application
import android.content.ContentResolver
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.documentfile.provider.DocumentFile
import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.data.model.FileAttachment
import com.philkes.notallyx.presentation.getFileName
import com.philkes.notallyx.presentation.viewmodel.NotallyModel.FileType
import com.philkes.notallyx.utils.FileError
import com.philkes.notallyx.utils.IO.copyToFile
import com.philkes.notallyx.utils.IO.getExternalAudioDirectory
import com.philkes.notallyx.utils.IO.getExternalFilesDirectory
import com.philkes.notallyx.utils.IO.getExternalImagesDirectory
import com.philkes.notallyx.utils.IO.rename
import com.philkes.notallyx.utils.Operations
import java.io.File
import java.io.FileInputStream
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class DataUtil {
companion object {
suspend fun addFile(
app: Application,
uri: Uri,
directory: File,
fileType: FileType,
errorWhileRenaming: Int = R.string.error_while_renaming_file,
proposedMimeType: String? = null,
): Pair<FileAttachment?, FileError?> {
return withContext(Dispatchers.IO) {
val document = requireNotNull(DocumentFile.fromSingleUri(app, uri))
val displayName = document.name ?: app.getString(R.string.unknown_name)
try {
/*
If we have reached this point, an SD card (emulated or real) exists and externalRoot
is not null. externalRoot.exists() can be false if the folder `Images` has been deleted after
the previous line, but externalRoot itself can't be null
*/
val temp = File(directory, "Temp")
val inputStream = requireNotNull(app.contentResolver.openInputStream(uri))
inputStream.copyToFile(temp)
val originalName = app.getFileName(uri)
when (fileType) {
FileType.IMAGE -> {
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeFile(temp.path, options)
val mimeType = options.outMimeType ?: proposedMimeType
if (mimeType != null) {
val extension = getExtensionForMimeType(mimeType)
if (extension != null) {
val name = "${UUID.randomUUID()}.$extension"
if (temp.rename(name)) {
return@withContext Pair(
FileAttachment(name, originalName ?: name, mimeType),
null,
)
} else {
// I don't expect this error to ever happen but just in
// case
return@withContext Pair(
null,
FileError(
displayName,
app.getString(errorWhileRenaming),
fileType,
),
)
}
} else
return@withContext Pair(
null,
FileError(
displayName,
app.getString(R.string.image_format_not_supported),
fileType,
),
)
} else
return@withContext Pair(
null,
FileError(
displayName,
app.getString(R.string.invalid_image),
fileType,
),
)
}
FileType.ANY -> {
val (mimeType, fileExtension) =
determineMimeTypeAndExtension(
proposedMimeType,
uri,
app.contentResolver,
)
val name = "${UUID.randomUUID()}${fileExtension}"
if (temp.rename(name)) {
return@withContext Pair(
FileAttachment(name, originalName ?: name, mimeType),
null,
)
} else {
// I don't expect this error to ever happen but just in case
return@withContext Pair(
null,
FileError(
displayName,
app.getString(errorWhileRenaming),
fileType,
),
)
}
}
}
} catch (exception: Exception) {
Operations.log(app, exception)
return@withContext Pair(
null,
FileError(displayName, app.getString(R.string.unknown_error), fileType),
)
}
}
}
private fun determineMimeTypeAndExtension(
proposedMimeType: String?,
uri: Uri,
contentResolver: ContentResolver,
) =
if (proposedMimeType != null && proposedMimeType.contains("/")) {
Pair(proposedMimeType, ".${uri.lastPathSegment?.substringAfterLast(".")}")
} else {
val actualMimeType = contentResolver.getType(uri) ?: "application/octet-stream"
Pair(
actualMimeType,
MimeTypeMap.getSingleton().getExtensionFromMimeType(actualMimeType)?.let {
".${it}"
} ?: "",
)
}
suspend fun addFile(
app: Application,
uri: Uri,
proposedMimeType: String? = null,
): Pair<FileAttachment?, FileError?> {
val filesRoot = app.getExternalFilesDirectory()
requireNotNull(filesRoot) { "filesRoot is null" }
return addFile(app, uri, filesRoot, FileType.ANY, proposedMimeType = proposedMimeType)
}
suspend fun addImage(
app: Application,
uri: Uri,
proposedMimeType: String? = null,
): Pair<FileAttachment?, FileError?> {
val imagesRoot = app.getExternalImagesDirectory()
requireNotNull(imagesRoot) { "imagesRoot is null" }
return addFile(
app,
uri,
imagesRoot,
FileType.IMAGE,
proposedMimeType = proposedMimeType,
)
}
suspend fun addAudio(app: Application, original: File, deleteOriginalFile: Boolean): Audio {
return withContext(Dispatchers.IO) {
/*
Regenerate because the directory may have been deleted between the time of activity creation
and audio recording
*/
val audioRoot = app.getExternalAudioDirectory()
requireNotNull(audioRoot) { "audioRoot is null" }
/*
If we have reached this point, an SD card (emulated or real) exists and audioRoot
is not null. audioRoot.exists() can be false if the folder `Audio` has been deleted after
the previous line, but audioRoot itself can't be null
*/
val name = "${UUID.randomUUID()}.m4a"
val final = File(audioRoot, name)
val input = FileInputStream(original)
input.copyToFile(final)
if (deleteOriginalFile) {
original.delete()
}
val retriever = MediaMetadataRetriever()
retriever.setDataSource(final.path)
val duration =
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
Audio(name, duration?.toLong(), System.currentTimeMillis())
}
}
private fun getExtensionForMimeType(type: String): String? {
return when (type) {
"image/png" -> "png"
"image/jpeg" -> "jpg"
"image/webp" -> "webp"
else -> null
}
}
}
}

View file

@ -1,7 +1,9 @@
package com.philkes.notallyx.data package com.philkes.notallyx.data
import android.app.Application import android.content.Context
import android.content.ContextWrapper
import android.os.Build import android.os.Build
import androidx.annotation.RequiresApi
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.room.Database import androidx.room.Database
import androidx.room.Room import androidx.room.Room
@ -10,21 +12,28 @@ import androidx.room.TypeConverters
import androidx.room.migration.Migration import androidx.room.migration.Migration
import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import com.philkes.notallyx.Preferences
import com.philkes.notallyx.data.dao.BaseNoteDao 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.presentation.observeForeverSkipFirst import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.presentation.view.misc.BetterLiveData import com.philkes.notallyx.data.model.toColorString
import com.philkes.notallyx.presentation.view.misc.BiometricLock.enabled import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.observeForeverSkipFirst
import com.philkes.notallyx.utils.getExternalMediaDirectory
import com.philkes.notallyx.utils.security.SQLCipherUtils
import com.philkes.notallyx.utils.security.getInitializedCipherForDecryption import com.philkes.notallyx.utils.security.getInitializedCipherForDecryption
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 = 6) @Database(entities = [BaseNote::class, Label::class], version = 9)
abstract class NotallyDatabase : RoomDatabase() { abstract class NotallyDatabase : RoomDatabase() {
abstract fun getLabelDao(): LabelDao abstract fun getLabelDao(): LabelDao
@ -37,58 +46,162 @@ abstract class NotallyDatabase : RoomDatabase() {
getBaseNoteDao().query(SimpleSQLiteQuery("pragma wal_checkpoint(FULL)")) getBaseNoteDao().query(SimpleSQLiteQuery("pragma wal_checkpoint(FULL)"))
} }
private var observer: Observer<String>? = null private var biometricLockObserver: Observer<BiometricLock>? = null
private var dataInPublicFolderObserver: Observer<Boolean>? = null
companion object { companion object {
const val DatabaseName = "NotallyDatabase" const val DATABASE_NAME = "NotallyDatabase"
@Volatile private var instance: BetterLiveData<NotallyDatabase>? = null @Volatile private var instance: NotNullLiveData<NotallyDatabase>? = null
fun getDatabase(app: Application): BetterLiveData<NotallyDatabase> { fun getCurrentDatabaseFile(context: ContextWrapper): File {
return if (NotallyXPreferences.getInstance(context).dataInPublicFolder.value) {
getExternalDatabaseFile(context)
} else {
getInternalDatabaseFile(context)
}
}
fun getExternalDatabaseFile(context: ContextWrapper): File {
return File(context.getExternalMediaDirectory(), DATABASE_NAME)
}
fun getExternalDatabaseFiles(context: ContextWrapper): List<File> {
return listOf(
File(context.getExternalMediaDirectory(), DATABASE_NAME),
File(context.getExternalMediaDirectory(), "$DATABASE_NAME-shm"),
File(context.getExternalMediaDirectory(), "$DATABASE_NAME-wal"),
)
}
fun getInternalDatabaseFile(context: Context): File {
return context.getDatabasePath(DATABASE_NAME)
}
fun getInternalDatabaseFiles(context: ContextWrapper): List<File> {
val directory = context.getDatabasePath(DATABASE_NAME).parentFile
return listOf(
File(directory, DATABASE_NAME),
File(directory, "$DATABASE_NAME-shm"),
File(directory, "$DATABASE_NAME-wal"),
)
}
private fun getCurrentDatabaseName(context: ContextWrapper): String {
return if (NotallyXPreferences.getInstance(context).dataInPublicFolder.value) {
getExternalDatabaseFile(context).absolutePath
} else {
DATABASE_NAME
}
}
fun getDatabase(
context: ContextWrapper,
observePreferences: Boolean = true,
): NotNullLiveData<NotallyDatabase> {
return instance return instance
?: synchronized(this) { ?: synchronized(this) {
val preferences = Preferences.getInstance(app) val preferences = NotallyXPreferences.getInstance(context)
this.instance = this.instance =
BetterLiveData( NotNullLiveData(createInstance(context, preferences, observePreferences))
createInstance(app, preferences, preferences.biometricLock.value)
)
return this.instance!! return this.instance!!
} }
} }
fun getFreshDatabase(context: ContextWrapper): NotallyDatabase {
return createInstance(context, NotallyXPreferences.getInstance(context), false)
}
private fun createInstance( private fun createInstance(
app: Application, context: ContextWrapper,
preferences: Preferences, preferences: NotallyXPreferences,
biometrickLock: String, observePreferences: Boolean,
): NotallyDatabase { ): NotallyDatabase {
val instanceBuilder = val instanceBuilder =
Room.databaseBuilder(app, NotallyDatabase::class.java, DatabaseName) Room.databaseBuilder(
.addMigrations(Migration2, Migration3, Migration4, Migration5, Migration6) context,
NotallyDatabase::class.java,
getCurrentDatabaseName(context),
)
.addMigrations(
Migration2,
Migration3,
Migration4,
Migration5,
Migration6,
Migration7,
Migration8,
Migration9,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (biometrickLock == enabled) { SQLiteDatabase.loadLibs(context)
val initializationVector = preferences.iv!! if (preferences.biometricLock.value == BiometricLock.ENABLED) {
val cipher = getInitializedCipherForDecryption(iv = initializationVector) if (
val encryptedPassphrase = preferences.getDatabasePassphrase() SQLCipherUtils.getDatabaseState(getCurrentDatabaseFile(context)) ==
val passphrase = cipher.doFinal(encryptedPassphrase) SQLCipherUtils.State.ENCRYPTED
val factory = SupportFactory(passphrase) ) {
instanceBuilder.openHelperFactory(factory) initializeDecryption(preferences, instanceBuilder)
} else {
preferences.biometricLock.save(BiometricLock.DISABLED)
}
} else {
if (
SQLCipherUtils.getDatabaseState(getCurrentDatabaseFile(context)) ==
SQLCipherUtils.State.ENCRYPTED
) {
preferences.biometricLock.save(BiometricLock.ENABLED)
initializeDecryption(preferences, instanceBuilder)
}
} }
val instance = instanceBuilder.build() val instance = instanceBuilder.build()
instance.observer = Observer { newBiometrickLock -> if (observePreferences) {
NotallyDatabase.instance?.value?.observer?.let { instance.biometricLockObserver = Observer {
preferences.biometricLock.removeObserver(it) NotallyDatabase.instance?.value?.biometricLockObserver?.let {
preferences.biometricLock.removeObserver(it)
}
val newInstance = createInstance(context, preferences, true)
NotallyDatabase.instance?.postValue(newInstance)
preferences.biometricLock.observeForeverSkipFirst(
newInstance.biometricLockObserver!!
)
} }
val newInstance = createInstance(app, preferences, newBiometrickLock) preferences.biometricLock.observeForeverSkipFirst(
NotallyDatabase.instance?.postValue(newInstance) instance.biometricLockObserver!!
preferences.biometricLock.observeForeverSkipFirst(newInstance.observer!!) )
instance.dataInPublicFolderObserver = Observer {
NotallyDatabase.instance?.value?.dataInPublicFolderObserver?.let {
preferences.dataInPublicFolder.removeObserver(it)
}
val newInstance = createInstance(context, preferences, true)
NotallyDatabase.instance?.postValue(newInstance)
preferences.dataInPublicFolder.observeForeverSkipFirst(
newInstance.dataInPublicFolderObserver!!
)
}
preferences.dataInPublicFolder.observeForeverSkipFirst(
instance.dataInPublicFolderObserver!!
)
} }
preferences.biometricLock.observeForeverSkipFirst(instance.observer!!)
return instance return instance
} }
return instanceBuilder.build() return instanceBuilder.build()
} }
@RequiresApi(Build.VERSION_CODES.M)
private fun initializeDecryption(
preferences: NotallyXPreferences,
instanceBuilder: Builder<NotallyDatabase>,
) {
val initializationVector = preferences.iv.value!!
val cipher = getInitializedCipherForDecryption(iv = initializationVector)
val encryptedPassphrase = preferences.databaseEncryptionKey.value
val passphrase = cipher.doFinal(encryptedPassphrase)
val factory = SupportFactory(passphrase)
instanceBuilder.openHelperFactory(factory)
}
object Migration2 : Migration(1, 2) { object Migration2 : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
@ -127,5 +240,37 @@ abstract class NotallyDatabase : RoomDatabase() {
) )
} }
} }
object Migration7 : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE `BaseNote` ADD COLUMN `reminders` TEXT NOT NULL DEFAULT `[]`"
)
}
}
object Migration8 : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
val cursor = db.query("SELECT id, color FROM BaseNote")
while (cursor.moveToNext()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow("id"))
val colorString = cursor.getString(cursor.getColumnIndexOrThrow("color"))
val color = Color.valueOfOrDefault(colorString)
val hexColor = color.toColorString()
db.execSQL("UPDATE BaseNote SET color = ? WHERE id = ?", arrayOf(hexColor, id))
}
cursor.close()
}
}
object Migration9 : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE `BaseNote` ADD COLUMN `viewMode` TEXT NOT NULL DEFAULT '${NoteViewMode.EDIT.name}'"
)
}
}
} }
} }

View file

@ -11,11 +11,21 @@ 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
import com.philkes.notallyx.data.model.ListItem import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.Reminder
import com.philkes.notallyx.data.model.Type
data class NoteIdReminder(val id: Long, val reminders: List<Reminder>)
data class NoteReminder(
val id: Long,
val title: String,
val type: Type,
val reminders: List<Reminder>,
)
@Dao @Dao
interface BaseNoteDao { interface BaseNoteDao {
@ -28,6 +38,8 @@ interface BaseNoteDao {
@Update(entity = BaseNote::class) suspend fun update(labelsInBaseNotes: List<LabelsInBaseNote>) @Update(entity = BaseNote::class) suspend fun update(labelsInBaseNotes: List<LabelsInBaseNote>)
@Query("SELECT COUNT(*) FROM BaseNote") fun count(): Int
@Query("DELETE FROM BaseNote WHERE id = :id") suspend fun delete(id: Long) @Query("DELETE FROM BaseNote WHERE id = :id") suspend fun delete(id: Long)
@Query("DELETE FROM BaseNote WHERE id IN (:ids)") suspend fun delete(ids: LongArray) @Query("DELETE FROM BaseNote WHERE id IN (:ids)") suspend fun delete(ids: LongArray)
@ -58,6 +70,16 @@ interface BaseNoteDao {
@Query("SELECT audios FROM BaseNote") fun getAllAudios(): List<String> @Query("SELECT audios FROM BaseNote") fun getAllAudios(): List<String>
@Query("SELECT id, reminders FROM BaseNote WHERE reminders IS NOT NULL AND reminders != '[]'")
suspend fun getAllReminders(): List<NoteIdReminder>
@Query("SELECT color FROM BaseNote WHERE id = :id ") fun getColorOfNote(id: Long): String?
@Query(
"SELECT id, title, type, reminders FROM BaseNote WHERE reminders IS NOT NULL AND reminders != '[]'"
)
fun getAllRemindersAsync(): LiveData<List<NoteReminder>>
@Query("SELECT id FROM BaseNote WHERE folder = 'DELETED'") @Query("SELECT id FROM BaseNote WHERE folder = 'DELETED'")
suspend fun getDeletedNoteIds(): LongArray suspend fun getDeletedNoteIds(): LongArray
@ -73,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)
@ -82,6 +111,9 @@ interface BaseNoteDao {
@Query("UPDATE BaseNote SET labels = :labels WHERE id = :id") @Query("UPDATE BaseNote SET labels = :labels WHERE id = :id")
suspend fun updateLabels(id: Long, labels: List<String>) suspend fun updateLabels(id: Long, labels: List<String>)
@Query("UPDATE BaseNote SET labels = :labels WHERE id IN (:ids)")
suspend fun updateLabels(ids: LongArray, labels: List<String>)
@Query("UPDATE BaseNote SET items = :items WHERE id = :id") @Query("UPDATE BaseNote SET items = :items WHERE id = :id")
suspend fun updateItems(id: Long, items: List<ListItem>) suspend fun updateItems(id: Long, items: List<ListItem>)
@ -94,6 +126,9 @@ interface BaseNoteDao {
@Query("UPDATE BaseNote SET audios = :audios WHERE id = :id") @Query("UPDATE BaseNote SET audios = :audios WHERE id = :id")
suspend fun updateAudios(id: Long, audios: List<Audio>) suspend fun updateAudios(id: Long, audios: List<Audio>)
@Query("UPDATE BaseNote SET reminders = :reminders WHERE id = :id")
suspend fun updateReminders(id: Long, reminders: List<Reminder>)
/** /**
* Both id and position can be invalid. * Both id and position can be invalid.
* *
@ -128,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)
@ -145,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

@ -16,10 +16,15 @@ interface LabelDao {
@Query("DELETE FROM Label WHERE value = :value") suspend fun delete(value: String) @Query("DELETE FROM Label WHERE value = :value") suspend fun delete(value: String)
@Query("DELETE FROM Label") suspend fun deleteAll()
@Query("UPDATE Label SET value = :newValue WHERE value = :oldValue") @Query("UPDATE Label SET value = :newValue WHERE value = :oldValue")
suspend fun update(oldValue: String, newValue: String) suspend fun update(oldValue: String, newValue: String)
@Query("SELECT value FROM Label ORDER BY value") fun getAll(): LiveData<List<String>> @Query("SELECT value FROM Label ORDER BY value") 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

@ -11,12 +11,13 @@ interface ExternalImporter {
/** /**
* Parses [BaseNote]s from [source] and copies attached files/images/audios to [destination] * Parses [BaseNote]s from [source] and copies attached files/images/audios to [destination]
* *
* @return List of [BaseNote]s to import + folder containing attached files * @return List of [BaseNote]s to import + folder containing attached files (if no attached
* files possible, return null).
*/ */
fun import( fun import(
app: Application, app: Application,
source: Uri, source: Uri,
destination: File, destination: File,
progress: MutableLiveData<ImportProgress>? = null, progress: MutableLiveData<ImportProgress>? = null,
): Pair<List<BaseNote>, File> ): Pair<List<BaseNote>, File?>
} }

View file

@ -6,14 +6,19 @@ import android.util.Log
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.data.DataUtil
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.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.Label import com.philkes.notallyx.data.model.Label
import com.philkes.notallyx.presentation.viewmodel.NotallyModel import com.philkes.notallyx.presentation.viewmodel.NotallyModel
import com.philkes.notallyx.utils.MIME_TYPE_ZIP
import com.philkes.notallyx.utils.backup.importAudio
import com.philkes.notallyx.utils.backup.importFile
import com.philkes.notallyx.utils.backup.importImage
import java.io.File import java.io.File
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
@ -34,6 +39,8 @@ class NotesImporter(private val app: Application, private val database: NotallyD
when (importSource) { when (importSource) {
ImportSource.GOOGLE_KEEP -> GoogleKeepImporter() ImportSource.GOOGLE_KEEP -> GoogleKeepImporter()
ImportSource.EVERNOTE -> EvernoteImporter() ImportSource.EVERNOTE -> EvernoteImporter()
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)
@ -49,23 +56,11 @@ class NotesImporter(private val app: Application, private val database: NotallyD
progress?.postValue( progress?.postValue(
ImportProgress(total = totalFiles, stage = ImportStage.IMPORT_FILES) ImportProgress(total = totalFiles, stage = ImportStage.IMPORT_FILES)
) )
importFiles( importDataFolder?.let {
files, importFiles(files, it, NotallyModel.FileType.ANY, progress, totalFiles, counter)
importDataFolder, importFiles(images, it, NotallyModel.FileType.IMAGE, progress, totalFiles, counter)
NotallyModel.FileType.ANY, importAudios(audios, it, progress, totalFiles, counter)
progress, }
totalFiles,
counter,
)
importFiles(
images,
importDataFolder,
NotallyModel.FileType.IMAGE,
progress,
totalFiles,
counter,
)
importAudios(audios, importDataFolder, progress, totalFiles, counter)
database.getBaseNoteDao().insert(notes) database.getBaseNoteDao().insert(notes)
progress?.postValue(ImportProgress(inProgress = false)) progress?.postValue(ImportProgress(inProgress = false))
return notes.size return notes.size
@ -85,9 +80,8 @@ class NotesImporter(private val app: Application, private val database: NotallyD
files.forEach { file -> files.forEach { file ->
val uri = File(sourceFolder, file.localName).toUri() val uri = File(sourceFolder, file.localName).toUri()
val (fileAttachment, error) = val (fileAttachment, error) =
if (fileType == NotallyModel.FileType.IMAGE) if (fileType == NotallyModel.FileType.IMAGE) app.importImage(uri, file.mimeType)
DataUtil.addImage(app, uri, file.mimeType) else app.importFile(uri, file.mimeType)
else DataUtil.addFile(app, uri, file.mimeType)
fileAttachment?.let { fileAttachment?.let {
file.localName = fileAttachment.localName file.localName = fileAttachment.localName
file.originalName = fileAttachment.originalName file.originalName = fileAttachment.originalName
@ -113,7 +107,7 @@ class NotesImporter(private val app: Application, private val database: NotallyD
) { ) {
audios.forEach { originalAudio -> audios.forEach { originalAudio ->
val file = File(sourceFolder, originalAudio.name) val file = File(sourceFolder, originalAudio.name)
val audio = DataUtil.addAudio(app, file, false) val audio = app.importAudio(file, false)
originalAudio.name = audio.name originalAudio.name = audio.name
originalAudio.duration = if (audio.duration == 0L) null else audio.duration originalAudio.duration = if (audio.duration == 0L) null else audio.duration
originalAudio.timestamp = audio.timestamp originalAudio.timestamp = audio.timestamp
@ -137,12 +131,12 @@ enum class ImportSource(
val displayNameResId: Int, val displayNameResId: Int,
val mimeType: String, val mimeType: String,
val helpTextResId: Int, val helpTextResId: Int,
val documentationUrl: String, val documentationUrl: String?,
val iconResId: Int, val iconResId: Int,
) { ) {
GOOGLE_KEEP( GOOGLE_KEEP(
R.string.google_keep, R.string.google_keep,
"application/zip", MIME_TYPE_ZIP,
R.string.google_keep_help, R.string.google_keep_help,
"https://support.google.com/keep/answer/10017039", "https://support.google.com/keep/answer/10017039",
R.drawable.icon_google_keep, R.drawable.icon_google_keep,
@ -154,4 +148,20 @@ enum class ImportSource(
"https://help.evernote.com/hc/en-us/articles/209005557-Export-notes-and-notebooks-as-ENEX-or-HTML", "https://help.evernote.com/hc/en-us/articles/209005557-Export-notes-and-notebooks-as-ENEX-or-HTML",
R.drawable.icon_evernote, R.drawable.icon_evernote,
), ),
PLAIN_TEXT(
R.string.plain_text_files,
FOLDER_OR_FILE_MIMETYPE,
R.string.plain_text_files_help,
null,
R.drawable.text_file,
),
JSON(
R.string.json_files,
FOLDER_OR_FILE_MIMETYPE,
R.string.json_files_help,
null,
R.drawable.file_json,
),
} }
const val FOLDER_OR_FILE_MIMETYPE = "FOLDER_OR_FILE"

View file

@ -14,13 +14,14 @@ import com.philkes.notallyx.data.imports.evernote.EvernoteImporter.Companion.par
import com.philkes.notallyx.data.imports.parseBodyAndSpansFromHtml import com.philkes.notallyx.data.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.IO.write import com.philkes.notallyx.utils.log
import com.philkes.notallyx.utils.Operations import com.philkes.notallyx.utils.startsWithAnyOf
import com.philkes.notallyx.utils.write
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -91,7 +92,7 @@ class EvernoteImporter : ExternalImporter {
val data = Base64.decode(it.data!!.content.trimStart(), Base64.DEFAULT) val data = Base64.decode(it.data!!.content.trimStart(), Base64.DEFAULT)
file.write(data) file.write(data)
} catch (e: Exception) { } catch (e: Exception) {
Operations.log(app, e) app.log(TAG, throwable = e)
} }
progress?.postValue( progress?.postValue(
ImportProgress( ImportProgress(
@ -104,6 +105,8 @@ class EvernoteImporter : ExternalImporter {
} }
companion object { companion object {
private const val TAG = "EvernoteImporter"
fun parseTimestamp(timestamp: String): Long { fun parseTimestamp(timestamp: String): Long {
val format = SimpleDateFormat(EVERNOTE_DATE_FORMAT, Locale.getDefault()) val format = SimpleDateFormat(EVERNOTE_DATE_FORMAT, Locale.getDefault())
format.timeZone = TimeZone.getTimeZone("UTC") format.timeZone = TimeZone.getTimeZone("UTC")
@ -140,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),
@ -152,6 +155,8 @@ fun EvernoteNote.mapToBaseNote(): BaseNote {
images = images, images = images,
files = files, files = files,
audios = audios, audios = audios,
reminders = mutableListOf(),
NoteViewMode.EDIT,
) )
} }
@ -167,11 +172,6 @@ fun Collection<EvernoteResource>.filterByExcludedMimeTypePrefixes(
return filter { !it.mime.startsWithAnyOf(*mimeTypePrefix) } return filter { !it.mime.startsWithAnyOf(*mimeTypePrefix) }
} }
private fun String.startsWithAnyOf(vararg s: String): Boolean {
s.forEach { if (startsWith(it)) return true }
return false
}
fun Collection<EvernoteResource>.toFileAttachments(): List<FileAttachment> { fun Collection<EvernoteResource>.toFileAttachments(): List<FileAttachment> {
return map { FileAttachment(it.attributes!!.fileName, it.attributes.fileName, it.mime) } return map { FileAttachment(it.attributes!!.fileName, it.attributes.fileName, it.mime) }
} }

View file

@ -11,11 +11,13 @@ 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.log
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
@ -43,7 +45,7 @@ class GoogleKeepImporter : ExternalImporter {
progress?.postValue(ImportProgress(indeterminate = true, stage = ImportStage.EXTRACT_FILES)) progress?.postValue(ImportProgress(indeterminate = true, stage = ImportStage.EXTRACT_FILES))
val dataFolder = val dataFolder =
try { try {
unzip(destination, app.contentResolver.openInputStream(source)!!) app.contentResolver.openInputStream(source)!!.use { unzip(destination, it) }
} catch (e: Exception) { } catch (e: Exception) {
throw ImportException(R.string.invalid_google_keep, e) throw ImportException(R.string.invalid_google_keep, e)
} }
@ -57,17 +59,29 @@ class GoogleKeepImporter : ExternalImporter {
val noteFiles = val noteFiles =
dataFolder dataFolder
.listFiles { file -> .listFilesRecursive { file ->
file.isFile && file.extension.equals("json", ignoreCase = true) file.isFile && file.extension.equals("json", ignoreCase = true)
} }
?.toList() ?: emptyList() .toList()
val total = noteFiles.size val total = noteFiles.size
progress?.postValue(ImportProgress(0, total, stage = ImportStage.IMPORT_NOTES)) progress?.postValue(ImportProgress(0, total, stage = ImportStage.IMPORT_NOTES))
var counter = 1 var counter = 1
val baseNotes = val baseNotes =
noteFiles noteFiles
.mapNotNull { file -> .mapNotNull { file ->
val baseNote = file.readText().parseToBaseNote() val baseNote =
try {
val relativePath = file.parentFile!!.toRelativeString(dataFolder)
file.readText().parseToBaseNote(relativePath)
} catch (e: Exception) {
app.log(
TAG,
msg =
"Could not parse BaseNote from JSON in file '${file.absolutePath}'",
throwable = e,
)
null
}
progress?.postValue( progress?.postValue(
ImportProgress(counter++, total, stage = ImportStage.IMPORT_NOTES) ImportProgress(counter++, total, stage = ImportStage.IMPORT_NOTES)
) )
@ -77,7 +91,7 @@ class GoogleKeepImporter : ExternalImporter {
return Pair(baseNotes, dataFolder) return Pair(baseNotes, dataFolder)
} }
fun String.parseToBaseNote(): BaseNote { fun String.parseToBaseNote(relativePath: String? = null): BaseNote {
val googleKeepNote = json.decodeFromString<GoogleKeepNote>(this) val googleKeepNote = json.decodeFromString<GoogleKeepNote>(this)
val (body, spans) = val (body, spans) =
parseBodyAndSpansFromHtml( parseBodyAndSpansFromHtml(
@ -89,15 +103,33 @@ class GoogleKeepImporter : ExternalImporter {
val images = val images =
googleKeepNote.attachments googleKeepNote.attachments
.filter { it.mimetype.startsWith("image") } .filter { it.mimetype.startsWith("image") }
.map { FileAttachment(it.filePath, it.filePath, it.mimetype) } .map { attachment ->
FileAttachment(
"${relativePath?.let { "$it/" } ?: ""}${attachment.filePath}",
attachment.filePath,
attachment.mimetype,
)
}
val files = val files =
googleKeepNote.attachments googleKeepNote.attachments
.filter { !it.mimetype.startsWith("audio") && !it.mimetype.startsWith("image") } .filter { !it.mimetype.startsWith("audio") && !it.mimetype.startsWith("image") }
.map { FileAttachment(it.filePath, it.filePath, it.mimetype) } .map { attachment ->
FileAttachment(
"${relativePath?.let { "$it/" } ?: ""}${attachment.filePath}",
attachment.filePath,
attachment.mimetype,
)
}
val audios = val audios =
googleKeepNote.attachments googleKeepNote.attachments
.filter { it.mimetype.startsWith("audio") } .filter { it.mimetype.startsWith("audio") }
.map { Audio(it.filePath, 0L, System.currentTimeMillis()) } .map { attachment ->
Audio(
"${relativePath?.let { "$it/" } ?: ""}${attachment.filePath}",
0L,
System.currentTimeMillis(),
)
}
val items = val items =
googleKeepNote.listContent.mapIndexed { index, item -> googleKeepNote.listContent.mapIndexed { index, item ->
ListItem( ListItem(
@ -118,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,
@ -130,6 +162,8 @@ class GoogleKeepImporter : ExternalImporter {
images = images, images = images,
files = files, files = files,
audios = audios, audios = audios,
reminders = mutableListOf(),
NoteViewMode.EDIT,
) )
} }
@ -163,18 +197,22 @@ class GoogleKeepImporter : ExternalImporter {
throw IOException("Failed to create directory $parent") throw IOException("Failed to create directory $parent")
} }
} }
val fos = FileOutputStream(newFile) FileOutputStream(newFile).use {
var len: Int var len: Int
while ((zis.read(buffer).also { len = it }) > 0) { while ((zis.read(buffer).also { length -> len = length }) > 0) {
fos.write(buffer, 0, len) it.write(buffer, 0, len)
}
} }
fos.close()
} }
zipEntry = zis.nextEntry zipEntry = zis.nextEntry
} }
zis.closeEntry() zis.closeEntry()
zis.close() zis.close()
return File(destinationPath, "Takeout/Keep") return destinationPath
}
companion object {
private const val TAG = "GoogleKeepImporter"
} }
} }

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

@ -0,0 +1,42 @@
package com.philkes.notallyx.data.imports.txt
import com.philkes.notallyx.data.model.ListItem
fun CharSequence.extractListItems(regex: Regex): List<ListItem> {
return regex
.findAll(this)
.mapIndexedNotNull { idx, matchResult ->
val isChild = matchResult.groupValues[1] != ""
val isChecked = matchResult.groupValues[2] != ""
val itemText = matchResult.groupValues[3]
if (itemText.isNotBlank()) {
ListItem(itemText.trimStart(), isChecked, isChild, idx, mutableListOf())
} else null
}
.toList()
}
fun CharSequence.findListSyntaxRegex(
checkContains: Boolean = false,
plainNewLineAllowed: Boolean = false,
): Regex? {
val checkCallback: (String) -> Boolean =
if (checkContains) {
{ string -> startsWith(string) || contains(string, ignoreCase = true) }
} else {
{ string -> startsWith(string) }
}
if (checkCallback("- [ ]") || checkCallback("- [x]")) {
return "\n?(\\s*)-? ?\\[? ?([xX]?)\\]?(.*)".toRegex()
}
if (checkCallback("[ ]") || checkCallback("[✓]")) {
return "\n?(\\s*)\\[? ?(✓?)\\]?(.*)".toRegex()
}
if (checkCallback("-") || checkCallback("*")) {
return "\n?(\\s*)[-*]?\\s*()(.*)".toRegex()
}
if (plainNewLineAllowed && contains("\n")) {
return "\n?(\\s*)()(.*)".toRegex()
}
return null
}

View file

@ -0,0 +1,89 @@
package com.philkes.notallyx.data.imports.txt
import android.app.Application
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.MutableLiveData
import com.philkes.notallyx.data.imports.ExternalImporter
import com.philkes.notallyx.data.imports.ImportProgress
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.data.model.Type
import com.philkes.notallyx.utils.MIME_TYPE_JSON
import com.philkes.notallyx.utils.readFileContents
import java.io.File
class PlainTextImporter : ExternalImporter {
override fun import(
app: Application,
source: Uri,
destination: File,
progress: MutableLiveData<ImportProgress>?,
): Pair<List<BaseNote>, File?> {
val notes = mutableListOf<BaseNote>()
fun readTxtFiles(file: DocumentFile) {
when {
file.isDirectory -> {
file.listFiles().forEach { readTxtFiles(it) }
}
file.isFile -> {
if (file.type?.isTextMimeType() == false) {
return
}
val fileNameWithoutExtension = file.name?.substringBeforeLast(".") ?: ""
var content = app.contentResolver.readFileContents(file.uri)
val listItems = mutableListOf<ListItem>()
content.findListSyntaxRegex()?.let { listSyntaxRegex ->
listItems.addAll(content.extractListItems(listSyntaxRegex))
content = ""
}
val timestamp = System.currentTimeMillis()
notes.add(
BaseNote(
id = 0L, // Auto-generated
type = if (listItems.isEmpty()) Type.NOTE else Type.LIST,
folder = Folder.NOTES,
color = BaseNote.COLOR_DEFAULT,
title = fileNameWithoutExtension,
pinned = false,
timestamp = timestamp,
modifiedTimestamp = timestamp,
labels = listOf(),
body = content,
spans = listOf(),
items = listItems,
images = listOf(),
files = listOf(),
audios = listOf(),
reminders = listOf(),
NoteViewMode.EDIT,
)
)
}
}
}
val file =
if (source.pathSegments.firstOrNull() == "tree") {
DocumentFile.fromTreeUri(app, source)
} else DocumentFile.fromSingleUri(app, source)
file?.let { readTxtFiles(it) }
return Pair(notes, null)
}
private fun String.isTextMimeType(): Boolean {
return startsWith("text/") || this in APPLICATION_TEXT_MIME_TYPES
}
}
val APPLICATION_TEXT_MIME_TYPES =
arrayOf(
MIME_TYPE_JSON,
"application/xml",
"application/javascript",
"application/xhtml+xml",
"application/yaml",
)

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,
@ -21,4 +24,70 @@ data class BaseNote(
val images: List<FileAttachment>, val images: List<FileAttachment>,
val files: List<FileAttachment>, val files: List<FileAttachment>,
val audios: List<Audio>, val audios: List<Audio>,
) : Item val reminders: List<Reminder>,
val viewMode: NoteViewMode,
) : Item {
companion object {
const val COLOR_DEFAULT = "DEFAULT"
const val COLOR_NEW = "NEW"
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as BaseNote
if (id != other.id) return false
if (type != other.type) return false
if (folder != other.folder) return false
if (color != other.color) return false
if (title != other.title) return false
if (pinned != other.pinned) return false
if (timestamp != other.timestamp) return false
if (labels != other.labels) return false
if (body != other.body) return false
if (spans != other.spans) return false
if (items != other.items) return false
if (images != other.images) return false
if (files != other.files) return false
if (audios != other.audios) return false
if (reminders != other.reminders) return false
if (viewMode != other.viewMode) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + type.hashCode()
result = 31 * result + folder.hashCode()
result = 31 * result + color.hashCode()
result = 31 * result + title.hashCode()
result = 31 * result + pinned.hashCode()
result = 31 * result + timestamp.hashCode()
result = 31 * result + labels.hashCode()
result = 31 * result + body.hashCode()
result = 31 * result + spans.hashCode()
result = 31 * result + items.hashCode()
result = 31 * result + images.hashCode()
result = 31 * result + files.hashCode()
result = 31 * result + audios.hashCode()
result = 31 * result + reminders.hashCode()
result = 31 * result + viewMode.hashCode()
return result
}
}
fun BaseNote.deepCopy(): BaseNote {
return copy(
labels = labels.toMutableList(),
spans = spans.map { it.copy() }.toMutableList(),
items = items.map { it.copy() }.toMutableList(),
images = images.map { it.copy() }.toMutableList(),
files = files.map { it.copy() }.toMutableList(),
audios = audios.map { it.copy() }.toMutableList(),
reminders = reminders.map { it.copy() }.toMutableList(),
)
}

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

@ -1,6 +1,7 @@
package com.philkes.notallyx.data.model package com.philkes.notallyx.data.model
import androidx.room.TypeConverter import androidx.room.TypeConverter
import java.util.Date
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
@ -9,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 {
@ -23,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")
@ -46,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")
@ -57,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())
@ -102,22 +137,55 @@ object Converters {
return JSONArray(objects) return JSONArray(objects)
} }
fun spansToJSONArray(list: List<SpanRepresentation>): JSONArray { @TypeConverter
fun remindersToJson(reminders: List<Reminder>) = remindersToJSONArray(reminders).toString()
fun remindersToJSONArray(reminders: List<Reminder>): JSONArray {
val objects = val objects =
list.map { representation -> reminders.map { reminder ->
val jsonObject = JSONObject() JSONObject().apply {
jsonObject.put("bold", representation.bold) put("id", reminder.id) // Store date as long timestamp
jsonObject.put("link", representation.link) put("dateTime", reminder.dateTime.time) // Store date as long timestamp
jsonObject.put("linkData", representation.linkData) put("repetition", reminder.repetition?.let { repetitionToJsonObject(it) })
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) return JSONArray(objects)
} }
@TypeConverter fun jsonToReminders(json: String) = jsonToReminders(JSONArray(json))
fun jsonToReminders(jsonArray: JSONArray): List<Reminder> {
return jsonArray.iterable<JSONObject>().map { jsonObject ->
val id = jsonObject.getLong("id")
val dateTime = Date(jsonObject.getLong("dateTime"))
val repetition = jsonObject.getSafeString("repetition")?.let { jsonToRepetition(it) }
Reminder(id, dateTime, repetition)
}
}
@TypeConverter
fun repetitionToJson(repetition: Repetition): String {
return repetitionToJsonObject(repetition).toString()
}
fun repetitionToJsonObject(repetition: Repetition): JSONObject {
val jsonObject = JSONObject()
jsonObject.put("value", repetition.value)
jsonObject.put("unit", repetition.unit.name) // Store the TimeUnit as a string
return jsonObject
}
@TypeConverter
fun jsonToRepetition(json: String): Repetition {
val jsonObject = JSONObject(json)
val value = jsonObject.getInt("value")
val unit =
RepetitionTimeUnit.valueOf(
jsonObject.getString("unit")
) // Convert string back to TimeUnit
return Repetition(value, unit)
}
private fun getSafeLocalName(jsonObject: JSONObject): String { private fun getSafeLocalName(jsonObject: JSONObject): String {
return try { return try {
jsonObject.getString("localName") jsonObject.getString("localName")

View file

@ -1,7 +1,18 @@
package com.philkes.notallyx.data.model package com.philkes.notallyx.data.model
enum class Folder { import java.io.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

@ -1,10 +1,20 @@
package com.philkes.notallyx.data.model package com.philkes.notallyx.data.model
import android.util.Patterns import android.content.Context
import android.text.Html
fun CharSequence?.isWebUrl(): Boolean { import androidx.core.text.toHtml
return this?.let { Patterns.WEB_URL.matcher(this).matches() } ?: false import com.philkes.notallyx.R
} import com.philkes.notallyx.data.dao.NoteIdReminder
import com.philkes.notallyx.data.model.BaseNote.Companion.COLOR_DEFAULT
import com.philkes.notallyx.presentation.applySpans
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
private const val NOTE_URL_PREFIX = "note://" private const val NOTE_URL_PREFIX = "note://"
private val NOTE_URL_POSTFIX_NOTE = "/${Type.NOTE.name}" private val NOTE_URL_POSTFIX_NOTE = "/${Type.NOTE.name}"
@ -31,32 +41,282 @@ fun String.getNoteTypeFromUrl(): Type {
return Type.valueOf(substringAfterLast("/")) return Type.valueOf(substringAfterLast("/"))
} }
fun String.getUrl(start: Int, end: Int): String {
return if (end <= length) {
substring(start, end).toUrl()
} else substring(start, length).toUrl()
}
private fun String.toUrl(): String {
return when {
matches(Patterns.PHONE.toRegex()) -> "tel:$this"
matches(Patterns.EMAIL_ADDRESS.toRegex()) -> "mailto:$this"
matches(Patterns.DOMAIN_NAME.toRegex()) -> "http://$this"
else -> this
}
}
val FileAttachment.isImage: Boolean 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/")
} }
val String.toPreservedByteArray: ByteArray fun BaseNote.toTxt(includeTitle: Boolean = true, includeCreationDate: Boolean = true) =
get() { buildString {
return this.toByteArray(Charsets.ISO_8859_1) val date = DateFormat.getDateInstance(DateFormat.FULL).format(timestamp)
val body =
when (type) {
Type.NOTE -> body
Type.LIST -> items.toText()
}
if (title.isNotEmpty() && includeTitle) {
append("${title}\n\n")
}
if (includeCreationDate) {
append("$date\n\n")
}
append(body)
return toString()
} }
val ByteArray.toPreservedString: String fun BaseNote.toJson(): String {
get() { val jsonObject =
return String(this, Charsets.ISO_8859_1) JSONObject()
.put("type", type.name)
.put("color", color)
.put("title", title)
.put("pinned", pinned)
.put("timestamp", timestamp)
.put("modifiedTimestamp", modifiedTimestamp)
.put("labels", JSONArray(labels))
when (type) {
Type.NOTE -> {
jsonObject.put("body", body)
jsonObject.put("spans", Converters.spansToJSONArray(spans))
}
Type.LIST -> {
jsonObject.put("items", Converters.itemsToJSONArray(items))
}
}
jsonObject.put("reminders", Converters.remindersToJSONArray(reminders))
jsonObject.put("viewMode", viewMode.name)
return jsonObject.toString(2)
}
fun String.toBaseNote(): BaseNote {
val jsonObject = JSONObject(this)
val id = jsonObject.getLongOrDefault("id", -1L)
val type = Type.valueOfOrDefault(jsonObject.getStringOrDefault("type", Type.NOTE.name))
val folder = Folder.valueOfOrDefault(jsonObject.getStringOrDefault("folder", Folder.NOTES.name))
val color =
jsonObject.getStringOrDefault("color", COLOR_DEFAULT).takeIf { it.isValid() }
?: COLOR_DEFAULT
val title = jsonObject.getStringOrDefault("title", "")
val pinned = jsonObject.getBooleanOrDefault("pinned", false)
val timestamp = jsonObject.getLongOrDefault("timestamp", System.currentTimeMillis())
val modifiedTimestamp = jsonObject.getLongOrDefault("modifiedTimestamp", timestamp)
val labels = Converters.jsonToLabels(jsonObject.getArrayOrEmpty("labels"))
val body = jsonObject.getStringOrDefault("body", "")
val spans = Converters.jsonToSpans(jsonObject.getArrayOrEmpty("spans"))
val items = Converters.jsonToItems(jsonObject.getArrayOrEmpty("items"))
val images = Converters.jsonToFiles(jsonObject.getArrayOrEmpty("images"))
val files = Converters.jsonToFiles(jsonObject.getArrayOrEmpty("files"))
val audios = Converters.jsonToAudios(jsonObject.getArrayOrEmpty("audios"))
val reminders = Converters.jsonToReminders(jsonObject.getArrayOrEmpty("reminders"))
val viewMode = NoteViewMode.valueOfOrDefault(jsonObject.getStringOrDefault("viewMode", ""))
return BaseNote(
id,
type,
folder,
color,
title,
pinned,
timestamp,
modifiedTimestamp,
labels,
body,
spans,
items,
images,
files,
audios,
reminders,
viewMode,
)
}
private fun JSONObject.getStringOrDefault(key: String, defaultValue: String): String {
return try {
getString(key)
} catch (exception: JSONException) {
defaultValue
}
}
private fun JSONObject.getArrayOrEmpty(key: String): JSONArray {
return try {
getJSONArray(key)
} catch (exception: JSONException) {
JSONArray("[]")
}
}
private fun JSONObject.getBooleanOrDefault(key: String, defaultValue: Boolean): Boolean {
return try {
getBoolean(key)
} catch (exception: JSONException) {
defaultValue
}
}
private fun JSONObject.getLongOrDefault(key: String, defaultValue: Long): Long {
return try {
getLong(key)
} catch (exception: JSONException) {
defaultValue
}
}
fun BaseNote.toHtml(showDateCreated: Boolean) = buildString {
val date = DateFormat.getDateInstance(DateFormat.FULL).format(timestamp)
val title = Html.escapeHtml(title)
append("<!DOCTYPE html>")
append("<html><head>")
append("<meta charset=\"UTF-8\"><title>$title</title>")
append("</head><body>")
append("<h2>$title</h2>")
if (showDateCreated) {
append("<p>$date</p>")
}
when (type) {
Type.NOTE -> {
val body = body.applySpans(spans).toHtml()
append(body)
}
Type.LIST -> {
append("<ol style=\"list-style: none; padding: 0;\">")
items.forEach { item ->
val body = Html.escapeHtml(item.body)
val checked = if (item.checked) "checked" else ""
val child = if (item.isChild) "style=\"margin-left: 20px\"" else ""
append("<li><input type=\"checkbox\" $child $checked>$body</li>")
}
append("</ol>")
}
}
append("</body></html>")
}
fun List<BaseNote>.toNoteIdReminders() = map { NoteIdReminder(it.id, it.reminders) }
fun BaseNote.attachmentsDifferFrom(other: BaseNote): Boolean {
return files.size != other.files.size ||
files.any { file -> other.files.none { it.localName == file.localName } } ||
other.files.any { file -> files.none { it.localName == file.localName } } ||
images.any { image -> other.images.none { it.localName == image.localName } } ||
other.images.any { image -> images.none { it.localName == image.localName } } ||
audios.any { audio -> other.audios.none { it.name == audio.name } } ||
other.audios.any { audio -> audios.none { it.name == audio.name } }
}
fun Date.toText(): String = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault()).format(this)
fun Repetition.toText(context: Context): String =
when {
value == 1 && unit == RepetitionTimeUnit.DAYS -> context.getString(R.string.daily)
value == 1 && unit == RepetitionTimeUnit.WEEKS -> context.getString(R.string.weekly)
value == 1 && unit == RepetitionTimeUnit.MONTHS -> context.getString(R.string.monthly)
value == 1 && unit == RepetitionTimeUnit.YEARS -> context.getString(R.string.yearly)
else -> "${context.getString(R.string.every)} $value ${unit.toText(context)}"
}
private fun RepetitionTimeUnit.toText(context: Context): String {
val resId =
when (this) {
RepetitionTimeUnit.MINUTES -> R.string.minutes
RepetitionTimeUnit.HOURS -> R.string.hours
RepetitionTimeUnit.DAYS -> R.string.days
RepetitionTimeUnit.WEEKS -> R.string.weeks
RepetitionTimeUnit.MONTHS -> R.string.months
RepetitionTimeUnit.YEARS -> R.string.years
}
return context.getString(resId)
}
fun Collection<Reminder>.copy() = map { it.copy() }
fun RepetitionTimeUnit.toCalendarField(): Int {
return when (this) {
RepetitionTimeUnit.MINUTES -> Calendar.MINUTE
RepetitionTimeUnit.HOURS -> Calendar.HOUR
RepetitionTimeUnit.DAYS -> Calendar.DAY_OF_MONTH
RepetitionTimeUnit.WEEKS -> Calendar.WEEK_OF_YEAR
RepetitionTimeUnit.MONTHS -> Calendar.MONTH
RepetitionTimeUnit.YEARS -> Calendar.YEAR
}
}
fun Reminder.nextNotification(from: Date = Date()): Date? {
if (from.before(dateTime)) {
return dateTime
}
if (repetition == null) {
return null
}
val timeDifferenceMillis: Long = from.time - dateTime.time
val intervalsPassed = timeDifferenceMillis / repetition!!.toMillis()
val unitsUntilNext = ((repetition!!.value) * (intervalsPassed + 1)).toInt()
val reminderStart = dateTime.toCalendar()
reminderStart.add(repetition!!.unit.toCalendarField(), unitsUntilNext)
return reminderStart.time
}
fun Reminder.nextRepetition(from: Date = Date()): Date? {
if (repetition == null) {
return null
}
return nextNotification(from)
}
fun Reminder.hasUpcomingNotification() = !(dateTime.before(Date()) && repetition == null)
fun Repetition.toMillis(): Long {
return Calendar.getInstance()
.apply {
timeInMillis = 0
add(unit.toCalendarField(), value)
}
.timeInMillis
}
fun Collection<Reminder>.hasAnyUpcomingNotifications(): Boolean {
return any { it.hasUpcomingNotification() }
}
fun Collection<Reminder>.findNextNotificationDate(): Date? {
return mapNotNull { it.nextNotification() }.minByOrNull { it }
}
fun Date.toCalendar() = Calendar.getInstance().apply { timeInMillis = this@toCalendar.time }
fun List<ListItem>.toText() = buildString {
for (item in this@toText) {
val check = if (item.checked) "[✓]" else "[ ]"
val childIndentation = if (item.isChild) " " else ""
appendLine("$childIndentation$check ${item.body}")
}
}
fun Collection<ListItem>.deepCopy() = map { it.copy(children = mutableListOf()) }
fun ColorString.isValid() =
when (this) {
COLOR_DEFAULT -> true
else ->
try {
android.graphics.Color.parseColor(this)
true
} catch (e: Exception) {
false
}
} }

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

@ -0,0 +1,19 @@
package com.philkes.notallyx.data.model
import android.os.Parcelable
import java.util.Date
import kotlinx.parcelize.Parcelize
@Parcelize
data class Reminder(var id: Long, var dateTime: Date, var repetition: Repetition?) : Parcelable
@Parcelize data class Repetition(var value: Int, var unit: RepetitionTimeUnit) : Parcelable
enum class RepetitionTimeUnit {
MINUTES,
HOURS,
DAYS,
WEEKS,
MONTHS,
YEARS,
}

View file

@ -21,15 +21,16 @@ 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 {
if (keyword.isNotEmpty()) { liveData = baseNoteDao.getBaseNotesByKeyword(keyword, folder, label)
liveData = baseNoteDao.getBaseNotesByKeyword(keyword, folder) // if (keyword.isNotEmpty())
liveData?.observeForever(observer) // baseNoteDao.getBaseNotesByKeyword(keyword, folder, label)
} else value = emptyList() // else baseNoteDao.getFrom(folder)
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

@ -3,9 +3,9 @@ package com.philkes.notallyx.presentation.activity
import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetManager
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import com.philkes.notallyx.Preferences
import com.philkes.notallyx.data.model.BaseNote import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.presentation.activity.note.PickNoteActivity import com.philkes.notallyx.presentation.activity.note.PickNoteActivity
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.widget.WidgetProvider import com.philkes.notallyx.presentation.widget.WidgetProvider
class ConfigureWidgetActivity : PickNoteActivity() { class ConfigureWidgetActivity : PickNoteActivity() {
@ -27,12 +27,12 @@ class ConfigureWidgetActivity : PickNoteActivity() {
override fun onClick(position: Int) { override fun onClick(position: Int) {
if (position != -1) { if (position != -1) {
val preferences = Preferences.getInstance(application) val preferences = NotallyXPreferences.getInstance(application)
val noteId = (adapter.getItem(position) as BaseNote).id val baseNote = adapter.getItem(position) as BaseNote
preferences.updateWidget(id, noteId) preferences.updateWidget(id, baseNote.id, baseNote.type)
val manager = AppWidgetManager.getInstance(this) val manager = AppWidgetManager.getInstance(this)
WidgetProvider.updateWidget(this, manager, id, noteId) 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

@ -3,33 +3,55 @@ package com.philkes.notallyx.presentation.activity
import android.app.Activity import android.app.Activity
import android.app.KeyguardManager import android.app.KeyguardManager
import android.content.Intent import android.content.Intent
import android.hardware.biometrics.BiometricPrompt.BIOMETRIC_ERROR_HW_NOT_PRESENT
import android.hardware.biometrics.BiometricPrompt.BIOMETRIC_ERROR_NO_BIOMETRICS
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings
import android.view.View.INVISIBLE import android.view.View.INVISIBLE
import android.view.View.VISIBLE import android.view.View.VISIBLE
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.philkes.notallyx.NotallyXApplication import com.philkes.notallyx.NotallyXApplication
import com.philkes.notallyx.Preferences
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.presentation.view.misc.BiometricLock.enabled import com.philkes.notallyx.presentation.showToast
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.utils.security.showBiometricOrPinPrompt import com.philkes.notallyx.utils.security.showBiometricOrPinPrompt
abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() { abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() {
private lateinit var notallyXApplication: NotallyXApplication private lateinit var notallyXApplication: NotallyXApplication
private lateinit var biometricAuthenticationActivityResultLauncher:
ActivityResultLauncher<Intent>
protected lateinit var binding: T protected lateinit var binding: T
protected lateinit var preferences: Preferences protected lateinit var preferences: NotallyXPreferences
val baseModel: BaseNoteModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
notallyXApplication = (application as NotallyXApplication) notallyXApplication = (application as NotallyXApplication)
preferences = Preferences.getInstance(application) preferences = NotallyXPreferences.getInstance(application)
biometricAuthenticationActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
unlock()
} else {
finish()
}
}
} }
override fun onResume() { override fun onResume() {
if (preferences.biometricLock.value == enabled) { if (preferences.biometricLock.value == BiometricLock.ENABLED) {
if (hasToAuthenticateWithBiometric()) { if (hasToAuthenticateWithBiometric()) {
hide() hide()
showLockScreen() showLockScreen()
@ -42,38 +64,64 @@ abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() {
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
if (preferences.biometricLock.value == enabled) { if (
preferences.biometricLock.value == BiometricLock.ENABLED &&
notallyXApplication.locked.value
) {
hide() hide()
} }
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_BIOMETRIC_AUTHENTICATION) {
if (resultCode == Activity.RESULT_OK) {
notallyXApplication.isLocked = false
show()
} else {
finish()
}
}
}
open fun showLockScreen() { open fun showLockScreen() {
showBiometricOrPinPrompt( showBiometricOrPinPrompt(
true, true,
preferences.iv!!, preferences.iv.value!!,
REQUEST_BIOMETRIC_AUTHENTICATION, biometricAuthenticationActivityResultLauncher,
R.string.unlock, R.string.unlock,
onSuccess = { onSuccess = { unlock() },
notallyXApplication.isLocked = false ) { errorCode ->
show() when (errorCode) {
}, BIOMETRIC_ERROR_NO_BIOMETRICS -> {
) { MaterialAlertDialogBuilder(this)
finish() .setMessage(R.string.unlock_with_biometrics_not_setup)
.setPositiveButton(R.string.disable) { _, _ ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
baseModel.disableBiometricLock()
}
show()
}
.setNegativeButton(R.string.tap_to_set_up) { _, _ ->
val intent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Intent(Settings.ACTION_BIOMETRIC_ENROLL)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
Intent(Settings.ACTION_FINGERPRINT_ENROLL)
} else {
Intent(Settings.ACTION_SECURITY_SETTINGS)
}
startActivity(intent)
}
.show()
}
BIOMETRIC_ERROR_HW_NOT_PRESENT -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
baseModel.disableBiometricLock()
showToast(R.string.biometrics_disable_success)
}
show()
}
else -> finish()
}
} }
} }
private fun unlock() {
notallyXApplication.locked.value = false
show()
}
protected fun show() { protected fun show() {
binding.root.visibility = VISIBLE binding.root.visibility = VISIBLE
} }
@ -83,16 +131,12 @@ abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() {
} }
private fun hasToAuthenticateWithBiometric(): Boolean { private fun hasToAuthenticateWithBiometric(): Boolean {
val keyguardManager: KeyguardManager = return ContextCompat.getSystemService(this, KeyguardManager::class.java)?.let {
this.getSystemService(KEYGUARD_SERVICE) as KeyguardManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { (it.isDeviceLocked || notallyXApplication.locked.value)
(keyguardManager.isDeviceLocked || notallyXApplication.isLocked) } else {
} else { false
false }
} } ?: false
}
companion object {
private const val REQUEST_BIOMETRIC_AUTHENTICATION = 11
} }
} }

View file

@ -1,26 +1,24 @@
package com.philkes.notallyx.presentation.activity.main package com.philkes.notallyx.presentation.activity.main
import android.app.Activity
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.print.PostPDFGenerator
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_SYSTEM
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.inputmethod.InputMethodManager import androidx.activity.OnBackPressedCallback
import android.widget.Toast import androidx.activity.result.ActivityResultLauncher
import androidx.activity.viewModels import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.FileProvider
import androidx.core.view.GravityCompat import androidx.core.view.GravityCompat
import androidx.core.view.forEach import androidx.core.view.children
import androidx.core.widget.doAfterTextChanged
import androidx.drawerlayout.widget.DrawerLayout import androidx.drawerlayout.widget.DrawerLayout
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
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
@ -30,41 +28,54 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.platform.MaterialFade import com.google.android.material.transition.platform.MaterialFade
import com.philkes.notallyx.R import com.philkes.notallyx.R
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.Color
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.databinding.ActivityMainBinding import com.philkes.notallyx.databinding.ActivityMainBinding
import com.philkes.notallyx.databinding.DialogColorBinding
import com.philkes.notallyx.presentation.activity.LockedActivity import com.philkes.notallyx.presentation.activity.LockedActivity
import com.philkes.notallyx.presentation.activity.main.fragment.DisplayLabelFragment.Companion.EXTRA_DISPLAYED_LABEL
import com.philkes.notallyx.presentation.activity.main.fragment.NotallyFragment
import com.philkes.notallyx.presentation.activity.note.EditListActivity import com.philkes.notallyx.presentation.activity.note.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.view.main.ColorAdapter import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.view.misc.MenuDialog import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener import com.philkes.notallyx.presentation.view.misc.tristatecheckbox.TriStateCheckBox
import com.philkes.notallyx.presentation.view.misc.tristatecheckbox.setMultiChoiceTriStateItems
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
import com.philkes.notallyx.utils.Operations import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel.Companion.CURRENT_LABEL_EMPTY
import java.io.File import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel.Companion.CURRENT_LABEL_NONE
import com.philkes.notallyx.presentation.viewmodel.ExportMimeType
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.START_VIEW_DEFAULT
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.START_VIEW_UNLABELED
import com.philkes.notallyx.utils.backup.exportNotes
import com.philkes.notallyx.utils.shareNote
import com.philkes.notallyx.utils.showColorSelectDialog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : LockedActivity<ActivityMainBinding>() { class MainActivity : LockedActivity<ActivityMainBinding>() {
private lateinit var navController: NavController private lateinit var navController: NavController
private lateinit var configuration: AppBarConfiguration private lateinit var configuration: AppBarConfiguration
private lateinit var exportFileActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var exportNotesActivityResultLauncher: ActivityResultLauncher<Intent>
private val model: BaseNoteModel by viewModels() private var isStartViewFragment = false
private val actionModeCancelCallback =
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
baseModel.actionMode.close(true)
}
}
override fun onBackPressed() { var getCurrentFragmentNotes: (() -> Collection<BaseNote>?)? = null
if (model.actionMode.enabled.value) {
model.actionMode.close(true)
} else super.onBackPressed()
}
override fun onSupportNavigateUp(): Boolean { override fun onSupportNavigateUp(): Boolean {
baseModel.keyword = ""
return navController.navigateUp(configuration) return navController.navigateUp(configuration)
} }
@ -77,48 +88,183 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
setupMenu() setupMenu()
setupActionMode() setupActionMode()
setupNavigation() setupNavigation()
setupSearch()
setupActivityResultLaunchers()
val fragmentIdToLoad = intent.getIntExtra(EXTRA_FRAGMENT_TO_OPEN, -1)
if (fragmentIdToLoad != -1) {
navController.navigate(fragmentIdToLoad, intent.extras)
} else if (savedInstanceState == null) {
navigateToStartView()
}
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)
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { private fun getStartViewNavigation(): Pair<Int, Bundle> {
super.onActivityResult(requestCode, resultCode, data) return when (val startView = preferences.startView.value) {
if (requestCode == REQUEST_EXPORT_FILE && resultCode == Activity.RESULT_OK) { START_VIEW_DEFAULT -> Pair(R.id.Notes, Bundle())
data?.data?.let { uri -> model.writeCurrentFileToUri(uri) } 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() {
binding.TakeNote.setOnClickListener { binding.TakeNote.setOnClickListener {
val intent = Intent(this, EditNoteActivity::class.java) val intent = Intent(this, EditNoteActivity::class.java)
startActivity(intent) startActivity(prepareNewNoteIntent(intent))
} }
binding.MakeList.setOnClickListener { binding.MakeList.setOnClickListener {
val intent = Intent(this, EditListActivity::class.java) val intent = Intent(this, EditListActivity::class.java)
startActivity(intent) startActivity(prepareNewNoteIntent(intent))
} }
} }
private fun prepareNewNoteIntent(intent: Intent): Intent {
return supportFragmentManager
.findFragmentById(R.id.NavHostFragment)
?.childFragmentManager
?.fragments
?.firstOrNull()
?.let { fragment ->
return if (fragment is NotallyFragment) {
fragment.prepareNewNoteIntent(intent)
} else intent
} ?: intent
}
private var labelsMenuItems: List<MenuItem> = listOf()
private var labelsMoreMenuItem: MenuItem? = null
private var labels: List<String> = listOf()
private var labelsLiveData: LiveData<List<String>>? = null
private fun setupMenu() { 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)
add(1, R.id.Labels, 0, R.string.labels).setCheckable(true).setIcon(R.drawable.label)
add(2, R.id.Deleted, 0, R.string.deleted).setCheckable(true).setIcon(R.drawable.delete) addStaticLabelsMenuItems()
add(2, R.id.Archived, 0, R.string.archived) NotallyDatabase.getDatabase(application).observe(this@MainActivity) { database ->
labelsLiveData?.removeObservers(this@MainActivity)
labelsLiveData =
database.getLabelDao().getAll().also {
it.observe(this@MainActivity) { labels ->
this@MainActivity.labels = labels
setupLabelsMenuItems(labels, preferences.maxLabels.value)
}
}
}
add(2, R.id.Deleted, CATEGORY_SYSTEM + 1, R.string.deleted)
.setCheckable(true)
.setIcon(R.drawable.delete)
add(2, R.id.Archived, CATEGORY_SYSTEM + 2, R.string.archived)
.setCheckable(true) .setCheckable(true)
.setIcon(R.drawable.archive) .setIcon(R.drawable.archive)
add(3, R.id.Settings, 0, R.string.settings) add(3, R.id.Reminders, CATEGORY_SYSTEM + 3, R.string.reminders)
.setCheckable(true)
.setIcon(R.drawable.notifications)
add(3, R.id.Settings, CATEGORY_SYSTEM + 4, R.string.settings)
.setCheckable(true) .setCheckable(true)
.setIcon(R.drawable.settings) .setIcon(R.drawable.settings)
} }
try { baseModel.preferences.labelsHidden.observe(this) { hiddenLabels ->
val pInfo = packageManager.getPackageInfo(packageName, 0) hideLabelsInNavigation(hiddenLabels, baseModel.preferences.maxLabels.value)
val version = pInfo.versionName }
binding.Version.text = "v$version" baseModel.preferences.maxLabels.observe(this) { maxLabels ->
} catch (_: PackageManager.NameNotFoundException) {} binding.NavigationView.menu.setupLabelsMenuItems(labels, maxLabels)
}
}
private fun Menu.addStaticLabelsMenuItems() {
add(1, R.id.Unlabeled, CATEGORY_CONTAINER + 1, R.string.unlabeled)
.setCheckable(true)
.setChecked(baseModel.currentLabel == CURRENT_LABEL_NONE)
.setIcon(R.drawable.label_off)
add(1, R.id.Labels, CATEGORY_CONTAINER + 2, R.string.labels)
.setCheckable(true)
.setIcon(R.drawable.label_more)
}
private fun Menu.setupLabelsMenuItems(labels: List<String>, maxLabelsToDisplay: Int) {
removeGroup(1)
addStaticLabelsMenuItems()
labelsMenuItems =
labels
.mapIndexed { index, label ->
add(1, R.id.DisplayLabel, CATEGORY_CONTAINER + index + 3, label)
.setCheckable(true)
.setChecked(baseModel.currentLabel == label)
.setVisible(index < maxLabelsToDisplay)
.setIcon(R.drawable.label)
.setOnMenuItemClickListener {
navigateToLabel(label)
false
}
}
.toList()
labelsMoreMenuItem =
if (labelsMenuItems.size > maxLabelsToDisplay) {
add(
1,
R.id.Labels,
CATEGORY_CONTAINER + labelsMenuItems.size + 2,
getString(R.string.more, labelsMenuItems.size - maxLabelsToDisplay),
)
.setCheckable(true)
.setIcon(R.drawable.label)
} else null
configuration = AppBarConfiguration(binding.NavigationView.menu, binding.DrawerLayout)
setupActionBarWithNavController(navController, configuration)
hideLabelsInNavigation(baseModel.preferences.labelsHidden.value, maxLabelsToDisplay)
}
private fun navigateToLabel(label: String) {
val bundle = Bundle().apply { putString(EXTRA_DISPLAYED_LABEL, label) }
navController.navigate(R.id.DisplayLabel, bundle)
}
private fun hideLabelsInNavigation(hiddenLabels: Set<String>, maxLabelsToDisplay: Int) {
var visibleLabels = 0
labelsMenuItems.forEach { menuItem ->
val visible =
!hiddenLabels.contains(menuItem.title) && visibleLabels < maxLabelsToDisplay
menuItem.setVisible(visible)
if (visible) {
visibleLabels++
}
}
labelsMoreMenuItem?.setTitle(getString(R.string.more, labels.size - visibleLabels))
} }
private fun setupActionMode() { private fun setupActionMode() {
binding.ActionMode.setNavigationOnClickListener { model.actionMode.close(true) } binding.ActionMode.setNavigationOnClickListener { baseModel.actionMode.close(true) }
val transition = val transition =
MaterialFade().apply { MaterialFade().apply {
@ -130,7 +276,7 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
excludeTarget(binding.NavigationView, true) excludeTarget(binding.NavigationView, true)
} }
model.actionMode.enabled.observe(this) { enabled -> baseModel.actionMode.enabled.observe(this) { enabled ->
TransitionManager.beginDelayedTransition(binding.RelativeLayout, transition) TransitionManager.beginDelayedTransition(binding.RelativeLayout, transition)
if (enabled) { if (enabled) {
binding.Toolbar.visibility = View.GONE binding.Toolbar.visibility = View.GONE
@ -141,257 +287,126 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
binding.ActionMode.visibility = View.GONE binding.ActionMode.visibility = View.GONE
binding.DrawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNDEFINED) binding.DrawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNDEFINED)
} }
actionModeCancelCallback.isEnabled = enabled
} }
val menu = binding.ActionMode.menu val menu = binding.ActionMode.menu
val pinned = menu.add(R.string.pin, R.drawable.pin) {} baseModel.folder.observe(this@MainActivity, ModelFolderObserver(menu, baseModel))
val share = menu.add(R.string.share, R.drawable.share) { share() } baseModel.actionMode.loading.observe(this@MainActivity) { loading ->
val labels = menu.add(R.string.labels, R.drawable.label) { label() } menu.setGroupEnabled(Menu.NONE, !loading)
val export = createExportMenu(menu)
val changeColor = menu.add(R.string.change_color, R.drawable.change_color) { changeColor() }
val delete = menu.add(R.string.delete, R.drawable.delete) { moveNotes(Folder.DELETED) }
val archive = menu.add(R.string.archive, R.drawable.archive) { moveNotes(Folder.ARCHIVED) }
val restore = menu.add(R.string.restore, R.drawable.restore) { moveNotes(Folder.NOTES) }
val unarchive =
menu.add(R.string.unarchive, R.drawable.unarchive) { moveNotes(Folder.NOTES) }
val deleteForever = menu.add(R.string.delete_forever, R.drawable.delete) { deleteForever() }
model.actionMode.count.observe(this) { count ->
if (count == 0) {
menu.forEach { item -> item.setVisible(false) }
} else {
binding.ActionMode.title = count.toString()
val baseNote = model.actionMode.getFirstNote()
if (count == 1) {
if (baseNote.pinned) {
pinned.setTitle(R.string.unpin)
pinned.setIcon(R.drawable.unpin)
} else {
pinned.setTitle(R.string.pin)
pinned.setIcon(R.drawable.pin)
}
pinned.onClick { model.pinBaseNote(!baseNote.pinned) }
}
pinned.setVisible(count == 1)
share.setVisible(count == 1)
labels.setVisible(count == 1)
export.setVisible(count == 1)
changeColor.setVisible(true)
val folder = baseNote.folder
delete.setVisible(folder == Folder.NOTES || folder == Folder.ARCHIVED)
archive.setVisible(folder == Folder.NOTES)
restore.setVisible(folder == Folder.DELETED)
unarchive.setVisible(folder == Folder.ARCHIVED)
deleteForever.setVisible(folder == Folder.DELETED)
}
}
}
private fun createExportMenu(menu: Menu): MenuItem {
return menu
.addSubMenu(R.string.export)
.apply {
setIcon(R.drawable.export)
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
add("PDF").onClick { exportToPDF() }
add("TXT").onClick { exportToTXT() }
add("JSON").onClick { exportToJSON() }
add("HTML").onClick { exportToHTML() }
}
.item
}
fun MenuItem.onClick(function: () -> Unit) {
setOnMenuItemClickListener {
function()
return@setOnMenuItemClickListener false
} }
} }
private fun moveNotes(folderTo: Folder) { private fun moveNotes(folderTo: Folder) {
val folderFrom = model.actionMode.getFirstNote().folder if (baseModel.actionMode.loading.value || baseModel.actionMode.isEmpty()) {
val ids = model.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) { model.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 = model.actionMode.getFirstNote() val baseNote = baseModel.actionMode.getFirstNote()
val body = this.shareNote(baseNote)
when (baseNote.type) {
Type.NOTE -> baseNote.body.applySpans(baseNote.spans)
Type.LIST -> Operations.getBody(baseNote.items)
}
Operations.shareNote(this, baseNote.title, body)
}
private fun changeColor() {
val dialog = MaterialAlertDialogBuilder(this).setTitle(R.string.change_color).create()
val colorAdapter =
ColorAdapter(
object : ListItemListener {
override fun onClick(position: Int) {
dialog.dismiss()
val color = Color.entries[position]
model.colorBaseNote(color)
}
override fun onLongClick(position: Int) {}
}
)
DialogColorBinding.inflate(layoutInflater).apply {
RecyclerView.adapter = colorAdapter
dialog.setView(root)
dialog.show()
}
} }
private fun deleteForever() { private fun deleteForever() {
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this)
.setMessage(R.string.delete_selected_notes) .setMessage(R.string.delete_selected_notes)
.setPositiveButton(R.string.delete) { _, _ -> model.deleteSelectedBaseNotes() } .setPositiveButton(R.string.delete) { _, _ -> baseModel.deleteSelectedBaseNotes() }
.setNegativeButton(R.string.cancel, null) .setCancelButton()
.show() .show()
} }
private fun label() { private fun label() {
val baseNote = model.actionMode.getFirstNote() val baseNotes = baseModel.actionMode.selectedNotes.values
lifecycleScope.launch { lifecycleScope.launch {
val labels = model.getAllLabels() val labels = baseModel.getAllLabels()
if (labels.isNotEmpty()) { if (labels.isNotEmpty()) {
displaySelectLabelsDialog(labels, baseNote) displaySelectLabelsDialog(labels, baseNotes)
} else { } else {
model.actionMode.close(true) baseModel.actionMode.close(true)
navigateWithAnimation(R.id.Labels) navigateWithAnimation(R.id.Labels)
} }
} }
} }
private fun displaySelectLabelsDialog(labels: Array<String>, baseNote: BaseNote) { private fun displaySelectLabelsDialog(labels: Array<String>, baseNotes: Collection<BaseNote>) {
val checkedPositions = val checkedPositions =
BooleanArray(labels.size) { index -> baseNote.labels.contains(labels[index]) } labels
.map { label ->
if (baseNotes.all { it.labels.contains(label) }) {
TriStateCheckBox.State.CHECKED
} else if (baseNotes.any { it.labels.contains(label) }) {
TriStateCheckBox.State.PARTIALLY_CHECKED
} else {
TriStateCheckBox.State.UNCHECKED
}
}
.toTypedArray()
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this)
.setTitle(R.string.labels) .setTitle(R.string.labels)
.setNegativeButton(R.string.cancel, null) .setCancelButton()
.setMultiChoiceItems(labels, checkedPositions) { _, which, isChecked -> .setMultiChoiceTriStateItems(this, labels, checkedPositions) { idx, state ->
checkedPositions[which] = isChecked checkedPositions[idx] = state
} }
.setPositiveButton(R.string.save) { _, _ -> .setPositiveButton(R.string.save) { _, _ ->
val new = ArrayList<String>() val checkedLabels =
checkedPositions.forEachIndexed { index, checked -> checkedPositions.mapIndexedNotNull { index, checked ->
if (checked) { if (checked == TriStateCheckBox.State.CHECKED) {
val label = labels[index] labels[index]
new.add(label) } else null
} }
val uncheckedLabels =
checkedPositions.mapIndexedNotNull { index, checked ->
if (checked == TriStateCheckBox.State.UNCHECKED) {
labels[index]
} else null
}
val updatedBaseNotesLabels =
baseNotes.map { baseNote ->
val noteLabels = baseNote.labels.toMutableList()
checkedLabels.forEach { checkedLabel ->
if (!noteLabels.contains(checkedLabel)) {
noteLabels.add(checkedLabel)
}
}
uncheckedLabels.forEach { uncheckedLabel ->
if (noteLabels.contains(uncheckedLabel)) {
noteLabels.remove(uncheckedLabel)
}
}
noteLabels
}
baseNotes.zip(updatedBaseNotesLabels).forEach { (baseNote, updatedLabels) ->
baseModel.updateBaseNoteLabels(updatedLabels, baseNote.id)
} }
model.updateBaseNoteLabels(new, baseNote.id)
} }
.show() .show()
} }
private fun exportToPDF() { private fun exportSelectedNotes(mimeType: ExportMimeType) {
val baseNote = model.actionMode.getFirstNote() exportNotes(
model.getPDFFile( mimeType,
baseNote, baseModel.actionMode.selectedNotes.values,
object : PostPDFGenerator.OnResult { exportFileActivityResultLauncher,
exportNotesActivityResultLauncher,
override fun onSuccess(file: File) {
showFileOptionsDialog(file, "application/pdf")
}
override fun onFailure(message: CharSequence?) {
Toast.makeText(
this@MainActivity,
R.string.something_went_wrong,
Toast.LENGTH_SHORT,
)
.show()
}
},
) )
} }
private fun exportToTXT() {
val baseNote = model.actionMode.getFirstNote()
lifecycleScope.launch {
val file = model.getTXTFile(baseNote)
showFileOptionsDialog(file, "text/plain")
}
}
private fun exportToJSON() {
val baseNote = model.actionMode.getFirstNote()
lifecycleScope.launch {
val file = model.getJSONFile(baseNote)
showFileOptionsDialog(file, "application/json")
}
}
private fun exportToHTML() {
val baseNote = model.actionMode.getFirstNote()
lifecycleScope.launch {
val file = model.getHTMLFile(baseNote)
showFileOptionsDialog(file, "text/html")
}
}
private fun showFileOptionsDialog(file: File, mimeType: String) {
val uri = FileProvider.getUriForFile(this, "${packageName}.provider", file)
MenuDialog(this)
.add(R.string.share) { shareFile(uri, mimeType) }
.add(R.string.view_file) { viewFile(uri, mimeType) }
.add(R.string.save_to_device) { saveFileToDevice(file, mimeType) }
.show()
}
private fun viewFile(uri: Uri, mimeType: String) {
val intent =
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
}
val chooser = Intent.createChooser(intent, getString(R.string.view_note))
startActivity(chooser)
}
private fun shareFile(uri: Uri, mimeType: String) {
val intent =
Intent(Intent.ACTION_SEND).apply {
type = mimeType
putExtra(Intent.EXTRA_STREAM, uri)
}
val chooser = Intent.createChooser(intent, null)
startActivity(chooser)
}
private fun saveFileToDevice(file: File, mimeType: String) {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
type = mimeType
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_TITLE, file.nameWithoutExtension)
}
model.currentFile = file
startActivityForResult(intent, REQUEST_EXPORT_FILE)
}
private fun setupNavigation() { private fun setupNavigation() {
val navHostFragment = val navHostFragment =
supportFragmentManager.findFragmentById(R.id.NavHostFragment) as NavHostFragment supportFragmentManager.findFragmentById(R.id.NavHostFragment) as NavHostFragment
@ -420,35 +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) {
handleDestinationChange(destination) R.id.DisplayLabel ->
bundle?.getString(EXTRA_DISPLAYED_LABEL)?.let {
baseModel.currentLabel = it
binding.NavigationView.menu.children
.find { menuItem -> menuItem.title == it }
?.let { menuItem -> menuItem.isChecked = true }
}
R.id.Unlabeled -> {
baseModel.currentLabel = CURRENT_LABEL_NONE
binding.NavigationView.setCheckedItem(destination.id)
}
else -> {
baseModel.currentLabel = CURRENT_LABEL_EMPTY
binding.NavigationView.setCheckedItem(destination.id)
}
}
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 {
if (destination.id == R.id.Notes) { val (startViewId, startViewBundle) = getStartViewNavigation()
binding.TakeNote.show() return startViewId == id &&
binding.MakeList.show() startViewBundle.getString(EXTRA_DISPLAYED_LABEL) ==
} else { bundle?.getString(EXTRA_DISPLAYED_LABEL)
binding.TakeNote.hide()
binding.MakeList.hide()
}
val inputManager = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
if (destination.id == R.id.Search) {
binding.EnterSearchKeyword.apply {
visibility = View.VISIBLE
requestFocus()
inputManager.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
}
} else {
binding.EnterSearchKeyword.apply {
visibility = View.GONE
inputManager.hideSoftInputFromWindow(this.windowToken, 0)
}
}
} }
private fun navigateWithAnimation(id: Int) { private fun navigateWithAnimation(id: Int) {
@ -465,14 +492,196 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
navController.navigate(id, null, options) navController.navigate(id, null, options)
} }
private fun setupSearch() { private fun setupActivityResultLaunchers() {
binding.EnterSearchKeyword.apply { exportFileActivityResultLauncher =
setText(model.keyword) registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
doAfterTextChanged { text -> model.keyword = requireNotNull(text).trim().toString() } if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri -> baseModel.exportSelectedFileToUri(uri) }
}
}
exportNotesActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri -> baseModel.exportSelectedNotesToFolder(uri) }
}
}
}
private inner class ModelFolderObserver(
private val menu: Menu,
private val model: BaseNoteModel,
) : Observer<Folder> {
override fun onChanged(value: Folder) {
menu.clear()
model.actionMode.count.removeObservers(this@MainActivity)
menu.add(
R.string.select_all,
R.drawable.select_all,
showAsAction = MenuItem.SHOW_AS_ACTION_ALWAYS,
) {
getCurrentFragmentNotes?.invoke()?.let { model.actionMode.add(it) }
}
when (value) {
Folder.NOTES -> {
val pinned = menu.addPinned(MenuItem.SHOW_AS_ACTION_ALWAYS)
menu.addLabels(MenuItem.SHOW_AS_ACTION_ALWAYS)
menu.addDelete(MenuItem.SHOW_AS_ACTION_ALWAYS)
menu.add(R.string.archive, R.drawable.archive) { moveNotes(Folder.ARCHIVED) }
menu.addChangeColor()
val share = menu.addShare()
menu.addExportMenu()
model.actionMode.count.observeCountAndPinned(this@MainActivity, share, pinned)
}
Folder.ARCHIVED -> {
menu.add(
R.string.unarchive,
R.drawable.unarchive,
MenuItem.SHOW_AS_ACTION_ALWAYS,
) {
moveNotes(Folder.NOTES)
}
menu.addDelete(MenuItem.SHOW_AS_ACTION_ALWAYS)
menu.addExportMenu(MenuItem.SHOW_AS_ACTION_ALWAYS)
val pinned = menu.addPinned()
menu.addLabels()
menu.addChangeColor()
val share = menu.addShare()
model.actionMode.count.observeCountAndPinned(this@MainActivity, share, pinned)
}
Folder.DELETED -> {
menu.add(R.string.restore, R.drawable.restore, MenuItem.SHOW_AS_ACTION_ALWAYS) {
moveNotes(Folder.NOTES)
}
menu.add(
R.string.delete_forever,
R.drawable.delete,
MenuItem.SHOW_AS_ACTION_ALWAYS,
) {
deleteForever()
}
menu.addExportMenu()
menu.addChangeColor()
val share = menu.add(R.string.share, R.drawable.share) { share() }
model.actionMode.count.observeCount(this@MainActivity, share)
}
}
}
private fun Menu.addPinned(showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM): MenuItem {
return add(R.string.pin, R.drawable.pin, showAsAction) {}
}
private fun Menu.addLabels(showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM): MenuItem {
return add(R.string.labels, R.drawable.label, showAsAction) { label() }
}
private fun Menu.addChangeColor(
showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM
): MenuItem {
return add(R.string.change_color, R.drawable.change_color, showAsAction) {
lifecycleScope.launch {
val colors =
withContext(Dispatchers.IO) {
NotallyDatabase.getDatabase(
this@MainActivity,
observePreferences = false,
)
.value
.getBaseNoteDao()
.getAllColors()
}
// Show color as selected only if all selected notes have the same color
val currentColor =
model.actionMode.selectedNotes.values
.map { it.color }
.distinct()
.takeIf { it.size == 1 }
?.firstOrNull()
showColorSelectDialog(
colors,
currentColor,
null,
{ selectedColor, oldColor ->
if (oldColor != null) {
model.changeColor(oldColor, selectedColor)
}
model.colorBaseNote(selectedColor)
},
) { colorToDelete, newColor ->
model.changeColor(colorToDelete, newColor)
}
}
}
}
private fun Menu.addDelete(showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM): MenuItem {
return add(R.string.delete, R.drawable.delete, showAsAction) {
moveNotes(Folder.DELETED)
}
}
private fun Menu.addShare(showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM): MenuItem {
return add(R.string.share, R.drawable.share, showAsAction) { share() }
}
private fun Menu.addExportMenu(
showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM
): MenuItem {
return addSubMenu(R.string.export)
.apply {
setIcon(R.drawable.export)
item.setShowAsAction(showAsAction)
ExportMimeType.entries.forEach {
add(it.name).onClick { exportSelectedNotes(it) }
}
}
.item
}
fun MenuItem.onClick(function: () -> Unit) {
setOnMenuItemClickListener {
function()
return@setOnMenuItemClickListener false
}
}
private fun NotNullLiveData<Int>.observeCount(
lifecycleOwner: LifecycleOwner,
share: MenuItem,
onCountChange: ((Int) -> Unit)? = null,
) {
observe(lifecycleOwner) { count ->
binding.ActionMode.title = count.toString()
onCountChange?.invoke(count)
share.setVisible(count == 1)
}
}
private fun NotNullLiveData<Int>.observeCountAndPinned(
lifecycleOwner: LifecycleOwner,
share: MenuItem,
pinned: MenuItem,
) {
observeCount(lifecycleOwner, share) {
val baseNotes = model.actionMode.selectedNotes.values
if (baseNotes.any { !it.pinned }) {
pinned.setTitle(R.string.pin).setIcon(R.drawable.pin).onClick {
model.pinBaseNotes(true)
}
} else {
pinned.setTitle(R.string.unpin).setIcon(R.drawable.unpin).onClick {
model.pinBaseNotes(false)
}
}
}
} }
} }
companion object { companion object {
private const val REQUEST_EXPORT_FILE = 10 const val EXTRA_FRAGMENT_TO_OPEN = "notallyx.intent.extra.FRAGMENT_TO_OPEN"
const val EXTRA_SKIP_START_VIEW_ON_BACK = "notallyx.intent.extra.SKIP_START_VIEW_ON_BACK"
} }
} }

View file

@ -1,9 +1,17 @@
package com.philkes.notallyx.presentation.activity.main.fragment package com.philkes.notallyx.presentation.activity.main.fragment
import android.os.Bundle
import android.view.View
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.Folder
class ArchivedFragment : NotallyFragment() { class ArchivedFragment : NotallyFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
model.folder.value = Folder.ARCHIVED
}
override fun getBackground() = R.drawable.archive override fun getBackground() = R.drawable.archive
override fun getObservable() = model.archivedNotes!! override fun getObservable() = model.archivedNotes!!

View file

@ -1,13 +1,22 @@
package com.philkes.notallyx.presentation.activity.main.fragment package com.philkes.notallyx.presentation.activity.main.fragment
import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.View
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.Folder
import com.philkes.notallyx.presentation.add import com.philkes.notallyx.presentation.add
import com.philkes.notallyx.presentation.setCancelButton
class DeletedFragment : NotallyFragment() { class DeletedFragment : NotallyFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
model.folder.value = Folder.DELETED
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
menu.add(R.string.delete_all, R.drawable.delete_all) { deleteAllNotes() } menu.add(R.string.delete_all, R.drawable.delete_all) { deleteAllNotes() }
} }
@ -16,7 +25,7 @@ class DeletedFragment : NotallyFragment() {
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.delete_all_notes) .setMessage(R.string.delete_all_notes)
.setPositiveButton(R.string.delete) { _, _ -> model.deleteAllTrashedBaseNotes() } .setPositiveButton(R.string.delete) { _, _ -> model.deleteAllTrashedBaseNotes() }
.setNegativeButton(R.string.cancel, null) .setCancelButton()
.show() .show()
} }

View file

@ -1,16 +1,34 @@
package com.philkes.notallyx.presentation.activity.main.fragment package com.philkes.notallyx.presentation.activity.main.fragment
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.data.model.Item import com.philkes.notallyx.data.model.Item
import com.philkes.notallyx.presentation.view.Constants
class DisplayLabelFragment : NotallyFragment() { class DisplayLabelFragment : NotallyFragment() {
private lateinit var label: String
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
model.folder.value = Folder.NOTES
}
override fun getBackground() = R.drawable.label override fun getBackground() = R.drawable.label
override fun getObservable(): LiveData<List<Item>> { override fun getObservable(): LiveData<List<Item>> {
val label = requireNotNull(requireArguments().getString(Constants.SelectedLabel)) label = requireNotNull(requireArguments().getString(EXTRA_DISPLAYED_LABEL))
return model.getNotesByLabel(label) return model.getNotesByLabel(label)
} }
override fun prepareNewNoteIntent(intent: Intent): Intent {
return intent.putExtra(EXTRA_DISPLAYED_LABEL, label)
}
companion object {
const val EXTRA_DISPLAYED_LABEL = "notallyx.intent.extra.DISPLAYED_LABEL"
}
} }

View file

@ -6,28 +6,29 @@ 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
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
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.model.Label import com.philkes.notallyx.data.model.Label
import com.philkes.notallyx.databinding.DialogInputBinding 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.add import com.philkes.notallyx.presentation.add
import com.philkes.notallyx.presentation.displayEditLabelDialog
import com.philkes.notallyx.presentation.initListView
import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.showAndFocus import com.philkes.notallyx.presentation.showAndFocus
import com.philkes.notallyx.presentation.view.Constants import com.philkes.notallyx.presentation.showToast
import com.philkes.notallyx.presentation.view.main.LabelAdapter import com.philkes.notallyx.presentation.view.main.label.LabelAdapter
import com.philkes.notallyx.presentation.view.misc.MenuDialog import com.philkes.notallyx.presentation.view.main.label.LabelData
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener import com.philkes.notallyx.presentation.view.main.label.LabelListener
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
class LabelsFragment : Fragment(), ListItemListener { class LabelsFragment : Fragment(), LabelListener {
private var labelAdapter: LabelAdapter? = null private var labelAdapter: LabelAdapter? = null
private var binding: FragmentNotesBinding? = null private var binding: FragmentNotesBinding? = null
@ -43,13 +44,9 @@ class LabelsFragment : Fragment(), ListItemListener {
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 {
setHasFixedSize(true) initListView(requireContext())
adapter = labelAdapter adapter = labelAdapter
layoutManager = LinearLayoutManager(requireContext())
val itemDecoration = DividerItemDecoration(requireContext(), RecyclerView.VERTICAL)
addItemDecoration(itemDecoration)
setPadding(0, 0, 0, 0)
binding?.ImageView?.setImageResource(R.drawable.label) binding?.ImageView?.setImageResource(R.drawable.label)
} }
@ -71,25 +68,45 @@ class LabelsFragment : Fragment(), ListItemListener {
} }
override fun onClick(position: Int) { override fun onClick(position: Int) {
labelAdapter?.currentList?.get(position)?.let { value -> labelAdapter?.currentList?.get(position)?.let { (label, _) ->
val bundle = Bundle() val bundle = Bundle()
bundle.putString(Constants.SelectedLabel, value) bundle.putString(EXTRA_DISPLAYED_LABEL, label)
findNavController().navigate(R.id.LabelsToDisplayLabel, bundle) findNavController().navigate(R.id.LabelsToDisplayLabel, bundle)
} }
} }
override fun onLongClick(position: Int) { override fun onEdit(position: Int) {
labelAdapter?.currentList?.get(position)?.let { (label, _) ->
displayEditLabelDialog(label, model)
}
}
override fun onDelete(position: Int) {
labelAdapter?.currentList?.get(position)?.let { (label, _) -> confirmDeletion(label) }
}
override fun onToggleVisibility(position: Int) {
labelAdapter?.currentList?.get(position)?.let { value -> labelAdapter?.currentList?.get(position)?.let { value ->
MenuDialog(requireContext()) val hiddenLabels = model.preferences.labelsHidden.value.toMutableSet()
.add(R.string.edit) { displayEditLabelDialog(value) } if (value.visibleInNavigation) {
.add(R.string.delete) { confirmDeletion(value) } hiddenLabels.add(value.label)
.show() } else {
hiddenLabels.remove(value.label)
}
model.savePreference(model.preferences.labelsHidden, hiddenLabels)
val currentList = labelAdapter!!.currentList.toMutableList()
currentList[position] =
currentList[position].copy(visibleInNavigation = !value.visibleInNavigation)
labelAdapter!!.submitList(currentList)
} }
} }
private fun setupObserver() { private fun setupObserver() {
model.labels.observe(viewLifecycleOwner) { labels -> model.labels.observe(viewLifecycleOwner) { labels ->
labelAdapter?.submitList(labels) val hiddenLabels = model.preferences.labelsHidden.value
val labelsData = labels.map { label -> LabelData(label, !hiddenLabels.contains(label)) }
labelAdapter?.submitList(labelsData)
binding?.ImageView?.isVisible = labels.isEmpty() binding?.ImageView?.isVisible = labels.isEmpty()
} }
} }
@ -101,7 +118,7 @@ class LabelsFragment : Fragment(), ListItemListener {
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.add_label) .setTitle(R.string.add_label)
.setView(dialogBinding.root) .setView(dialogBinding.root)
.setNegativeButton(R.string.cancel, null) .setCancelButton()
.setPositiveButton(R.string.save) { dialog, _ -> .setPositiveButton(R.string.save) { dialog, _ ->
val value = dialogBinding.EditText.text.toString().trim() val value = dialogBinding.EditText.text.toString().trim()
if (value.isNotEmpty()) { if (value.isNotEmpty()) {
@ -109,12 +126,18 @@ class LabelsFragment : Fragment(), ListItemListener {
model.insertLabel(label) { success: Boolean -> model.insertLabel(label) { success: Boolean ->
if (success) { if (success) {
dialog.dismiss() dialog.dismiss()
} else } else {
Toast.makeText(context, R.string.label_exists, Toast.LENGTH_LONG).show() showToast(R.string.label_exists)
}
} }
} }
} }
.showAndFocus(dialogBinding.EditText) .showAndFocus(dialogBinding.EditText, allowFullSize = true) { positiveButton ->
dialogBinding.EditText.doAfterTextChanged { text ->
positiveButton.isEnabled = !text.isNullOrEmpty()
}
positiveButton.isEnabled = false
}
} }
private fun confirmDeletion(value: String) { private fun confirmDeletion(value: String) {
@ -122,35 +145,7 @@ class LabelsFragment : Fragment(), ListItemListener {
.setTitle(R.string.delete_label) .setTitle(R.string.delete_label)
.setMessage(R.string.your_notes_associated) .setMessage(R.string.your_notes_associated)
.setPositiveButton(R.string.delete) { _, _ -> model.deleteLabel(value) } .setPositiveButton(R.string.delete) { _, _ -> model.deleteLabel(value) }
.setNegativeButton(R.string.cancel, null) .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)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.save) { dialog, _ ->
val value = dialogBinding.EditText.text.toString().trim()
if (value.isNotEmpty()) {
model.updateLabel(oldValue, value) { success ->
if (success) {
dialog.dismiss()
} else
Toast.makeText(
requireContext(),
R.string.label_exists,
Toast.LENGTH_LONG,
)
.show()
}
}
}
.showAndFocus(dialogBinding.EditText)
}
} }

View file

@ -6,10 +6,14 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.activity.result.ActivityResultLauncher
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
@ -20,22 +24,31 @@ import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.data.model.Item 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.note.EditActivity.Companion.FOLDER_FROM import com.philkes.notallyx.presentation.activity.main.MainActivity
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.FOLDER_TO import com.philkes.notallyx.presentation.activity.main.fragment.SearchFragment.Companion.EXTRA_INITIAL_FOLDER
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.NOTE_ID import com.philkes.notallyx.presentation.activity.main.fragment.SearchFragment.Companion.EXTRA_INITIAL_LABEL
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_FOLDER_FROM
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_FOLDER_TO
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_NOTE_ID
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_SELECTED_BASE_NOTE
import com.philkes.notallyx.presentation.activity.note.EditListActivity import com.philkes.notallyx.presentation.activity.note.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.view.Constants 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.misc.View as ViewPref import com.philkes.notallyx.presentation.view.main.BaseNoteVHPreferences
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener import com.philkes.notallyx.presentation.view.misc.ItemListener
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
import com.philkes.notallyx.presentation.viewmodel.preference.NotesView
abstract class NotallyFragment : Fragment(), ListItemListener { abstract class NotallyFragment : Fragment(), ItemListener {
private var notesAdapter: BaseNoteAdapter? = null private var notesAdapter: BaseNoteAdapter? = null
private lateinit var openNoteActivityResultLauncher: ActivityResultLauncher<Intent>
private var lastSelectedNotePosition = -1
internal var binding: FragmentNotesBinding? = null internal var binding: FragmentNotesBinding? = null
internal val model: BaseNoteModel by activityViewModels() internal val model: BaseNoteModel by activityViewModels()
@ -46,12 +59,67 @@ abstract class NotallyFragment : Fragment(), ListItemListener {
notesAdapter = null notesAdapter = null
} }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
val layoutManager = binding?.MainListView?.layoutManager as? LinearLayoutManager
if (layoutManager != null) {
val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()
if (firstVisiblePosition != RecyclerView.NO_POSITION) {
val firstVisibleView = layoutManager.findViewByPosition(firstVisiblePosition)
val offset = firstVisibleView?.top ?: 0
outState.putInt(EXTRA_SCROLL_POS, firstVisiblePosition)
outState.putInt(EXTRA_SCROLL_OFFSET, offset)
}
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding?.ImageView?.setImageResource(getBackground()) binding?.ImageView?.setImageResource(getBackground())
setupAdapter() setupAdapter()
setupRecyclerView() setupRecyclerView()
setupObserver() setupObserver()
setupSearch()
setupActivityResultLaunchers()
savedInstanceState?.let { bundle ->
val scrollPosition = bundle.getInt(EXTRA_SCROLL_POS, -1)
val scrollOffset = bundle.getInt(EXTRA_SCROLL_OFFSET, 0)
if (scrollPosition > -1) {
binding?.MainListView?.post {
val layoutManager = binding?.MainListView?.layoutManager as? LinearLayoutManager
layoutManager?.scrollToPositionWithOffset(scrollPosition, scrollOffset)
}
}
}
}
private fun setupActivityResultLaunchers() {
openNoteActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
// If a note has been moved inside of EditActivity
// present snackbar to undo it
val data = result.data
val id = data?.getLongExtra(EXTRA_NOTE_ID, -1)
if (id != null) {
val folderFrom = Folder.valueOf(data.getStringExtra(EXTRA_FOLDER_FROM)!!)
val folderTo = Folder.valueOf(data.getStringExtra(EXTRA_FOLDER_TO)!!)
Snackbar.make(
binding!!.root,
requireContext().getQuantityString(folderTo.movedToResId(), 1),
Snackbar.LENGTH_SHORT,
)
.apply {
setAction(R.string.undo) {
model.moveBaseNotes(longArrayOf(id), folderFrom)
}
}
.show()
}
}
}
} }
override fun onCreateView( override fun onCreateView(
@ -84,35 +152,63 @@ abstract class NotallyFragment : Fragment(), ListItemListener {
override fun onLongClick(position: Int) { override fun onLongClick(position: Int) {
if (position != -1) { if (position != -1) {
notesAdapter?.getItem(position)?.let { item -> if (model.actionMode.selectedNotes.isNotEmpty()) {
if (item is BaseNote) { if (lastSelectedNotePosition > position) {
handleNoteSelection(item.id, position, item) position..lastSelectedNotePosition
} else {
lastSelectedNotePosition..position
}
.forEach { pos ->
notesAdapter!!.getItem(pos)?.let { item ->
if (item is BaseNote) {
if (!model.actionMode.selectedNotes.contains(item.id)) {
handleNoteSelection(item.id, pos, item)
}
}
}
}
} else {
notesAdapter?.getItem(position)?.let { item ->
if (item is BaseNote) {
handleNoteSelection(item.id, position, item)
}
} }
} }
} }
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { private fun setupSearch() {
super.onActivityResult(requestCode, resultCode, data) binding?.EnterSearchKeyword?.apply {
if (resultCode == RESULT_OK) { setText(model.keyword)
if (requestCode == REQUEST_NOTE_EDIT) { val navController = findNavController()
// If a note has been moved inside of EditActivity navController.addOnDestinationChangedListener { controller, destination, arguments ->
// present snackbar to undo it if (destination.id == R.id.Search) {
val id = data?.getLongExtra(NOTE_ID, -1) // setText("")
if (id != null) { visibility = View.VISIBLE
val folderFrom = Folder.valueOf(data.getStringExtra(FOLDER_FROM)!!) requestFocus()
val folderTo = Folder.valueOf(data.getStringExtra(FOLDER_TO)!!) activity?.showKeyboard(this)
Snackbar.make( } else {
binding!!.root, // visibility = View.GONE
requireContext().getQuantityString(folderTo.movedToResId(), 1), setText("")
Snackbar.LENGTH_SHORT, clearFocus()
) activity?.hideKeyboard(this)
.apply { }
setAction(R.string.undo) { }
model.moveBaseNotes(longArrayOf(id), folderFrom) doAfterTextChanged { text ->
} val isSearchFragment = navController.currentDestination?.id == R.id.Search
} if (isSearchFragment) {
.show() 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)
},
)
} }
} }
} }
@ -121,22 +217,28 @@ abstract class NotallyFragment : Fragment(), ListItemListener {
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)
} else model.actionMode.add(id, baseNote) } else {
model.actionMode.add(id, baseNote)
lastSelectedNotePosition = position
}
notesAdapter?.notifyItemChanged(position, 0) notesAdapter?.notifyItemChanged(position, 0)
} }
private fun setupAdapter() { private fun setupAdapter() {
notesAdapter = notesAdapter =
with(model.preferences) { with(model.preferences) {
BaseNoteAdapter( BaseNoteAdapter(
model.actionMode.selectedIds, model.actionMode.selectedIds,
dateFormat.value, dateFormat.value,
notesSorting.value.first, notesSorting.value,
textSize.value, BaseNoteVHPreferences(
maxItems, textSize.value,
maxLines, maxItems.value,
maxTitle, maxLines.value,
maxTitle.value,
labelTagsHiddenInOverview.value,
imagesHiddenInOverview.value,
),
model.imageRoot, model.imageRoot,
this@NotallyFragment, this@NotallyFragment,
) )
@ -146,14 +248,20 @@ abstract class NotallyFragment : Fragment(), ListItemListener {
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(true) setHasFixedSize(false)
}
model.actionMode.addListener = { notesAdapter?.notifyDataSetChanged() }
if (activity is MainActivity) {
(activity as MainActivity).getCurrentFragmentNotes = {
notesAdapter?.currentList?.filterIsInstance<BaseNote>()
}
} }
} }
@ -163,8 +271,8 @@ abstract class NotallyFragment : Fragment(), ListItemListener {
binding?.ImageView?.isVisible = list.isEmpty() binding?.ImageView?.isVisible = list.isEmpty()
} }
model.preferences.notesSorting.observe(viewLifecycleOwner) { (sortBy, sortDirection) -> model.preferences.notesSorting.observe(viewLifecycleOwner) { notesSort ->
notesAdapter?.setSorting(sortBy, sortDirection) notesAdapter?.setNotesSort(notesSort)
} }
model.actionMode.closeListener.observe(viewLifecycleOwner) { event -> model.actionMode.closeListener.observe(viewLifecycleOwner) { event ->
@ -179,23 +287,28 @@ abstract class NotallyFragment : Fragment(), ListItemListener {
} }
private fun setupRecyclerView() { private fun setupRecyclerView() {
binding?.RecyclerView?.layoutManager = binding?.MainListView?.layoutManager =
if (model.preferences.view.value == ViewPref.grid) { if (model.preferences.notesView.value == NotesView.GRID) {
StaggeredGridLayoutManager(2, RecyclerView.VERTICAL) StaggeredGridLayoutManager(2, RecyclerView.VERTICAL)
} else LinearLayoutManager(requireContext()) } else LinearLayoutManager(requireContext())
} }
private fun goToActivity(activity: Class<*>, baseNote: BaseNote) { private fun goToActivity(activity: Class<*>, baseNote: BaseNote) {
val intent = Intent(requireContext(), activity) val intent = Intent(requireContext(), activity)
intent.putExtra(Constants.SelectedBaseNote, baseNote.id) intent.putExtra(EXTRA_SELECTED_BASE_NOTE, baseNote.id)
startActivityForResult(intent, REQUEST_NOTE_EDIT) openNoteActivityResultLauncher.launch(intent)
} }
abstract fun getBackground(): Int abstract fun getBackground(): Int
abstract fun getObservable(): LiveData<List<Item>> abstract fun getObservable(): LiveData<List<Item>>
open fun prepareNewNoteIntent(intent: Intent): Intent {
return intent
}
companion object { companion object {
private const val REQUEST_NOTE_EDIT = 11 private const val EXTRA_SCROLL_POS = "notallyx.intent.extra.SCROLL_POS"
private const val EXTRA_SCROLL_OFFSET = "notallyx.intent.extra.SCROLL_OFFSET"
} }
} }

View file

@ -1,17 +1,15 @@
package com.philkes.notallyx.presentation.activity.main.fragment package com.philkes.notallyx.presentation.activity.main.fragment
import android.view.Menu import android.os.Bundle
import android.view.MenuInflater import android.view.View
import androidx.navigation.fragment.findNavController
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.presentation.add import com.philkes.notallyx.data.model.Folder
class NotesFragment : NotallyFragment() { class NotesFragment : NotallyFragment() {
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
menu.add(R.string.search, R.drawable.search) { super.onViewCreated(view, savedInstanceState)
findNavController().navigate(R.id.NotesToSearch) model.folder.value = Folder.NOTES
}
} }
override fun getObservable() = model.baseNotes!! override fun getObservable() = model.baseNotes!!

View file

@ -0,0 +1,84 @@
package com.philkes.notallyx.presentation.activity.main.fragment
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.philkes.notallyx.R
import com.philkes.notallyx.data.dao.NoteReminder
import com.philkes.notallyx.data.model.hasAnyUpcomingNotifications
import com.philkes.notallyx.databinding.FragmentRemindersBinding
import com.philkes.notallyx.presentation.activity.note.reminders.RemindersActivity
import com.philkes.notallyx.presentation.initListView
import com.philkes.notallyx.presentation.view.main.reminder.NoteReminderAdapter
import com.philkes.notallyx.presentation.view.main.reminder.NoteReminderListener
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
import com.philkes.notallyx.utils.getOpenNoteIntent
class RemindersFragment : Fragment(), NoteReminderListener {
private var reminderAdapter: NoteReminderAdapter? = null
private var binding: FragmentRemindersBinding? = null
private lateinit var allReminders: List<NoteReminder>
private val model: BaseNoteModel by activityViewModels()
override fun onDestroyView() {
super.onDestroyView()
binding = null
reminderAdapter = null
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
reminderAdapter = NoteReminderAdapter(this)
binding?.MainListView?.apply {
initListView(requireContext())
adapter = reminderAdapter
binding?.ImageView?.setImageResource(R.drawable.notifications)
}
binding?.ChipGroup?.setOnCheckedStateChangeListener { _, _ -> updateList() }
model.reminders.observe(viewLifecycleOwner) { reminders ->
allReminders = reminders.sortedBy { it.title }
updateList()
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View? {
setHasOptionsMenu(true)
binding = FragmentRemindersBinding.inflate(inflater)
return binding?.root
}
private fun updateList() {
val list =
when (binding?.ChipGroup?.checkedChipId) {
R.id.Upcoming -> allReminders.filter { it.reminders.hasAnyUpcomingNotifications() }
R.id.Past -> allReminders.filter { !it.reminders.hasAnyUpcomingNotifications() }
else -> allReminders
}
reminderAdapter?.submitList(list)
binding?.ImageView?.isVisible = list.isEmpty()
}
override fun openReminder(reminder: NoteReminder) {
val intent =
Intent(requireContext(), RemindersActivity::class.java).apply {
putExtra(RemindersActivity.NOTE_ID, reminder.id)
}
startActivity(intent)
}
override fun openNote(reminder: NoteReminder) {
startActivity(requireContext().getOpenNoteIntent(reminder.id, reminder.type))
}
}

View file

@ -3,38 +3,59 @@ package com.philkes.notallyx.presentation.activity.main.fragment
import android.os.Build 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.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 =
arguments?.let {
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 (model.folder) { 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
setOnCheckedChangeListener { _, checkedId ->
when (checkedId) {
R.id.Notes -> model.folder = Folder.NOTES
R.id.Deleted -> model.folder = Folder.DELETED
R.id.Archived -> model.folder = Folder.ARCHIVED
} }
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 })
} }
} }
override fun getBackground() = R.drawable.search override fun getBackground() = R.drawable.search
override fun getObservable() = model.searchResults!! override fun getObservable() = model.searchResults!!
companion object {
const val EXTRA_INITIAL_FOLDER = "notallyx.intent.extra.INITIAL_FOLDER"
const val EXTRA_INITIAL_LABEL = "notallyx.intent.extra.INITIAL_LABEL"
}
} }

View file

@ -1,654 +0,0 @@
package com.philkes.notallyx.presentation.activity.main.fragment
import android.app.Activity
import android.app.Application
import android.content.ActivityNotFoundException
import android.content.Intent
import android.hardware.biometrics.BiometricManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.text.method.PasswordTransformationMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.documentfile.provider.DocumentFile
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputLayout.END_ICON_PASSWORD_TOGGLE
import com.philkes.notallyx.NotallyXApplication
import com.philkes.notallyx.R
import com.philkes.notallyx.data.imports.ImportSource
import com.philkes.notallyx.databinding.ChoiceItemBinding
import com.philkes.notallyx.databinding.FragmentSettingsBinding
import com.philkes.notallyx.databinding.NotesSortDialogBinding
import com.philkes.notallyx.databinding.PreferenceBinding
import com.philkes.notallyx.databinding.PreferenceSeekbarBinding
import com.philkes.notallyx.databinding.TextInputDialogBinding
import com.philkes.notallyx.presentation.canAuthenticateWithBiometrics
import com.philkes.notallyx.presentation.checkedTag
import com.philkes.notallyx.presentation.setupImportProgressDialog
import com.philkes.notallyx.presentation.setupProgressDialog
import com.philkes.notallyx.presentation.view.misc.AutoBackup
import com.philkes.notallyx.presentation.view.misc.AutoBackupMax
import com.philkes.notallyx.presentation.view.misc.AutoBackupPeriodDays
import com.philkes.notallyx.presentation.view.misc.BackupPassword
import com.philkes.notallyx.presentation.view.misc.BackupPassword.emptyPassword
import com.philkes.notallyx.presentation.view.misc.BiometricLock
import com.philkes.notallyx.presentation.view.misc.BiometricLock.disabled
import com.philkes.notallyx.presentation.view.misc.BiometricLock.enabled
import com.philkes.notallyx.presentation.view.misc.DateFormat
import com.philkes.notallyx.presentation.view.misc.ListInfo
import com.philkes.notallyx.presentation.view.misc.MaxItems
import com.philkes.notallyx.presentation.view.misc.MaxLines
import com.philkes.notallyx.presentation.view.misc.MaxTitle
import com.philkes.notallyx.presentation.view.misc.MenuDialog
import com.philkes.notallyx.presentation.view.misc.NotesSorting
import com.philkes.notallyx.presentation.view.misc.SeekbarInfo
import com.philkes.notallyx.presentation.view.misc.SortDirection
import com.philkes.notallyx.presentation.view.misc.TextSize
import com.philkes.notallyx.presentation.view.misc.TextWithIconAdapter
import com.philkes.notallyx.presentation.view.misc.Theme
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
import com.philkes.notallyx.utils.Operations
import com.philkes.notallyx.utils.backup.Export.scheduleAutoBackup
import com.philkes.notallyx.utils.security.decryptDatabase
import com.philkes.notallyx.utils.security.encryptDatabase
import com.philkes.notallyx.utils.security.showBiometricOrPinPrompt
class SettingsFragment : Fragment() {
private val model: BaseNoteModel by activityViewModels()
private lateinit var selectedImportSource: ImportSource
private fun setupBinding(binding: FragmentSettingsBinding) {
model.preferences.apply {
view.observe(viewLifecycleOwner) { value ->
binding.View.setup(com.philkes.notallyx.presentation.view.misc.View, value)
}
theme.observe(viewLifecycleOwner) { value -> binding.Theme.setup(Theme, value) }
dateFormat.observe(viewLifecycleOwner) { value ->
binding.DateFormat.setup(DateFormat, value)
}
textSize.observe(viewLifecycleOwner) { value ->
binding.TextSize.setup(TextSize, value)
}
notesSorting.observe(viewLifecycleOwner) { (sortBy, sortDirection) ->
binding.NotesSortOrder.setup(NotesSorting, sortBy, sortDirection)
}
// TODO: Hide for now until checked auto-sort is working reliably
// listItemSorting.observe(viewLifecycleOwner) { value ->
// binding.CheckedListItemSorting.setup(ListItemSorting, value)
// }
binding.MaxItems.setup(MaxItems, maxItems)
binding.MaxLines.setup(MaxLines, maxLines)
binding.MaxTitle.setup(MaxTitle, maxTitle)
binding.AutoBackupMax.setup(AutoBackupMax, autoBackupMax)
autoBackupPath.observe(viewLifecycleOwner) { value ->
binding.AutoBackup.setup(AutoBackup, value)
}
autoBackupPeriodDays.observe(viewLifecycleOwner) { value ->
binding.AutoBackupPeriodDays.setup(AutoBackupPeriodDays, value)
scheduleAutoBackup(value.toLong(), requireContext())
}
backupPassword.observe(viewLifecycleOwner) { value ->
binding.BackupPassword.setup(BackupPassword, value)
}
biometricLock.observe(viewLifecycleOwner) { value ->
binding.BiometricLock.setup(BiometricLock, value)
}
}
binding.ImportBackup.setOnClickListener { importBackup() }
binding.ImportOther.setOnClickListener { importOther() }
binding.ExportBackup.setOnClickListener { exportBackup() }
binding.ClearData.setOnClickListener { clearData() }
model.exportProgress.setupProgressDialog(this, R.string.exporting_backup)
model.importProgress.setupImportProgressDialog(this, R.string.importing_backup)
model.deletionProgress.setupProgressDialog(this, R.string.deleting_files)
binding.GitHub.setOnClickListener { openLink("https://github.com/PhilKes/NotallyX") }
binding.Libraries.setOnClickListener { displayLibraries() }
binding.Rate.setOnClickListener {
openLink("https://play.google.com/store/apps/details?id=com.philkes.notallyx")
}
binding.SendFeedback.setOnClickListener { sendFeedback() }
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
val binding = FragmentSettingsBinding.inflate(inflater)
setupBinding(binding)
return binding.root
}
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
if (resultCode == Activity.RESULT_OK) {
intent?.data?.let { uri ->
when (requestCode) {
REQUEST_IMPORT_BACKUP -> importBackup(uri)
REQUEST_EXPORT_BACKUP -> model.exportBackup(uri)
REQUEST_CHOOSE_FOLDER -> model.setAutoBackupPath(uri)
REQUEST_IMPORT_OTHER -> model.importFromOtherApp(uri, selectedImportSource)
}
return
}
}
when (requestCode) {
REQUEST_SETUP_LOCK -> showEnableBiometricLock()
REQUEST_DISABLE_LOCK -> showDisableBiometricLock()
}
}
private fun importBackup(uri: Uri) {
when (requireContext().contentResolver.getType(uri)) {
"text/xml" -> {
model.importXmlBackup(uri)
}
"application/zip" -> {
val layout = TextInputDialogBinding.inflate(layoutInflater, null, false)
val password = model.preferences.backupPassword.value
layout.InputText.apply {
if (password != emptyPassword) {
setText(password)
}
transformationMethod = PasswordTransformationMethod.getInstance()
}
layout.InputTextLayout.endIconMode = END_ICON_PASSWORD_TOGGLE
layout.Message.apply {
setText(R.string.import_backup_password_hint)
visibility = View.VISIBLE
}
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.backup_password)
.setView(layout.root)
.setPositiveButton(R.string.import_backup) { dialog, _ ->
dialog.cancel()
val usedPassword = layout.InputText.text.toString()
model.importZipBackup(uri, usedPassword)
}
.setNegativeButton(R.string.cancel, null)
.show()
}
}
}
private fun exportBackup() {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
type = "application/zip"
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_TITLE, "NotallyX Backup")
}
startActivityForResult(intent, REQUEST_EXPORT_BACKUP)
}
private fun importBackup() {
val intent =
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
type = "*/*"
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("application/zip", "text/xml"))
addCategory(Intent.CATEGORY_OPENABLE)
}
startActivityForResult(intent, REQUEST_IMPORT_BACKUP)
}
private fun clearData() {
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.clear_data_message)
.setPositiveButton(R.string.delete_all) { _, _ -> model.deleteAllBaseNotes() }
.setNegativeButton(R.string.cancel) { _, _ -> }
.show()
}
private fun importOther() {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.choose_other_app)
.setAdapter(
TextWithIconAdapter(
requireContext(),
ImportSource.entries.toMutableList(),
{ item -> getString(item.displayNameResId) },
ImportSource::iconResId,
)
) { _, which ->
selectedImportSource = ImportSource.entries[which]
MaterialAlertDialogBuilder(requireContext())
.setMessage(selectedImportSource.helpTextResId)
.setPositiveButton(R.string.import_action) { dialog, _ ->
dialog.cancel()
val intent =
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
type = "ap/*"
putExtra(
Intent.EXTRA_MIME_TYPES,
arrayOf(selectedImportSource.mimeType),
)
addCategory(Intent.CATEGORY_OPENABLE)
}
startActivityForResult(intent, REQUEST_IMPORT_OTHER)
}
.setNegativeButton(R.string.help) { _, _ ->
val intent =
Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(selectedImportSource.documentationUrl)
}
startActivity(intent)
}
.setNeutralButton(R.string.cancel) { dialog, _ -> dialog.cancel() }
.show()
}
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun sendFeedback() {
MaterialAlertDialogBuilder(requireContext())
val options =
arrayOf(getString(R.string.report_bug), getString(R.string.make_feature_request))
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.send_feedback)
.setItems(options) { _, which ->
val intent =
when (which) {
0 -> {
val app = requireContext().applicationContext as Application
val logs = Operations.getLastExceptionLog(app)
Intent(
Intent.ACTION_VIEW,
Uri.parse(
"https://github.com/PhilKes/NotallyX/issues/new?labels=bug&projects=&template=bug_report.yml&logs=$logs"
.take(2000)
),
)
}
else ->
Intent(
Intent.ACTION_VIEW,
Uri.parse(
"https://github.com/PhilKes/NotallyX/issues/new?labels=enhancement&template=feature_request.md"
),
)
}
try {
startActivity(intent)
} catch (exception: ActivityNotFoundException) {
Toast.makeText(requireContext(), R.string.install_a_browser, Toast.LENGTH_LONG)
.show()
}
}
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun displayLibraries() {
val libraries =
arrayOf(
"Glide",
"Pretty Time",
"Swipe Layout",
"Work Manager",
"Subsampling Scale ImageView",
"Material Components for Android",
"SQLCipher",
"Zip4J",
)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.libraries)
.setItems(libraries) { _, which ->
when (which) {
0 -> openLink("https://github.com/bumptech/glide")
1 -> openLink("https://github.com/ocpsoft/prettytime")
2 -> openLink("https://github.com/zerobranch/SwipeLayout")
3 -> openLink("https://developer.android.com/jetpack/androidx/releases/work")
4 -> openLink("https://github.com/davemorrissey/subsampling-scale-image-view")
5 ->
openLink(
"https://github.com/material-components/material-components-android"
)
6 -> openLink("https://github.com/sqlcipher/sqlcipher")
7 -> openLink("https://github.com/srikanth-lingala/zip4j")
}
}
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun displayChooseFolderDialog() {
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.notes_will_be)
.setPositiveButton(R.string.choose_folder) { _, _ ->
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
startActivityForResult(intent, REQUEST_CHOOSE_FOLDER)
}
.show()
}
private fun PreferenceBinding.setup(info: ListInfo, value: String) {
Title.setText(info.title)
val entries = info.getEntries(requireContext())
val entryValues = info.getEntryValues()
val checked = entryValues.indexOf(value)
val displayValue = entries[checked]
Value.text = displayValue
root.setOnClickListener {
MaterialAlertDialogBuilder(requireContext())
.setTitle(info.title)
.setSingleChoiceItems(entries, checked) { dialog, which ->
dialog.cancel()
val newValue = entryValues[which]
model.savePreference(info, newValue)
}
.setNegativeButton(R.string.cancel, null)
.show()
}
}
private fun PreferenceBinding.setup(
info: NotesSorting,
sortBy: String,
sortDirection: SortDirection,
) {
Title.setText(info.title)
val entries = info.getEntries(requireContext())
val entryValues = info.getEntryValues()
val checked = entryValues.indexOf(sortBy)
val displayValue = entries[checked]
Value.text = "$displayValue (${requireContext().getString(sortDirection.textResId)})"
root.setOnClickListener {
val layout = NotesSortDialogBinding.inflate(layoutInflater, null, false)
entries.zip(entryValues).forEachIndexed { idx, (choiceText, sortByValue) ->
ChoiceItemBinding.inflate(layoutInflater).root.apply {
id = idx
text = choiceText
tag = sortByValue
layout.NotesSortByRadioGroup.addView(this)
setCompoundDrawablesRelativeWithIntrinsicBounds(
NotesSorting.getSortIconResId(sortByValue),
0,
0,
0,
)
if (sortByValue == sortBy) {
layout.NotesSortByRadioGroup.check(this.id)
}
}
}
SortDirection.entries.forEachIndexed { idx, sortDir ->
ChoiceItemBinding.inflate(layoutInflater).root.apply {
id = idx
text = requireContext().getString(sortDir.textResId)
tag = sortDir
setCompoundDrawablesRelativeWithIntrinsicBounds(sortDir.iconResId, 0, 0, 0)
layout.NotesSortDirectionRadioGroup.addView(this)
if (sortDir == sortDirection) {
layout.NotesSortDirectionRadioGroup.check(this.id)
}
}
}
MaterialAlertDialogBuilder(requireContext())
.setTitle(info.title)
.setView(layout.root)
.setPositiveButton(R.string.save) { dialog, _ ->
dialog.cancel()
val newSortBy = layout.NotesSortByRadioGroup.checkedTag() as String
val newSortDirection =
layout.NotesSortDirectionRadioGroup.checkedTag() as SortDirection
model.preferences.savePreference(info, newSortBy, newSortDirection)
}
.setNegativeButton(R.string.cancel, null)
.show()
}
}
private fun PreferenceBinding.setup(info: BackupPassword, password: String) {
Title.setText(info.title)
Value.transformationMethod =
if (password != emptyPassword) PasswordTransformationMethod.getInstance() else null
Value.text = if (password != emptyPassword) password else getText(R.string.tap_to_set_up)
root.setOnClickListener {
val layout = TextInputDialogBinding.inflate(layoutInflater, null, false)
layout.InputText.apply {
if (password != emptyPassword) {
setText(password)
}
transformationMethod = PasswordTransformationMethod.getInstance()
}
layout.InputTextLayout.endIconMode = END_ICON_PASSWORD_TOGGLE
layout.Message.apply {
setText(R.string.backup_password_hint)
visibility = View.VISIBLE
}
MaterialAlertDialogBuilder(requireContext())
.setTitle(info.title)
.setView(layout.root)
.setPositiveButton(R.string.save) { dialog, _ ->
dialog.cancel()
val updatedPassword = layout.InputText.text.toString()
model.preferences.savePreference(info, updatedPassword)
}
.setNegativeButton(R.string.cancel, null)
.setNeutralButton(R.string.clear) { dialog, _ ->
dialog.cancel()
model.preferences.savePreference(info, emptyPassword)
}
.show()
}
}
private fun PreferenceBinding.setup(info: BiometricLock, value: String) {
Title.setText(info.title)
val entries = info.getEntries(requireContext())
val entryValues = info.getEntryValues()
val checked = entryValues.indexOf(value)
val displayValue = entries[checked]
Value.text = displayValue
root.setOnClickListener {
MaterialAlertDialogBuilder(requireContext())
.setTitle(info.title)
.setSingleChoiceItems(entries, checked) { dialog, which ->
dialog.cancel()
val newValue = entryValues[which]
if (newValue == enabled) {
when (requireContext().canAuthenticateWithBiometrics()) {
BiometricManager.BIOMETRIC_SUCCESS -> showEnableBiometricLock()
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE ->
showNoBiometricsSupportToast()
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED ->
showBiometricsNotSetupDialog()
}
} else {
when (requireContext().canAuthenticateWithBiometrics()) {
BiometricManager.BIOMETRIC_SUCCESS -> showDisableBiometricLock()
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
showNoBiometricsSupportToast()
model.preferences.biometricLock.value = disabled
}
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
showBiometricsNotSetupDialog()
model.preferences.biometricLock.value = disabled
}
}
}
}
.setNegativeButton(R.string.cancel, null)
.show()
}
}
private fun showEnableBiometricLock() {
showBiometricOrPinPrompt(
false,
REQUEST_SETUP_LOCK,
R.string.enable_lock_title,
R.string.enable_lock_description,
onSuccess = { cipher ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
model.preferences.iv = cipher.iv
val passphrase = model.preferences.generatePassphrase(cipher)
encryptDatabase(requireContext(), passphrase)
model.savePreference(BiometricLock, enabled)
}
(activity?.application as NotallyXApplication).isLocked = false
showBiometricsEnabledToast()
},
) {
showBiometricsNotSetupDialog()
}
}
private fun showDisableBiometricLock() {
showBiometricOrPinPrompt(
true,
REQUEST_DISABLE_LOCK,
R.string.disable_lock_title,
R.string.disable_lock_description,
model.preferences.iv!!,
onSuccess = { cipher ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val encryptedPassphrase = model.preferences.getDatabasePassphrase()
val passphrase = cipher.doFinal(encryptedPassphrase)
model.closeDatabase()
decryptDatabase(requireContext(), passphrase)
model.savePreference(BiometricLock, disabled)
}
showBiometricsDisabledToast()
},
) {}
}
private fun showNoBiometricsSupportToast() {
ContextCompat.getMainExecutor(requireContext()).execute {
Toast.makeText(requireContext(), R.string.biometrics_setup_success, Toast.LENGTH_LONG)
.show()
}
}
private fun showBiometricsEnabledToast() {
ContextCompat.getMainExecutor(requireContext()).execute {
Toast.makeText(requireContext(), R.string.biometrics_setup_success, Toast.LENGTH_LONG)
.show()
}
}
private fun showBiometricsDisabledToast() {
ContextCompat.getMainExecutor(requireContext()).execute {
Toast.makeText(requireContext(), R.string.biometrics_disable_success, Toast.LENGTH_LONG)
.show()
}
}
private fun showBiometricsNotSetupDialog() {
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.biometrics_not_setup)
.setNegativeButton(R.string.cancel) { _, _ -> }
.setPositiveButton(R.string.tap_to_set_up) { _, _ ->
val intent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Intent(Settings.ACTION_BIOMETRIC_ENROLL)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
Intent(Settings.ACTION_FINGERPRINT_ENROLL)
} else {
Intent(Settings.ACTION_SECURITY_SETTINGS)
}
startActivityForResult(intent, REQUEST_SETUP_LOCK)
}
.show()
}
private fun PreferenceBinding.setup(info: AutoBackup, value: String) {
Title.setText(info.title)
if (value == info.emptyPath) {
Value.setText(R.string.tap_to_set_up)
root.setOnClickListener { displayChooseFolderDialog() }
} else {
val uri = Uri.parse(value)
val folder = requireNotNull(DocumentFile.fromTreeUri(requireContext(), uri))
if (folder.exists()) {
Value.text = folder.name
} else Value.setText(R.string.cant_find_folder)
root.setOnClickListener {
MenuDialog(requireContext())
.add(R.string.disable_auto_backup) { model.disableAutoBackup() }
.add(R.string.choose_another_folder) { displayChooseFolderDialog() }
.show()
}
}
}
private fun PreferenceSeekbarBinding.setup(info: SeekbarInfo, initialValue: Int) {
Title.setText(info.title)
Slider.apply {
valueTo = info.max.toFloat()
valueFrom = info.min.toFloat()
value = initialValue.toFloat()
addOnChangeListener { _, value, _ -> model.savePreference(info, value.toInt()) }
}
}
private fun openLink(link: String) {
val uri = Uri.parse(link)
val intent = Intent(Intent.ACTION_VIEW, uri)
try {
startActivity(intent)
} catch (exception: ActivityNotFoundException) {
Toast.makeText(requireContext(), R.string.install_a_browser, Toast.LENGTH_LONG).show()
}
}
companion object {
private const val REQUEST_IMPORT_BACKUP = 20
private const val REQUEST_EXPORT_BACKUP = 21
private const val REQUEST_CHOOSE_FOLDER = 22
private const val REQUEST_SETUP_LOCK = 23
private const val REQUEST_DISABLE_LOCK = 24
private const val REQUEST_IMPORT_OTHER = 25
}
}

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

@ -0,0 +1,559 @@
package com.philkes.notallyx.presentation.activity.main.fragment.settings
import android.content.Context
import android.hardware.biometrics.BiometricManager
import android.net.Uri
import android.text.method.PasswordTransformationMethod
import android.view.LayoutInflater
import android.view.View
import androidx.core.view.isVisible
import androidx.documentfile.provider.DocumentFile
import com.google.android.material.color.DynamicColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.slider.Slider
import com.google.android.material.textfield.TextInputLayout.END_ICON_PASSWORD_TOGGLE
import com.philkes.notallyx.R
import com.philkes.notallyx.databinding.ChoiceItemBinding
import com.philkes.notallyx.databinding.DialogNotesSortBinding
import com.philkes.notallyx.databinding.DialogPreferenceBooleanBinding
import com.philkes.notallyx.databinding.DialogPreferenceEnumWithToggleBinding
import com.philkes.notallyx.databinding.DialogSelectionBoxBinding
import com.philkes.notallyx.databinding.DialogTextInputBinding
import com.philkes.notallyx.databinding.PreferenceBinding
import com.philkes.notallyx.databinding.PreferenceSeekbarBinding
import com.philkes.notallyx.presentation.checkedTag
import com.philkes.notallyx.presentation.select
import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.showAndFocus
import com.philkes.notallyx.presentation.showToast
import com.philkes.notallyx.presentation.view.misc.MenuDialog
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock
import com.philkes.notallyx.presentation.viewmodel.preference.BooleanPreference
import com.philkes.notallyx.presentation.viewmodel.preference.Constants.PASSWORD_EMPTY
import com.philkes.notallyx.presentation.viewmodel.preference.DateFormat
import com.philkes.notallyx.presentation.viewmodel.preference.EnumPreference
import com.philkes.notallyx.presentation.viewmodel.preference.IntPreference
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.EMPTY_PATH
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.START_VIEW_DEFAULT
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.START_VIEW_UNLABELED
import com.philkes.notallyx.presentation.viewmodel.preference.NotesSort
import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortBy
import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortPreference
import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection
import com.philkes.notallyx.presentation.viewmodel.preference.StringPreference
import com.philkes.notallyx.presentation.viewmodel.preference.TextProvider
import com.philkes.notallyx.presentation.viewmodel.preference.Theme
import com.philkes.notallyx.utils.canAuthenticateWithBiometrics
import com.philkes.notallyx.utils.toReadablePath
inline fun <reified T> PreferenceBinding.setup(
enumPreference: EnumPreference<T>,
value: T,
context: Context,
crossinline onSave: (newValue: T) -> Unit,
) where T : Enum<T>, T : TextProvider {
Title.setText(enumPreference.titleResId!!)
Value.text = value.getText(context)
val enumEntries = T::class.java.enumConstants!!.toList()
val entries = enumEntries.map { it.getText(context) }.toTypedArray()
val checked = enumEntries.indexOfFirst { it == value }
root.setOnClickListener {
MaterialAlertDialogBuilder(context)
.setTitle(enumPreference.titleResId)
.setSingleChoiceItems(entries, checked) { dialog, which ->
dialog.cancel()
val newValue = enumEntries[which]
onSave(newValue)
}
.setCancelButton()
.show()
}
}
fun PreferenceBinding.setup(
preference: EnumPreference<BiometricLock>,
value: BiometricLock,
context: Context,
model: BaseNoteModel,
onEnableSuccess: () -> Unit,
onDisableSuccess: () -> Unit,
onNotSetup: () -> Unit,
) {
Title.setText(preference.titleResId!!)
Value.text = value.getText(context)
val enumEntries = BiometricLock.entries
val entries = enumEntries.map { context.getString(it.textResId) }.toTypedArray()
val checked = enumEntries.indexOfFirst { it == value }
root.setOnClickListener {
MaterialAlertDialogBuilder(context)
.setTitle(preference.titleResId)
.setSingleChoiceItems(entries, checked) { dialog, which ->
dialog.cancel()
val newValue = enumEntries[which]
if (newValue == value) {
return@setSingleChoiceItems
}
if (newValue == BiometricLock.ENABLED) {
when (context.canAuthenticateWithBiometrics()) {
BiometricManager.BIOMETRIC_SUCCESS -> onEnableSuccess()
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
context.showToast(R.string.biometrics_no_support)
}
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> onNotSetup()
}
} else {
when (context.canAuthenticateWithBiometrics()) {
BiometricManager.BIOMETRIC_SUCCESS -> onDisableSuccess()
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
context.showToast(R.string.biometrics_no_support)
model.savePreference(
model.preferences.biometricLock,
BiometricLock.DISABLED,
)
}
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
onNotSetup()
model.savePreference(
model.preferences.biometricLock,
BiometricLock.DISABLED,
)
}
}
}
}
.setCancelButton()
.show()
}
}
fun PreferenceBinding.setup(
preference: NotesSortPreference,
value: NotesSort,
context: Context,
layoutInflater: LayoutInflater,
model: BaseNoteModel,
) {
Title.setText(preference.titleResId!!)
Value.text = value.getText(context)
root.setOnClickListener {
val layout = DialogNotesSortBinding.inflate(layoutInflater, null, false)
NotesSortBy.entries.forEachIndexed { idx, notesSortBy ->
ChoiceItemBinding.inflate(layoutInflater).root.apply {
id = idx
text = context.getString(notesSortBy.textResId)
tag = notesSortBy
layout.NotesSortByRadioGroup.addView(this)
setCompoundDrawablesRelativeWithIntrinsicBounds(notesSortBy.iconResId, 0, 0, 0)
if (notesSortBy == value.sortedBy) {
layout.NotesSortByRadioGroup.check(this.id)
}
}
}
SortDirection.entries.forEachIndexed { idx, sortDir ->
ChoiceItemBinding.inflate(layoutInflater).root.apply {
id = idx
text = context.getString(sortDir.textResId)
tag = sortDir
setCompoundDrawablesRelativeWithIntrinsicBounds(sortDir.iconResId, 0, 0, 0)
layout.NotesSortDirectionRadioGroup.addView(this)
if (sortDir == value.sortDirection) {
layout.NotesSortDirectionRadioGroup.check(this.id)
}
}
}
MaterialAlertDialogBuilder(context)
.setTitle(preference.titleResId)
.setView(layout.root)
.setPositiveButton(R.string.save) { dialog, _ ->
dialog.cancel()
val newSortBy = layout.NotesSortByRadioGroup.checkedTag() as NotesSortBy
val newSortDirection =
layout.NotesSortDirectionRadioGroup.checkedTag() as SortDirection
model.savePreference(
model.preferences.notesSorting,
NotesSort(newSortBy, newSortDirection),
)
}
.setCancelButton()
.show()
}
}
fun PreferenceBinding.setup(
dateFormatPreference: EnumPreference<DateFormat>,
dateFormatValue: DateFormat,
applyToNoteViewValue: Boolean,
context: Context,
layoutInflater: LayoutInflater,
onSave: (dateFormat: DateFormat, applyToEditMode: Boolean) -> Unit,
) {
Title.setText(dateFormatPreference.titleResId!!)
Value.text = dateFormatValue.getText(context)
root.setOnClickListener {
val layout = DialogPreferenceEnumWithToggleBinding.inflate(layoutInflater, null, false)
layout.EnumHint.apply {
setText(R.string.date_format_hint)
isVisible = true
}
DateFormat.entries.forEachIndexed { idx, dateFormat ->
ChoiceItemBinding.inflate(layoutInflater).root.apply {
id = idx
text = dateFormat.getText(context)
tag = dateFormat
layout.EnumRadioGroup.addView(this)
if (dateFormat == dateFormatValue) {
layout.EnumRadioGroup.check(this.id)
}
}
}
layout.Toggle.apply {
setText(R.string.date_format_apply_in_note_view)
isChecked = applyToNoteViewValue
}
MaterialAlertDialogBuilder(context)
.setTitle(dateFormatPreference.titleResId)
.setView(layout.root)
.setPositiveButton(R.string.save) { dialog, _ ->
dialog.cancel()
val dateFormat = layout.EnumRadioGroup.checkedTag() as DateFormat
val applyToNoteView = layout.Toggle.isChecked
onSave(dateFormat, applyToNoteView)
}
.setCancelButton()
.show()
}
}
fun PreferenceBinding.setup(
themePreference: EnumPreference<Theme>,
themeValue: Theme,
useDynamicColorsValue: Boolean,
context: Context,
layoutInflater: LayoutInflater,
onSave: (theme: Theme, useDynamicColors: Boolean) -> Unit,
) {
Title.setText(themePreference.titleResId!!)
Value.text = themeValue.getText(context)
root.setOnClickListener {
val layout = DialogPreferenceEnumWithToggleBinding.inflate(layoutInflater, null, false)
Theme.entries.forEachIndexed { idx, theme ->
ChoiceItemBinding.inflate(layoutInflater).root.apply {
id = idx
text = theme.getText(context)
tag = theme
layout.EnumRadioGroup.addView(this)
if (theme == themeValue) {
layout.EnumRadioGroup.check(this.id)
}
}
}
layout.Toggle.apply {
isVisible = DynamicColors.isDynamicColorAvailable()
setText(R.string.theme_use_dynamic_colors)
isChecked = useDynamicColorsValue
}
MaterialAlertDialogBuilder(context)
.setTitle(themePreference.titleResId)
.setView(layout.root)
.setPositiveButton(R.string.save) { dialog, _ ->
dialog.cancel()
val theme = layout.EnumRadioGroup.checkedTag() as Theme
val useDynamicColors = layout.Toggle.isChecked
onSave(theme, useDynamicColors)
}
.setCancelButton()
.show()
}
}
fun PreferenceBinding.setup(
preference: BooleanPreference,
value: Boolean,
context: Context,
layoutInflater: LayoutInflater,
messageResId: Int? = null,
enabled: Boolean = true,
disabledTextResId: Int? = null,
onSave: (newValue: Boolean) -> Unit,
) {
Title.setText(preference.titleResId!!)
if (enabled) {
Value.setText(if (value) R.string.enabled else R.string.disabled)
} else {
disabledTextResId?.let { Value.setText(it) }
}
root.isEnabled = enabled
root.setOnClickListener {
val layout =
DialogPreferenceBooleanBinding.inflate(layoutInflater, null, false).apply {
Title.setText(preference.titleResId)
messageResId?.let { Message.setText(it) }
if (value) {
EnabledButton.isChecked = true
} else {
DisabledButton.isChecked = true
}
}
val dialog =
MaterialAlertDialogBuilder(context).setView(layout.root).setCancelButton().show()
layout.apply {
EnabledButton.setOnClickListener {
dialog.cancel()
if (!value) {
onSave.invoke(true)
}
}
DisabledButton.setOnClickListener {
dialog.cancel()
if (value) {
onSave.invoke(false)
}
}
}
}
}
fun PreferenceBinding.setupPeriodicBackup(
value: Boolean,
context: Context,
layoutInflater: LayoutInflater,
enabled: Boolean,
onSave: (newValue: Boolean) -> Unit,
) {
Title.setText(R.string.backup_periodic)
val enabledText = context.getString(R.string.enabled)
val disabledText = context.getString(R.string.disabled)
val text =
if (enabled) {
if (value) enabledText else disabledText
} else context.getString(R.string.auto_backups_folder_set)
Value.text = text
root.isEnabled = enabled
root.setOnClickListener {
val layout =
DialogPreferenceBooleanBinding.inflate(layoutInflater, null, false).apply {
Title.setText(R.string.backup_periodic)
Message.setText(R.string.backup_periodic_hint)
if (value) {
EnabledButton.isChecked = true
} else {
DisabledButton.isChecked = true
}
}
val dialog =
MaterialAlertDialogBuilder(context).setView(layout.root).setCancelButton().show()
layout.apply {
EnabledButton.setOnClickListener {
dialog.cancel()
if (!value) {
onSave.invoke(true)
}
}
DisabledButton.setOnClickListener {
dialog.cancel()
if (value) {
onSave.invoke(false)
}
}
}
}
}
fun PreferenceBinding.setupBackupPassword(
preference: StringPreference,
password: String,
context: Context,
layoutInflater: LayoutInflater,
onSave: (newValue: String) -> Unit,
) {
Title.setText(preference.titleResId!!)
Value.transformationMethod =
if (password != PASSWORD_EMPTY) PasswordTransformationMethod.getInstance() else null
Value.text =
if (password != PASSWORD_EMPTY) password else context.getText(R.string.tap_to_set_up)
root.setOnClickListener {
val layout = DialogTextInputBinding.inflate(layoutInflater, null, false)
layout.InputText.apply {
if (password != PASSWORD_EMPTY) {
setText(password)
}
transformationMethod = PasswordTransformationMethod.getInstance()
}
layout.InputTextLayout.endIconMode = END_ICON_PASSWORD_TOGGLE
layout.Message.apply {
setText(R.string.backup_password_hint)
visibility = View.VISIBLE
}
MaterialAlertDialogBuilder(context)
.setTitle(preference.titleResId)
.setView(layout.root)
.setPositiveButton(R.string.save) { dialog, _ ->
dialog.cancel()
val updatedPassword = layout.InputText.text.toString()
onSave(updatedPassword)
}
.setCancelButton()
.setNeutralButton(R.string.clear) { dialog, _ ->
dialog.cancel()
onSave(PASSWORD_EMPTY)
}
.showAndFocus(allowFullSize = true)
}
}
fun PreferenceBinding.setupBackupsFolder(
value: String,
context: Context,
chooseBackupFolder: () -> Unit,
onDisable: () -> Unit,
) {
Title.setText(R.string.auto_backups_folder)
if (value == EMPTY_PATH) {
Value.setText(R.string.tap_to_set_up)
root.setOnClickListener { chooseBackupFolder() }
} else {
val uri = Uri.parse(value)
val folder = requireNotNull(DocumentFile.fromTreeUri(context, uri))
if (folder.exists()) {
val path = uri.toReadablePath()
Value.text = path
} else Value.setText(R.string.cant_find_folder)
root.setOnClickListener {
MenuDialog(context)
.add(R.string.clear) { onDisable() }
.add(R.string.choose_another_folder) { chooseBackupFolder() }
.show()
}
}
}
fun PreferenceSeekbarBinding.setup(
value: Int,
titleResId: Int,
min: Int,
max: Int,
context: Context,
enabled: Boolean = true,
onChange: (newValue: Int) -> Unit,
) {
Title.setText(titleResId)
val valueInBoundaries = (if (value < min) min else if (value > max) max else value).toFloat()
Slider.apply {
isEnabled = enabled
valueTo = max.toFloat()
valueFrom = min.toFloat()
this@apply.value = valueInBoundaries
clearOnSliderTouchListeners()
addOnSliderTouchListener(
object : Slider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: Slider) {}
override fun onStopTrackingTouch(slider: Slider) {
onChange(slider.value.toInt())
}
}
)
contentDescription = context.getString(titleResId)
}
}
fun PreferenceSeekbarBinding.setup(
preference: IntPreference,
context: Context,
value: Int = preference.value,
onChange: (newValue: Int) -> Unit,
) {
setup(value, preference.titleResId!!, preference.min, preference.max, context) { newValue ->
onChange(newValue)
}
}
fun PreferenceSeekbarBinding.setupAutoSaveIdleTime(
preference: IntPreference,
context: Context,
value: Int = preference.value,
onChange: (newValue: Int) -> Unit,
) {
Slider.apply {
setLabelFormatter { sliderValue ->
if (sliderValue == -1f) {
context.getString(R.string.disabled)
} else "${sliderValue.toInt()}s"
}
addOnChangeListener { _, value, _ ->
if (value == -1f) {
setAlpha(0.6f) // Reduce opacity to make it look disabled
} else {
setAlpha(1f) // Restore normal appearance
}
}
}
setup(preference, context, value, onChange)
}
fun PreferenceBinding.setupStartView(
preference: StringPreference,
value: String,
labels: List<String>?,
context: Context,
layoutInflater: LayoutInflater,
onSave: (value: String) -> Unit,
) {
Title.setText(preference.titleResId!!)
val notesText = "${context.getText(R.string.notes)} (${context.getText(R.string.text_default)})"
val unlabeledText = context.getText(R.string.unlabeled).toString()
val textValue =
when (value) {
START_VIEW_DEFAULT -> notesText
START_VIEW_UNLABELED -> unlabeledText
else -> value
}
Value.text = textValue
root.setOnClickListener {
val layout = DialogSelectionBoxBinding.inflate(layoutInflater, null, false)
layout.Message.setText(R.string.start_view_hint)
val values =
mutableListOf(notesText to START_VIEW_DEFAULT, unlabeledText to START_VIEW_UNLABELED)
.apply { labels?.forEach { add(it to it) } }
var selected = -1
layout.SelectionBox.apply {
setSimpleItems(values.map { it.first }.toTypedArray())
select(textValue)
setOnItemClickListener { _, _, position, _ -> selected = position }
}
MaterialAlertDialogBuilder(context)
.setTitle(preference.titleResId)
.setView(layout.root)
.setPositiveButton(R.string.save) { dialog, _ ->
dialog.cancel()
val newValue = values[selected].second
onSave(newValue)
}
.setCancelButton()
.showAndFocus(allowFullSize = true)
}
}

View file

@ -0,0 +1,876 @@
package com.philkes.notallyx.presentation.activity.main.fragment.settings
import android.Manifest
import android.app.Application
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.Intent.ACTION_OPEN_DOCUMENT_TREE
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.DocumentsContract
import android.provider.Settings
import android.text.method.PasswordTransformationMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputLayout.END_ICON_PASSWORD_TOGGLE
import com.philkes.notallyx.NotallyXApplication
import com.philkes.notallyx.R
import com.philkes.notallyx.data.imports.FOLDER_OR_FILE_MIMETYPE
import com.philkes.notallyx.data.imports.ImportSource
import com.philkes.notallyx.data.imports.txt.APPLICATION_TEXT_MIME_TYPES
import com.philkes.notallyx.data.model.toText
import com.philkes.notallyx.databinding.DialogTextInputBinding
import com.philkes.notallyx.databinding.FragmentSettingsBinding
import com.philkes.notallyx.presentation.activity.main.MainActivity
import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.setEnabledSecureFlag
import com.philkes.notallyx.presentation.setupImportProgressDialog
import com.philkes.notallyx.presentation.setupProgressDialog
import com.philkes.notallyx.presentation.showAndFocus
import com.philkes.notallyx.presentation.showDialog
import com.philkes.notallyx.presentation.showToast
import com.philkes.notallyx.presentation.view.misc.TextWithIconAdapter
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
import com.philkes.notallyx.presentation.viewmodel.preference.Constants.PASSWORD_EMPTY
import com.philkes.notallyx.presentation.viewmodel.preference.LongPreference
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.EMPTY_PATH
import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackup
import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackup.Companion.BACKUP_MAX_MIN
import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackup.Companion.BACKUP_PERIOD_DAYS_MIN
import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackupsPreference
import com.philkes.notallyx.utils.MIME_TYPE_JSON
import com.philkes.notallyx.utils.MIME_TYPE_ZIP
import com.philkes.notallyx.utils.backup.exportPreferences
import com.philkes.notallyx.utils.catchNoBrowserInstalled
import com.philkes.notallyx.utils.getExtraBooleanFromBundleOrIntent
import com.philkes.notallyx.utils.getLastExceptionLog
import com.philkes.notallyx.utils.getLogFile
import com.philkes.notallyx.utils.getUriForFile
import com.philkes.notallyx.utils.reportBug
import com.philkes.notallyx.utils.security.showBiometricOrPinPrompt
import com.philkes.notallyx.utils.wrapWithChooser
import java.util.Date
class SettingsFragment : Fragment() {
private val model: BaseNoteModel by activityViewModels()
private lateinit var importBackupActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var importOtherActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var exportBackupActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var chooseBackupFolderActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var setupLockActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var disableLockActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var exportSettingsActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var importSettingsActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var selectedImportSource: ImportSource
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
val binding = FragmentSettingsBinding.inflate(inflater)
model.preferences.apply {
setupAppearance(binding)
setupContentDensity(binding)
setupBackup(binding)
setupAutoBackups(binding)
setupSecurity(binding)
setupSettings(binding)
}
setupAbout(binding)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupActivityResultLaunchers()
val showImportBackupsFolder =
getExtraBooleanFromBundleOrIntent(
savedInstanceState,
EXTRA_SHOW_IMPORT_BACKUPS_FOLDER,
false,
)
showImportBackupsFolder.let {
if (it) {
model.refreshBackupsFolder(
requireContext(),
askForUriPermissions = ::askForUriPermissions,
)
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
if (model.showRefreshBackupsFolderAfterThemeChange) {
outState.putBoolean(EXTRA_SHOW_IMPORT_BACKUPS_FOLDER, true)
}
}
private fun setupActivityResultLaunchers() {
importBackupActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { importBackup(it) }
}
}
importOtherActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri ->
model.importFromOtherApp(uri, selectedImportSource)
}
}
}
exportBackupActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri -> model.exportBackup(uri) }
}
}
chooseBackupFolderActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri ->
model.setupBackupsFolder(uri)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activity?.let {
val permission = Manifest.permission.POST_NOTIFICATIONS
if (
it.checkSelfPermission(permission) !=
PackageManager.PERMISSION_GRANTED
) {
MaterialAlertDialogBuilder(it)
.setMessage(
R.string.please_grant_notally_notification_auto_backup
)
.setNegativeButton(R.string.skip, null)
.setPositiveButton(R.string.continue_) { _, _ ->
it.requestPermissions(arrayOf(permission), 0)
}
.show()
}
}
}
}
}
}
setupLockActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
showEnableBiometricLock()
}
disableLockActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
showDisableBiometricLock()
}
exportSettingsActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri ->
if (requireContext().exportPreferences(model.preferences, uri)) {
showToast(R.string.export_settings_success)
} else {
showToast(R.string.export_settings_failure)
}
}
}
}
importSettingsActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri ->
model.importPreferences(
requireContext(),
uri,
::askForUriPermissions,
{ showToast(R.string.import_settings_success) },
) {
showToast(R.string.import_settings_failure)
}
}
}
}
}
private fun importBackup(uri: Uri) {
when (requireContext().contentResolver.getType(uri)) {
"text/xml" -> {
model.importXmlBackup(uri)
}
MIME_TYPE_ZIP -> {
val layout = DialogTextInputBinding.inflate(layoutInflater, null, false)
val password = model.preferences.backupPassword.value
layout.InputText.apply {
if (password != PASSWORD_EMPTY) {
setText(password)
}
transformationMethod = PasswordTransformationMethod.getInstance()
}
layout.InputTextLayout.endIconMode = END_ICON_PASSWORD_TOGGLE
layout.Message.apply {
setText(R.string.import_backup_password_hint)
visibility = View.VISIBLE
}
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.backup_password)
.setView(layout.root)
.setPositiveButton(R.string.import_backup) { dialog, _ ->
dialog.cancel()
val usedPassword = layout.InputText.text.toString()
model.importZipBackup(uri, usedPassword)
}
.setCancelButton()
.show()
}
}
}
private fun NotallyXPreferences.setupAppearance(binding: FragmentSettingsBinding) {
notesView.observe(viewLifecycleOwner) { value ->
binding.View.setup(notesView, value, requireContext()) { newValue ->
model.savePreference(notesView, newValue)
}
}
theme.merge(useDynamicColors).observe(viewLifecycleOwner) {
(themeValue, useDynamicColorsValue) ->
binding.Theme.setup(
theme,
themeValue,
useDynamicColorsValue,
requireContext(),
layoutInflater,
) { newThemeValue, newUseDynamicColorsValue ->
model.savePreference(theme, newThemeValue)
model.savePreference(useDynamicColors, newUseDynamicColorsValue)
val packageManager = requireContext().packageManager
val intent = packageManager.getLaunchIntentForPackage(requireContext().packageName)
val componentName = intent!!.component
val mainIntent =
Intent.makeRestartActivityTask(componentName).apply {
putExtra(MainActivity.EXTRA_FRAGMENT_TO_OPEN, R.id.Settings)
}
mainIntent.setPackage(requireContext().packageName)
requireContext().startActivity(mainIntent)
Runtime.getRuntime().exit(0)
}
}
dateFormat.merge(applyDateFormatInNoteView).observe(viewLifecycleOwner) {
(dateFormatValue, applyDateFormatInEditNoteValue) ->
binding.DateFormat.setup(
dateFormat,
dateFormatValue,
applyDateFormatInEditNoteValue,
requireContext(),
layoutInflater,
) { newDateFormatValue, newApplyDateFormatInEditNote ->
model.savePreference(dateFormat, newDateFormatValue)
model.savePreference(applyDateFormatInNoteView, newApplyDateFormatInEditNote)
}
}
textSize.observe(viewLifecycleOwner) { value ->
binding.TextSize.setup(textSize, value, requireContext()) { newValue ->
model.savePreference(textSize, newValue)
}
}
notesSorting.observe(viewLifecycleOwner) { notesSort ->
binding.NotesSortOrder.setup(
notesSorting,
notesSort,
requireContext(),
layoutInflater,
model,
)
}
listItemSorting.observe(viewLifecycleOwner) { value ->
binding.CheckedListItemSorting.setup(listItemSorting, value, requireContext()) {
newValue ->
model.savePreference(listItemSorting, newValue)
}
}
binding.MaxLabels.setup(maxLabels, requireContext()) { newValue ->
model.savePreference(maxLabels, newValue)
}
startView.merge(model.labels).observe(viewLifecycleOwner) { (startViewValue, labelsValue) ->
binding.StartView.setupStartView(
startView,
startViewValue,
labelsValue,
requireContext(),
layoutInflater,
) { newValue ->
model.savePreference(startView, newValue)
}
}
}
private fun NotallyXPreferences.setupContentDensity(binding: FragmentSettingsBinding) {
binding.apply {
MaxTitle.setup(maxTitle, requireContext()) { newValue ->
model.savePreference(maxTitle, newValue)
}
MaxItems.setup(maxItems, requireContext()) { newValue ->
model.savePreference(maxItems, newValue)
}
MaxLines.setup(maxLines, requireContext()) { newValue ->
model.savePreference(maxLines, newValue)
}
MaxLabels.setup(maxLabels, requireContext()) { newValue ->
model.savePreference(maxLabels, newValue)
}
labelTagsHiddenInOverview.observe(viewLifecycleOwner) { value ->
binding.LabelsHiddenInOverview.setup(
labelTagsHiddenInOverview,
value,
requireContext(),
layoutInflater,
R.string.labels_hidden_in_overview,
) { enabled ->
model.savePreference(labelTagsHiddenInOverview, enabled)
}
}
imagesHiddenInOverview.observe(viewLifecycleOwner) { value ->
binding.ImagesHiddenInOverview.setup(
imagesHiddenInOverview,
value,
requireContext(),
layoutInflater,
R.string.images_hidden_in_overview,
) { enabled ->
model.savePreference(imagesHiddenInOverview, enabled)
}
}
}
}
private fun NotallyXPreferences.setupBackup(binding: FragmentSettingsBinding) {
binding.apply {
ImportBackup.setOnClickListener {
val intent =
Intent(Intent.ACTION_OPEN_DOCUMENT)
.apply {
type = "*/*"
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf(MIME_TYPE_ZIP, "text/xml"))
addCategory(Intent.CATEGORY_OPENABLE)
}
.wrapWithChooser(requireContext())
importBackupActivityResultLauncher.launch(intent)
}
ImportOther.setOnClickListener { importFromOtherApp() }
ExportBackup.setOnClickListener {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT)
.apply {
type = MIME_TYPE_ZIP
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_TITLE, "NotallyX Backup")
}
.wrapWithChooser(requireContext())
exportBackupActivityResultLauncher.launch(intent)
}
}
model.exportProgress.setupProgressDialog(this@SettingsFragment, R.string.exporting_backup)
model.importProgress.setupImportProgressDialog(
this@SettingsFragment,
R.string.importing_backup,
)
}
private fun NotallyXPreferences.setupAutoBackups(binding: FragmentSettingsBinding) {
backupsFolder.observe(viewLifecycleOwner) { value ->
binding.BackupsFolder.setupBackupsFolder(
value,
requireContext(),
::displayChooseBackupFolderDialog,
) {
model.disableBackups()
}
}
backupOnSave.merge(backupsFolder).observe(viewLifecycleOwner) { (onSave, backupFolder) ->
binding.BackupOnSave.setup(
backupOnSave,
onSave,
requireContext(),
layoutInflater,
messageResId = R.string.auto_backup_on_save,
enabled = backupFolder != EMPTY_PATH,
disabledTextResId = R.string.auto_backups_folder_set,
) { enabled ->
model.savePreference(backupOnSave, enabled)
}
}
periodicBackups.merge(backupsFolder).observe(viewLifecycleOwner) {
(periodicBackup, backupFolder) ->
setupPeriodicBackup(
binding,
periodicBackup,
backupFolder,
periodicBackups,
periodicBackupLastExecution,
)
}
}
private fun importFromOtherApp() {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.choose_other_app)
.setAdapter(
TextWithIconAdapter(
requireContext(),
ImportSource.entries.toMutableList(),
{ item -> getString(item.displayNameResId) },
ImportSource::iconResId,
)
) { _, which ->
selectedImportSource = ImportSource.entries[which]
MaterialAlertDialogBuilder(requireContext())
.setMessage(selectedImportSource.helpTextResId)
.setPositiveButton(R.string.import_action) { dialog, _ ->
dialog.cancel()
when (selectedImportSource.mimeType) {
FOLDER_OR_FILE_MIMETYPE ->
MaterialAlertDialogBuilder(requireContext())
.setTitle(selectedImportSource.displayNameResId)
.setItems(
arrayOf(
getString(R.string.folder),
getString(R.string.single_file),
)
) { _, which ->
when (which) {
0 ->
importOtherActivityResultLauncher.launch(
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
.apply {
addCategory(Intent.CATEGORY_DEFAULT)
}
.wrapWithChooser(requireContext())
)
1 ->
importOtherActivityResultLauncher.launch(
Intent(Intent.ACTION_OPEN_DOCUMENT)
.apply {
type = "text/*"
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(
Intent.EXTRA_MIME_TYPES,
arrayOf("text/*") +
APPLICATION_TEXT_MIME_TYPES,
)
}
.wrapWithChooser(requireContext())
)
}
}
.setCancelButton()
.show()
else ->
importOtherActivityResultLauncher.launch(
Intent(Intent.ACTION_OPEN_DOCUMENT)
.apply {
type = "application/*"
putExtra(
Intent.EXTRA_MIME_TYPES,
arrayOf(selectedImportSource.mimeType),
)
addCategory(Intent.CATEGORY_OPENABLE)
}
.wrapWithChooser(requireContext())
)
}
}
.also {
selectedImportSource.documentationUrl?.let<String, Unit> { docUrl ->
it.setNegativeButton(R.string.help) { _, _ ->
val intent =
Intent(Intent.ACTION_VIEW)
.apply { data = Uri.parse(docUrl) }
.wrapWithChooser(requireContext())
startActivity(intent)
}
}
}
.setNeutralButton(R.string.cancel) { dialog, _ -> dialog.cancel() }
.showAndFocus(allowFullSize = true)
}
.setCancelButton()
.show()
}
private fun setupPeriodicBackup(
binding: FragmentSettingsBinding,
value: PeriodicBackup,
backupFolder: String,
preference: PeriodicBackupsPreference,
lastExecutionPreference: LongPreference,
) {
val periodicBackupsEnabled = value.periodInDays > 0 && backupFolder != EMPTY_PATH
binding.PeriodicBackups.setupPeriodicBackup(
periodicBackupsEnabled,
requireContext(),
layoutInflater,
enabled = backupFolder != EMPTY_PATH,
) { enabled ->
if (enabled) {
val periodInDays =
preference.value.periodInDays.let {
if (it >= BACKUP_PERIOD_DAYS_MIN) it else BACKUP_PERIOD_DAYS_MIN
}
val maxBackups =
preference.value.maxBackups.let {
if (it >= BACKUP_MAX_MIN) it else BACKUP_MAX_MIN
}
model.savePreference(
preference,
preference.value.copy(periodInDays = periodInDays, maxBackups = maxBackups),
)
} else {
model.savePreference(preference, preference.value.copy(periodInDays = 0))
}
}
lastExecutionPreference.observe(viewLifecycleOwner) { time ->
binding.PeriodicBackupLastExecution.apply {
if (time != -1L) {
isVisible = true
text =
"${requireContext().getString(R.string.auto_backup_last)}: ${Date(time).toText()}"
} else isVisible = false
}
}
binding.PeriodicBackupsPeriodInDays.setup(
value.periodInDays,
R.string.backup_period_days,
PeriodicBackup.BACKUP_PERIOD_DAYS_MIN,
PeriodicBackup.BACKUP_PERIOD_DAYS_MAX,
requireContext(),
enabled = periodicBackupsEnabled,
) { newValue ->
model.savePreference(preference, preference.value.copy(periodInDays = newValue))
}
binding.PeriodicBackupsMax.setup(
value.maxBackups,
R.string.max_backups,
PeriodicBackup.BACKUP_MAX_MIN,
PeriodicBackup.BACKUP_MAX_MAX,
requireContext(),
enabled = periodicBackupsEnabled,
) { newValue: Int ->
model.savePreference(preference, preference.value.copy(maxBackups = newValue))
}
}
private fun NotallyXPreferences.setupSecurity(binding: FragmentSettingsBinding) {
biometricLock.observe(viewLifecycleOwner) { value ->
binding.BiometricLock.setup(
biometricLock,
value,
requireContext(),
model,
::showEnableBiometricLock,
::showDisableBiometricLock,
::showBiometricsNotSetupDialog,
)
}
backupPassword.observe(viewLifecycleOwner) { value ->
binding.BackupPassword.setupBackupPassword(
backupPassword,
value,
requireContext(),
layoutInflater,
) { newValue ->
model.savePreference(backupPassword, newValue)
}
}
secureFlag.observe(viewLifecycleOwner) { value ->
binding.SecureFlag.setup(secureFlag, value, requireContext(), layoutInflater) { newValue
->
model.savePreference(secureFlag, newValue)
activity?.setEnabledSecureFlag(newValue)
}
}
}
private fun NotallyXPreferences.setupSettings(binding: FragmentSettingsBinding) {
binding.apply {
ImportSettings.setOnClickListener {
showDialog(R.string.import_settings_message, R.string.import_action) { _, _ ->
val intent =
Intent(Intent.ACTION_OPEN_DOCUMENT)
.apply {
type = MIME_TYPE_JSON
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_TITLE, "NotallyX_Settings.json")
}
.wrapWithChooser(requireContext())
importSettingsActivityResultLauncher.launch(intent)
}
}
ExportSettings.setOnClickListener {
showDialog(R.string.export_settings_message, R.string.export) { _, _ ->
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT)
.apply {
type = MIME_TYPE_JSON
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_TITLE, "NotallyX_Settings.json")
}
.wrapWithChooser(requireContext())
exportSettingsActivityResultLauncher.launch(intent)
}
}
ResetSettings.setOnClickListener {
showDialog(R.string.reset_settings_message, R.string.reset_settings) { _, _ ->
model.resetPreferences { _ -> showToast(R.string.reset_settings_success) }
}
}
dataInPublicFolder.observe(viewLifecycleOwner) { value ->
binding.DataInPublicFolder.setup(
dataInPublicFolder,
value,
requireContext(),
layoutInflater,
R.string.data_in_public_message,
) { enabled ->
if (enabled) {
model.enableDataInPublic()
} else {
model.disableDataInPublic()
}
}
}
AutoSaveAfterIdle.setupAutoSaveIdleTime(autoSaveAfterIdleTime, requireContext()) {
newValue ->
model.savePreference(autoSaveAfterIdleTime, newValue)
}
ClearData.setOnClickListener {
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.clear_data_message)
.setPositiveButton(R.string.delete_all) { _, _ -> model.deleteAll() }
.setCancelButton()
.show()
}
}
model.deletionProgress.setupProgressDialog(this@SettingsFragment, R.string.deleting_files)
}
private fun setupAbout(binding: FragmentSettingsBinding) {
binding.apply {
SendFeedback.setOnClickListener {
val options =
arrayOf(
getString(R.string.report_bug),
getString(R.string.make_feature_request),
getString(R.string.send_feedback),
)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.send_feedback)
.setItems(options) { _, which ->
when (which) {
0 -> {
val app = requireContext().applicationContext as Application
val logs = app.getLastExceptionLog()
reportBug(logs)
}
1 ->
requireContext().catchNoBrowserInstalled {
startActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse(
"https://github.com/PhilKes/NotallyX/issues/new?labels=enhancement&template=feature_request.md"
),
)
.wrapWithChooser(requireContext())
)
}
2 -> {
val intent =
Intent(Intent.ACTION_SEND)
.apply {
selector =
Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:"))
putExtra(
Intent.EXTRA_EMAIL,
arrayOf("notallyx@yahoo.com"),
)
putExtra(Intent.EXTRA_SUBJECT, "NotallyX [Feedback]")
val app =
requireContext().applicationContext as Application
val log = app.getLogFile()
if (log.exists()) {
val uri = app.getUriForFile(log)
putExtra(Intent.EXTRA_STREAM, uri)
}
}
.wrapWithChooser(requireContext())
try {
startActivity(intent)
} catch (exception: ActivityNotFoundException) {
showToast(R.string.install_an_email)
}
}
}
}
.setCancelButton()
.show()
}
Rate.setOnClickListener {
openLink("https://play.google.com/store/apps/details?id=com.philkes.notallyx")
}
SourceCode.setOnClickListener { openLink("https://github.com/PhilKes/NotallyX") }
Libraries.setOnClickListener {
val libraries =
arrayOf(
"Glide",
"Pretty Time",
"SwipeDrawer",
"Work Manager",
"Subsampling Scale ImageView",
"Material Components for Android",
"SQLCipher",
"Zip4J",
"AndroidFastScroll",
"ColorPickerView",
)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.libraries)
.setItems(libraries) { _, which ->
when (which) {
0 -> openLink("https://github.com/bumptech/glide")
1 -> openLink("https://github.com/ocpsoft/prettytime")
2 -> openLink("https://leaqi.github.io/SwipeDrawer_en")
3 ->
openLink(
"https://developer.android.com/jetpack/androidx/releases/work"
)
4 ->
openLink(
"https://github.com/davemorrissey/subsampling-scale-image-view"
)
5 ->
openLink(
"https://github.com/material-components/material-components-android"
)
6 -> openLink("https://github.com/sqlcipher/sqlcipher")
7 -> openLink("https://github.com/srikanth-lingala/zip4j")
8 -> openLink("https://github.com/zhanghai/AndroidFastScroll")
9 -> openLink("https://github.com/skydoves/ColorPickerView")
}
}
.setCancelButton()
.show()
}
Donate.setOnClickListener { openLink("https://ko-fi.com/philkes") }
try {
val pInfo =
requireContext().packageManager.getPackageInfo(requireContext().packageName, 0)
val version = pInfo.versionName
VersionText.text = "v$version"
} catch (_: PackageManager.NameNotFoundException) {}
}
}
private fun displayChooseBackupFolderDialog() {
showDialog(R.string.auto_backups_folder_hint, R.string.choose_folder) { _, _ ->
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).wrapWithChooser(requireContext())
chooseBackupFolderActivityResultLauncher.launch(intent)
}
}
private fun showEnableBiometricLock() {
showBiometricOrPinPrompt(
false,
setupLockActivityResultLauncher,
R.string.enable_lock_title,
R.string.enable_lock_description,
onSuccess = { cipher ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
model.enableBiometricLock(cipher)
}
val app = (activity?.application as NotallyXApplication)
app.locked.value = false
showToast(R.string.biometrics_setup_success)
},
) {
showBiometricsNotSetupDialog()
}
}
private fun showDisableBiometricLock() {
showBiometricOrPinPrompt(
true,
disableLockActivityResultLauncher,
R.string.disable_lock_title,
R.string.disable_lock_description,
model.preferences.iv.value!!,
onSuccess = { cipher ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
model.disableBiometricLock(cipher)
}
showToast(R.string.biometrics_disable_success)
},
) {}
}
private fun showBiometricsNotSetupDialog() {
showDialog(R.string.biometrics_not_setup, R.string.tap_to_set_up) { _, _ ->
val intent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Intent(Settings.ACTION_BIOMETRIC_ENROLL)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
Intent(Settings.ACTION_FINGERPRINT_ENROLL)
} else {
Intent(Settings.ACTION_SECURITY_SETTINGS)
}
setupLockActivityResultLauncher.launch(intent)
}
}
private fun openLink(link: String) {
val uri = Uri.parse(link)
val intent = Intent(Intent.ACTION_VIEW, uri).wrapWithChooser(requireContext())
startActivity(intent)
}
private fun askForUriPermissions(uri: Uri) {
chooseBackupFolderActivityResultLauncher.launch(
Intent(ACTION_OPEN_DOCUMENT_TREE).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
putExtra(DocumentsContract.EXTRA_INITIAL_URI, uri)
}
}
)
}
companion object {
const val EXTRA_SHOW_IMPORT_BACKUPS_FOLDER =
"notallyx.intent.extra.SHOW_IMPORT_BACKUPS_FOLDER"
}
}

View file

@ -1,80 +1,223 @@
package com.philkes.notallyx.presentation.activity.note package com.philkes.notallyx.presentation.activity.note
import android.os.Build import android.os.Bundle
import android.view.MenuItem import android.view.View
import android.view.inputmethod.InputMethodManager import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.Preferences 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.add 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.view.misc.ListItemSorting import com.philkes.notallyx.presentation.showKeyboardOnFocusedItem
import com.philkes.notallyx.presentation.view.note.listitem.ListItemAdapter import com.philkes.notallyx.presentation.view.note.action.MoreListActions
import com.philkes.notallyx.presentation.view.note.action.MoreListBottomSheet
import com.philkes.notallyx.presentation.view.note.listitem.HighlightText
import com.philkes.notallyx.presentation.view.note.listitem.ListManager import com.philkes.notallyx.presentation.view.note.listitem.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.toMutableList import com.philkes.notallyx.presentation.view.note.listitem.adapter.ListItemVH
import com.philkes.notallyx.presentation.widget.WidgetProvider import com.philkes.notallyx.presentation.view.note.listitem.init
import com.philkes.notallyx.utils.changehistory.ChangeHistory import com.philkes.notallyx.presentation.view.note.listitem.setItems
import com.philkes.notallyx.presentation.view.note.listitem.sorting.ListItemParentSortCallback
import com.philkes.notallyx.presentation.view.note.listitem.sorting.SortedItemsList
import com.philkes.notallyx.presentation.view.note.listitem.splitByChecked
import com.philkes.notallyx.presentation.view.note.listitem.toMutableList
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.autoSortByCheckedEnabled
import com.philkes.notallyx.utils.findAllOccurrences
import com.philkes.notallyx.utils.indices
import com.philkes.notallyx.utils.mapIndexed
import java.util.concurrent.atomic.AtomicInteger
class EditListActivity : EditActivity(Type.LIST) { class EditListActivity : EditActivity(Type.LIST), MoreListActions {
private lateinit var adapter: ListItemAdapter private 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 suspend fun saveNote() { override fun finish() {
super.saveNote() notallyModel.setItems(items.toMutableList() + (itemsChecked?.toMutableList() ?: listOf()))
model.saveNote(items.toMutableList()) super.finish()
WidgetProvider.sendBroadcast(application, longArrayOf(model.id))
} }
override fun setupToolbar() { override fun updateModel() {
super.setupToolbar() super.updateModel()
binding.Toolbar.menu.apply { notallyModel.setItems(items.toMutableList() + (itemsChecked?.toMutableList() ?: listOf()))
add( }
1,
R.string.delete_checked_items, override fun onSaveInstanceState(outState: Bundle) {
R.drawable.delete_all, updateModel()
MenuItem.SHOW_AS_ACTION_IF_ROOM, binding.MainListView.focusedChild?.let { focusedChild ->
) { val viewHolder = binding.MainListView.findContainingViewHolder(focusedChild)
listManager.deleteCheckedItems() if (viewHolder is ListItemVH) {
val itemPos = binding.MainListView.getChildAdapterPosition(focusedChild)
if (itemPos > -1) {
val (selectionStart, selectionEnd) = viewHolder.getSelection()
outState.apply {
putInt(EXTRA_ITEM_POS, itemPos)
putInt(EXTRA_SELECTION_START, selectionStart)
putInt(EXTRA_SELECTION_END, selectionEnd)
}
}
} }
add( }
1,
R.string.check_all_items, super.onSaveInstanceState(outState)
R.drawable.checkbox_fill, }
MenuItem.SHOW_AS_ACTION_IF_ROOM,
) { override fun toggleCanEdit(mode: NoteViewMode) {
listManager.changeCheckedForAll(true) 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
} }
add( }
1,
R.string.uncheck_all_items, override fun deleteChecked() {
R.drawable.checkbox, listManager.deleteCheckedItems()
MenuItem.SHOW_AS_ACTION_IF_ROOM, }
) {
listManager.changeCheckedForAll(false) override fun checkAll() {
listManager.changeCheckedForAll(true)
}
override fun uncheckAll() {
listManager.changeCheckedForAll(false)
}
override fun initBottomMenu() {
super.initBottomMenu()
binding.BottomAppBarRight.apply {
removeAllViews()
addToggleViewMode()
addIconButton(R.string.tap_for_more_options, R.drawable.more_vert, marginStart = 0) {
MoreListBottomSheet(
this@EditListActivity,
createNoteTypeActions() + createFolderActions(),
colorInt,
)
.show(supportFragmentManager, MoreListBottomSheet.TAG)
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { }
setGroupDividerEnabled(true) setBottomAppBarColor(colorInt)
}
private fun SortedList<ListItem>.highlightSearch(
search: String,
adapter: HighlightText?,
resultPosCounter: AtomicInteger,
alreadyNotifiedItemPos: MutableSet<Int>,
): Int {
return mapIndexed { idx, item ->
val occurrences = item.body.findAllOccurrences(search)
occurrences.onEach { (startIdx, endIdx) ->
adapter?.highlightText(
ListItemHighlight(
idx,
resultPosCounter.getAndIncrement(),
startIdx,
endIdx,
false,
)
)
}
if (occurrences.isNotEmpty()) {
alreadyNotifiedItemPos.add(idx)
}
occurrences.size
} }
.sum()
}
private fun List<ListItem>.highlightSearch(
search: String,
adapter: ListItemAdapter?,
resultPosCounter: AtomicInteger,
alreadyNotifiedItemPos: MutableSet<Int>,
): Int {
return mapIndexed { idx, item ->
val occurrences = item.body.findAllOccurrences(search)
occurrences.onEach { (startIdx, endIdx) ->
adapter?.highlightText(
ListItemHighlight(
idx,
resultPosCounter.getAndIncrement(),
startIdx,
endIdx,
false,
)
)
}
if (occurrences.isNotEmpty()) {
alreadyNotifiedItemPos.add(idx)
}
occurrences.size
}
.sum()
}
override fun highlightSearchResults(search: String): Int {
val resultPosCounter = AtomicInteger(0)
val alreadyNotifiedItemPos = mutableSetOf<Int>()
adapter?.clearHighlights()
adapterChecked?.clearHighlights()
val amount =
items.highlightSearch(search, adapter, resultPosCounter, alreadyNotifiedItemPos) +
(itemsChecked?.highlightSearch(
search,
adapterChecked,
resultPosCounter,
alreadyNotifiedItemPos,
) ?: 0)
items.indices
.filter { !alreadyNotifiedItemPos.contains(it) }
.forEach { adapter?.notifyItemChanged(it) }
itemsChecked
?.indices
?.filter { !alreadyNotifiedItemPos.contains(it) }
?.forEach { adapter?.notifyItemChanged(it) }
return amount
}
override fun selectSearchResult(resultPos: Int) {
var selectedItemPos = adapter!!.selectHighlight(resultPos)
if (selectedItemPos == -1 && adapterChecked != null) {
selectedItemPos = adapterChecked!!.selectHighlight(resultPos)
if (selectedItemPos != -1) {
binding.CheckedListView.scrollToItemPosition(selectedItemPos)
}
} else if (selectedItemPos != -1) {
binding.MainListView.scrollToItemPosition(selectedItemPos)
} }
} }
override fun initActionManager(undo: MenuItem, redo: MenuItem) { private fun RecyclerView.scrollToItemPosition(position: Int) {
changeHistory = ChangeHistory { post {
undo.isEnabled = changeHistory.canUndo() findViewHolderForAdapterPosition(position)?.itemView?.let {
redo.isEnabled = changeHistory.canRedo() binding.ScrollView.scrollTo(0, top + it.top)
}
} }
} }
override fun configureUI() { override fun configureUI() {
binding.EnterTitle.setOnNextAction { listManager.moveFocusToNext(-1) } binding.EnterTitle.setOnNextAction { listManager.moveFocusToNext(-1) }
if (model.isNewNote || model.items.isEmpty()) { if (notallyModel.isNewNote || notallyModel.items.isEmpty()) {
listManager.add(pushChange = false) listManager.add(pushChange = false)
} }
} }
@ -84,36 +227,92 @@ class EditListActivity : EditActivity(Type.LIST) {
binding.AddItem.setOnClickListener { listManager.add() } binding.AddItem.setOnClickListener { listManager.add() }
} }
override fun setStateFromModel() { override fun setStateFromModel(savedInstanceState: Bundle?) {
super.setStateFromModel() super.setStateFromModel(savedInstanceState)
val elevation = resources.displayMetrics.density * 2 val elevation = resources.displayMetrics.density * 2
listManager = listManager =
ListManager( ListManager(
binding.RecyclerView, binding.MainListView,
changeHistory, changeHistory,
preferences, preferences,
getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager, inputMethodManager,
) {
if (isInSearchMode()) {
endSearch()
}
},
) { _ ->
if (isInSearchMode() && search.results.value > 0) {
updateSearchResults(search.query)
}
}
adapter = adapter =
ListItemAdapter( ListItemAdapter(
model.textSize, colorInt,
notallyModel.textSize,
elevation, elevation,
Preferences.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) {
ListItemSorting.autoSortByChecked -> 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(model.items) listManager.init(adapter!!, itemsChecked, adapterChecked)
adapter.setList(items) binding.MainListView.adapter = adapter
binding.RecyclerView.adapter = adapter
listManager.adapter = adapter savedInstanceState?.let {
listManager.initList(items) val itemPos = it.getInt(EXTRA_ITEM_POS, -1)
if (itemPos > -1) {
binding.MainListView.apply {
post {
scrollToPosition(itemPos)
val viewHolder = findViewHolderForLayoutPosition(itemPos)
if (viewHolder is ListItemVH) {
val selectionStart = it.getInt(EXTRA_SELECTION_START, -1)
val selectionEnd = it.getInt(EXTRA_SELECTION_END, -1)
viewHolder.focusEditText(
selectionStart,
selectionEnd,
inputMethodManager,
)
}
}
}
}
}
}
override fun setColor() {
super.setColor()
adapter?.setBackgroundColor(colorInt)
adapterChecked?.setBackgroundColor(colorInt)
}
companion object {
private const val EXTRA_ITEM_POS = "notallyx.intent.extra.ITEM_POS"
private const val EXTRA_SELECTION_START = "notallyx.intent.extra.EXTRA_SELECTION_START"
private const val EXTRA_SELECTION_END = "notallyx.intent.extra.EXTRA_SELECTION_END"
} }
} }

View file

@ -1,201 +1,192 @@
package com.philkes.notallyx.presentation.activity.note package com.philkes.notallyx.presentation.activity.note
import android.app.Activity import android.app.Activity
import android.content.ClipboardManager
import android.content.Intent import android.content.Intent
import android.graphics.Typeface import android.graphics.Typeface
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.text.Spanned import android.os.Bundle
import android.text.style.StrikethroughSpan import android.text.style.StrikethroughSpan
import android.text.style.StyleSpan import android.text.style.StyleSpan
import android.text.style.TypefaceSpan import android.text.style.TypefaceSpan
import android.text.style.URLSpan import android.text.style.URLSpan
import android.text.style.UnderlineSpan
import android.view.ActionMode import android.view.ActionMode
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.widget.Toast import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import android.widget.LinearLayout
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.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
import com.philkes.notallyx.data.model.getNoteTypeFromUrl import com.philkes.notallyx.data.model.getNoteTypeFromUrl
import com.philkes.notallyx.data.model.isNoteUrl import com.philkes.notallyx.data.model.isNoteUrl
import com.philkes.notallyx.data.model.isWebUrl import com.philkes.notallyx.databinding.BottomTextFormattingMenuBinding
import com.philkes.notallyx.databinding.TextInputDialog2Binding import com.philkes.notallyx.databinding.RecyclerToggleBinding
import com.philkes.notallyx.presentation.activity.note.PickNoteActivity.Companion.EXCLUDE_NOTE_ID import com.philkes.notallyx.presentation.activity.note.PickNoteActivity.Companion.EXTRA_EXCLUDE_NOTE_ID
import com.philkes.notallyx.presentation.activity.note.PickNoteActivity.Companion.PICKED_NOTE_ID import com.philkes.notallyx.presentation.activity.note.PickNoteActivity.Companion.EXTRA_PICKED_NOTE_ID
import com.philkes.notallyx.presentation.activity.note.PickNoteActivity.Companion.PICKED_NOTE_TITLE import com.philkes.notallyx.presentation.activity.note.PickNoteActivity.Companion.EXTRA_PICKED_NOTE_TITLE
import com.philkes.notallyx.presentation.activity.note.PickNoteActivity.Companion.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.copyToClipBoard import com.philkes.notallyx.presentation.addIconButton
import com.philkes.notallyx.presentation.getLatestText import com.philkes.notallyx.presentation.createBoldSpan
import com.philkes.notallyx.presentation.dp
import com.philkes.notallyx.presentation.hideKeyboard
import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews
import com.philkes.notallyx.presentation.setOnNextAction import com.philkes.notallyx.presentation.setOnNextAction
import com.philkes.notallyx.presentation.showAndFocus
import com.philkes.notallyx.presentation.showKeyboard import com.philkes.notallyx.presentation.showKeyboard
import com.philkes.notallyx.presentation.view.Constants import com.philkes.notallyx.presentation.showToast
import com.philkes.notallyx.presentation.widget.WidgetProvider import com.philkes.notallyx.presentation.view.note.TextFormattingAdapter
import com.philkes.notallyx.presentation.view.note.action.AddNoteActions
import com.philkes.notallyx.presentation.view.note.action.AddNoteBottomSheet
import com.philkes.notallyx.utils.LinkMovementMethod import com.philkes.notallyx.utils.LinkMovementMethod
import com.philkes.notallyx.utils.copyToClipBoard
import com.philkes.notallyx.utils.findAllOccurrences
import com.philkes.notallyx.utils.wrapWithChooser
private const val UNNAMED_NOTE_PLACEHOLDER = "Unnamed Note" class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
class EditNoteActivity : EditActivity(Type.NOTE) {
private lateinit var selectedSpan: URLSpan private lateinit var selectedSpan: URLSpan
private lateinit var pickNoteNewActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var pickNoteUpdateActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var textFormatMenu: View
override suspend fun saveNote() { private var textFormattingAdapter: TextFormattingAdapter? = null
super.saveNote()
model.saveNote() private var searchResultIndices: List<Pair<Int, Int>>? = null
WidgetProvider.sendBroadcast(application, longArrayOf(model.id))
}
override fun configureUI() { override fun configureUI() {
binding.EnterTitle.setOnNextAction { binding.EnterBody.requestFocus() } binding.EnterTitle.setOnNextAction { binding.EnterBody.requestFocus() }
setupEditor() if (notallyModel.isNewNote) {
if (model.isNewNote) {
binding.EnterBody.requestFocus() binding.EnterBody.requestFocus()
} }
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onActivityResult(requestCode, resultCode, data) super.onCreate(savedInstanceState)
if (resultCode == RESULT_OK) {
when (requestCode) { setupActivityResultLaunchers()
REQUEST_CODE_PICK_NOTE_NEW -> { }
val noteId = data?.getLongExtra(PICKED_NOTE_ID, -1L)!!
if (noteId == -1L) { override fun toggleCanEdit(mode: NoteViewMode) {
return super.toggleCanEdit(mode)
} textFormatMenu.isVisible = mode == NoteViewMode.EDIT
val noteTitle = data.getStringExtra(PICKED_NOTE_TITLE)!! when {
val noteType = Type.valueOf(data.getStringExtra(PICKED_NOTE_TYPE)!!) mode == NoteViewMode.EDIT -> showKeyboard(binding.EnterBody)
binding.EnterBody.addSpan(noteTitle, URLSpan(noteId.createNoteUrl(noteType))) binding.EnterBody.isFocused -> hideKeyboard(binding.EnterBody)
} }
REQUEST_CODE_PICK_NOTE_UPDATE -> { binding.EnterBody.setCanEdit(mode == NoteViewMode.EDIT)
val noteId = data?.getLongExtra(PICKED_NOTE_ID, -1L)!! setupEditor()
if (noteId == -1L) { }
return
} override fun onSaveInstanceState(outState: Bundle) {
// TODO: If the linked note title changes the link display text does not change super.onSaveInstanceState(outState)
val noteTitle = outState.apply {
data.getStringExtra(PICKED_NOTE_TITLE)!!.ifEmpty { putInt(EXTRA_SELECTION_START, binding.EnterBody.selectionStart)
UNNAMED_NOTE_PLACEHOLDER putInt(EXTRA_SELECTION_END, binding.EnterBody.selectionEnd)
}
}
private fun setupActivityResultLaunchers() {
pickNoteNewActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
try {
val (title, url, emptyTitle) = result.data.getPickedNoteData()
if (emptyTitle) {
binding.EnterBody.showAddLinkDialog(
this,
presetDisplayText = title,
presetUrl = url,
isNewUnnamedLink = true,
)
} else {
binding.EnterBody.addSpans(title, listOf(UnderlineSpan(), URLSpan(url)))
} }
val noteType = Type.valueOf(data.getStringExtra(PICKED_NOTE_TYPE)!!) } catch (_: IllegalArgumentException) {}
val noteUrl = noteId.createNoteUrl(noteType)
binding.EnterBody.updateSpan(selectedSpan, URLSpan(noteUrl), noteTitle)
} }
} }
pickNoteUpdateActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
try {
val (title, url, emptyTitle) = result.data.getPickedNoteData()
val newSpan = URLSpan(url)
binding.EnterBody.updateSpan(selectedSpan, newSpan, title)
if (emptyTitle) {
binding.EnterBody.showEditDialog(newSpan, isNewUnnamedLink = true)
}
} catch (_: IllegalArgumentException) {}
}
}
}
override fun highlightSearchResults(search: String): Int {
binding.EnterBody.clearHighlights()
if (search.isEmpty()) {
return 0
}
searchResultIndices =
notallyModel.body.toString().findAllOccurrences(search).onEach { (startIdx, endIdx) ->
binding.EnterBody.highlight(startIdx, endIdx, false)
}
return searchResultIndices!!.size
}
override fun selectSearchResult(resultPos: Int) {
if (resultPos < 0) {
binding.EnterBody.unselectHighlight()
return
}
searchResultIndices?.get(resultPos)?.let { (startIdx, endIdx) ->
val selectedLineTop = binding.EnterBody.highlight(startIdx, endIdx, true)
selectedLineTop?.let { binding.ScrollView.scrollTo(0, it) }
} }
} }
override fun setupListeners() { override fun setupListeners() {
super.setupListeners() super.setupListeners()
binding.EnterBody.initHistory(changeHistory) { text -> model.body = text } binding.EnterBody.initHistory(changeHistory) { text ->
val textChanged = !notallyModel.body.toString().contentEquals(text)
notallyModel.body = text
if (textChanged) {
updateSearchResults(search.query)
}
}
} }
override fun setStateFromModel() { override fun setStateFromModel(savedInstanceState: Bundle?) {
super.setStateFromModel() super.setStateFromModel(savedInstanceState)
updateEditText() updateEditText()
savedInstanceState?.let {
val selectionStart = it.getInt(EXTRA_SELECTION_START, -1)
val selectionEnd = it.getInt(EXTRA_SELECTION_END, -1)
if (selectionStart > -1) {
binding.EnterBody.focusAndSelect(selectionStart, selectionEnd)
}
}
} }
private fun updateEditText() { private fun updateEditText() {
binding.EnterBody.text = model.body binding.EnterBody.text = notallyModel.body
} }
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.bold, 0) {
binding.EnterBody.applySpan(StyleSpan(Typeface.BOLD))
mode?.finish()
}
add(R.string.link, 0) { showAddLinkDialog(mode) }
add(R.string.italic, 0) {
binding.EnterBody.applySpan(StyleSpan(Typeface.ITALIC))
mode?.finish()
}
add(R.string.monospace, 0) {
binding.EnterBody.applySpan(TypefaceSpan("monospace"))
mode?.finish()
}
add(R.string.strikethrough, 0) {
binding.EnterBody.applySpan(StrikethroughSpan())
mode?.finish()
}
add(R.string.clear_formatting, 0) {
binding.EnterBody.clearFormatting()
mode?.finish()
}
}
} catch (exception: Exception) {
exception.printStackTrace()
}
return true
}
private fun showAddLinkDialog(mode: ActionMode?) {
val urlFromClipboard =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
baseContext
.getSystemService(ClipboardManager::class.java)!!
.getLatestText()
.let { if (it.isWebUrl()) it.toString() else "" }
} else ""
val displayTextBefore = binding.EnterBody.getSelectionText()!!
this@EditNoteActivity.showEditLinkDialog(urlFromClipboard, displayTextBefore) {
urlAfter,
displayTextAfter ->
if (displayTextAfter == displayTextBefore) {
binding.EnterBody.applySpan(URLSpan(urlAfter))
} else {
binding.EnterBody.changeTextWithHistory { text ->
val start = binding.EnterBody.selectionStart
text.replace(
start,
binding.EnterBody.selectionEnd,
displayTextAfter,
)
text.setSpan(
URLSpan(urlAfter),
start,
start + displayTextAfter.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
)
}
}
mode?.finish()
}
}
override fun onDestroyActionMode(mode: ActionMode?) {
binding.EnterBody.isActionModeOn = false
}
}
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
@ -207,8 +198,54 @@ class EditNoteActivity : EditActivity(Type.NOTE) {
// ActionMode implementation // ActionMode implementation
try { try {
menu?.apply { menu?.apply {
add(R.string.link_note, 0) { add(
startPickNote(REQUEST_CODE_PICK_NOTE_NEW) 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()
} }
} }
@ -222,38 +259,162 @@ class EditNoteActivity : EditActivity(Type.NOTE) {
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
}
if (canEdit) {
binding.EnterBody.setOnSelectionChange { selStart, selEnd ->
if (selEnd - selStart > 0) {
if (!textFormatMenu.isEnabled) {
initBottomTextFormattingMenu()
}
textFormatMenu.isEnabled = true
textFormattingAdapter?.updateTextFormattingToggles(selStart, selEnd)
} else {
if (textFormatMenu.isEnabled) {
initBottomMenu()
}
textFormatMenu.isEnabled = false
}
}
} else {
binding.EnterBody.setOnSelectionChange { _, _ -> }
} }
binding.ContentLayout.setOnClickListener { binding.ContentLayout.setOnClickListener {
binding.EnterBody.apply { binding.EnterBody.apply {
requestFocus() requestFocus()
setSelection(length()) if (canEdit) {
showKeyboard(this) setSelection(length())
showKeyboard(this)
}
} }
} }
} }
private fun EditNoteActivity.startPickNote(requestCode: Int) { override fun initBottomMenu() {
super.initBottomMenu()
binding.BottomAppBarCenter.visibility = VISIBLE
binding.BottomAppBarLeft.apply {
removeAllViews()
addIconButton(R.string.add_item, R.drawable.add, marginStart = 0) {
AddNoteBottomSheet(this@EditNoteActivity, colorInt)
.show(supportFragmentManager, AddNoteBottomSheet.TAG)
}
updateLayoutParams<ConstraintLayout.LayoutParams> { endToStart = -1 }
textFormatMenu =
addIconButton(R.string.edit, R.drawable.text_format) {
initBottomTextFormattingMenu()
}
.apply { isEnabled = binding.EnterBody.isActionModeOn }
}
setBottomAppBarColor(colorInt)
}
private fun initBottomTextFormattingMenu() {
binding.BottomAppBarCenter.visibility = GONE
val extractColor = colorInt
binding.BottomAppBarRight.apply {
removeAllViews()
addView(
RecyclerToggleBinding.inflate(layoutInflater, this, false).root.apply {
setIconResource(R.drawable.close)
contentDescription = context.getString(R.string.cancel)
setOnClickListener { initBottomMenu() }
updateLayoutParams<LinearLayout.LayoutParams> {
marginEnd = 0
marginStart = 10.dp
}
setControlsContrastColorForAllViews(extractColor)
setBackgroundColor(0)
}
)
}
binding.BottomAppBarLeft.apply {
removeAllViews()
updateLayoutParams<ConstraintLayout.LayoutParams> {
endToStart = R.id.BottomAppBarRight
}
requestLayout()
val layout = BottomTextFormattingMenuBinding.inflate(layoutInflater, this, false)
layout.MainListView.apply {
textFormattingAdapter =
TextFormattingAdapter(this@EditNoteActivity, binding.EnterBody, colorInt)
adapter = textFormattingAdapter
layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
}
addView(layout.root)
}
}
override fun linkNote() {
linkNote(pickNoteNewActivityResultLauncher)
}
fun linkNote(activityResultLauncher: ActivityResultLauncher<Intent>) {
val intent = val intent =
Intent(this, PickNoteActivity::class.java).apply { putExtra(EXCLUDE_NOTE_ID, model.id) } Intent(this, PickNoteActivity::class.java).apply {
startActivityForResult(intent, requestCode) putExtra(EXTRA_EXCLUDE_NOTE_ID, notallyModel.id)
}
activityResultLauncher.launch(intent)
} }
private fun setupMovementMethod() { private fun setupMovementMethod() {
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.open_note), getString(R.string.remove_link),
) 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(
@ -265,31 +426,16 @@ class EditNoteActivity : EditActivity(Type.NOTE) {
) )
.setItems(items) { _, which -> .setItems(items) { _, which ->
when (which) { when (which) {
0 -> { 0 -> openLink(span)
binding.EnterBody.removeSpan(span, true)
}
1 -> 1 ->
if (span.url.isNoteUrl()) { if (span.url.isNoteUrl()) {
selectedSpan = span removeLink(span)
startPickNote(REQUEST_CODE_PICK_NOTE_UPDATE) } else copyLink(span)
} else { 2 ->
copyToClipBoard(span.url) if (span.url.isNoteUrl()) {
Toast.makeText(this, R.string.copied_link, Toast.LENGTH_LONG).show() changeNoteLink(span)
} } else removeLink(span)
3 -> editLink(span)
2 -> {
span.url?.let {
if (it.isNoteUrl()) {
span.navigateToNote()
} else {
span.showEditDialog()
}
}
}
3 -> {
openLink(span.url)
}
} }
} }
.show() .show()
@ -297,13 +443,44 @@ class EditNoteActivity : EditActivity(Type.NOTE) {
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) val intent = Intent(Intent.ACTION_VIEW, uri).wrapWithChooser(this)
try { try {
startActivity(intent) startActivity(intent)
} catch (exception: Exception) { } catch (exception: Exception) {
Toast.makeText(this, R.string.cant_open_link, Toast.LENGTH_LONG).show() showToast(R.string.cant_open_link)
} }
} }
@ -316,56 +493,30 @@ class EditNoteActivity : EditActivity(Type.NOTE) {
} }
} }
private fun URLSpan.showEditDialog() {
val displayTextBefore = binding.EnterBody.getSpanText(this)
showEditLinkDialog(url, displayTextBefore) { urlAfter, displayTextAfter ->
if (urlAfter != null) {
binding.EnterBody.updateSpan(
this,
URLSpan(urlAfter),
if (displayTextAfter == displayTextBefore) null else displayTextAfter,
)
} else {
binding.EnterBody.removeSpan(this)
}
}
}
private fun goToActivity(activity: Class<out Activity>, noteId: Long) { private fun goToActivity(activity: Class<out Activity>, noteId: Long) {
val intent = Intent(this, activity) val intent = Intent(this, activity)
intent.putExtra(Constants.SelectedBaseNote, noteId) intent.putExtra(EXTRA_SELECTED_BASE_NOTE, noteId)
startActivityForResult(intent, -1) startActivity(intent)
} }
private fun showEditLinkDialog( private fun Intent?.getPickedNoteData(): Triple<String, String, Boolean> {
urlBefore: String, val noteId = this?.getLongExtra(EXTRA_PICKED_NOTE_ID, -1L)!!
displayTextBefore: String, if (noteId == -1L) {
onSuccess: (urlAfter: String?, displayTextAfter: String) -> Unit, throw IllegalArgumentException("Invalid note picked!")
) { }
val layout = TextInputDialog2Binding.inflate(layoutInflater) var emptyTitle = false
layout.InputText1.apply { setText(displayTextBefore) } val noteTitle =
layout.InputTextLayout1.setHint(R.string.display_text) this.getStringExtra(EXTRA_PICKED_NOTE_TITLE)!!.ifEmpty {
layout.InputText2.apply { setText(urlBefore) } emptyTitle = true
this@EditNoteActivity.getString(R.string.note)
layout.InputTextLayout2.setHint(R.string.link)
MaterialAlertDialogBuilder(this)
.setView(layout.root)
.setTitle(R.string.edit_link)
.setPositiveButton(R.string.save) { _, _ ->
val displayTextAfter = layout.InputText1.text.toString()
val urlAfter = layout.InputText2.text.toString()
onSuccess.invoke(urlAfter, displayTextAfter)
} }
.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.cancel() } val noteType = Type.valueOf(this.getStringExtra(EXTRA_PICKED_NOTE_TYPE)!!)
.setNeutralButton(R.string.clear) { dialog, _ -> val noteUrl = noteId.createNoteUrl(noteType)
dialog.cancel() return Triple(noteTitle, noteUrl, emptyTitle)
onSuccess.invoke(null, displayTextBefore)
}
.showAndFocus(layout.InputText2)
} }
companion object { companion object {
const val REQUEST_CODE_PICK_NOTE_NEW = 50 private const val EXTRA_SELECTION_START = "notallyx.intent.extra.EXTRA_SELECTION_START"
const val REQUEST_CODE_PICK_NOTE_UPDATE = 51 private const val EXTRA_SELECTION_END = "notallyx.intent.extra.EXTRA_SELECTION_END"
} }
} }

View file

@ -6,7 +6,6 @@ import androidx.lifecycle.lifecycleScope
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
import com.philkes.notallyx.Preferences
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
@ -14,20 +13,22 @@ import com.philkes.notallyx.data.model.Header
import com.philkes.notallyx.databinding.ActivityPickNoteBinding import com.philkes.notallyx.databinding.ActivityPickNoteBinding
import com.philkes.notallyx.presentation.activity.LockedActivity import com.philkes.notallyx.presentation.activity.LockedActivity
import com.philkes.notallyx.presentation.view.main.BaseNoteAdapter import com.philkes.notallyx.presentation.view.main.BaseNoteAdapter
import com.philkes.notallyx.presentation.view.misc.View import com.philkes.notallyx.presentation.view.main.BaseNoteVHPreferences
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener import com.philkes.notallyx.presentation.view.misc.ItemListener
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
import com.philkes.notallyx.utils.IO.getExternalImagesDirectory import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.NotesView
import com.philkes.notallyx.utils.getExternalImagesDirectory
import java.util.Collections import java.util.Collections
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
open class PickNoteActivity : LockedActivity<ActivityPickNoteBinding>(), ListItemListener { open class PickNoteActivity : LockedActivity<ActivityPickNoteBinding>(), ItemListener {
protected lateinit var adapter: BaseNoteAdapter protected lateinit var adapter: BaseNoteAdapter
private val excludedNoteId by lazy { intent.getLongExtra(EXCLUDE_NOTE_ID, -1L) } private val excludedNoteId by lazy { intent.getLongExtra(EXTRA_EXCLUDE_NOTE_ID, -1L) }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -37,28 +38,32 @@ open class PickNoteActivity : LockedActivity<ActivityPickNoteBinding>(), ListIte
val result = Intent() val result = Intent()
setResult(RESULT_CANCELED, result) setResult(RESULT_CANCELED, result)
val preferences = Preferences.getInstance(application) val preferences = NotallyXPreferences.getInstance(application)
adapter = adapter =
with(preferences) { with(preferences) {
BaseNoteAdapter( BaseNoteAdapter(
Collections.emptySet(), Collections.emptySet(),
dateFormat.value, dateFormat.value,
notesSorting.value.first, notesSorting.value,
textSize.value, BaseNoteVHPreferences(
maxItems, textSize.value,
maxLines, maxItems.value,
maxTitle, maxLines.value,
maxTitle.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 =
if (preferences.view.value == View.grid) { if (preferences.notesView.value == NotesView.GRID) {
StaggeredGridLayoutManager(2, RecyclerView.VERTICAL) StaggeredGridLayoutManager(2, RecyclerView.VERTICAL)
} else LinearLayoutManager(this@PickNoteActivity) } else LinearLayoutManager(this@PickNoteActivity)
} }
@ -67,6 +72,7 @@ open class PickNoteActivity : LockedActivity<ActivityPickNoteBinding>(), ListIte
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 {
@ -74,7 +80,7 @@ open class PickNoteActivity : LockedActivity<ActivityPickNoteBinding>(), ListIte
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 =
@ -87,9 +93,9 @@ open class PickNoteActivity : LockedActivity<ActivityPickNoteBinding>(), ListIte
if (position != -1) { if (position != -1) {
val note = (adapter.getItem(position) as BaseNote) val note = (adapter.getItem(position) as BaseNote)
val success = Intent() val success = Intent()
success.putExtra(PICKED_NOTE_ID, note.id) success.putExtra(EXTRA_PICKED_NOTE_ID, note.id)
success.putExtra(PICKED_NOTE_TITLE, note.title) success.putExtra(EXTRA_PICKED_NOTE_TITLE, note.title)
success.putExtra(PICKED_NOTE_TYPE, note.type.name) success.putExtra(EXTRA_PICKED_NOTE_TYPE, note.type.name)
setResult(RESULT_OK, success) setResult(RESULT_OK, success)
finish() finish()
} }
@ -98,10 +104,10 @@ open class PickNoteActivity : LockedActivity<ActivityPickNoteBinding>(), ListIte
override fun onLongClick(position: Int) {} override fun onLongClick(position: Int) {}
companion object { companion object {
const val EXCLUDE_NOTE_ID = "EXCLUDE_NOTE_ID" const val EXTRA_EXCLUDE_NOTE_ID = "notallyx.intent.extra.EXCLUDE_NOTE_ID"
const val PICKED_NOTE_ID = "PICKED_NOTE_ID" const val EXTRA_PICKED_NOTE_ID = "notallyx.intent.extra.PICKED_NOTE_ID"
const val PICKED_NOTE_TITLE = "PICKED_NOTE_TITLE" const val EXTRA_PICKED_NOTE_TITLE = "notallyx.intent.extra.PICKED_NOTE_TITLE"
const val PICKED_NOTE_TYPE = "PICKED_NOTE_TYPE" const val EXTRA_PICKED_NOTE_TYPE = "notallyx.intent.extra.PICKED_NOTE_TYPE"
} }
} }

View file

@ -7,7 +7,9 @@ import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.widget.Toast import android.widget.Toast
import androidx.core.content.FileProvider import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.IntentCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.philkes.notallyx.R import com.philkes.notallyx.R
@ -15,9 +17,12 @@ import com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.databinding.ActivityPlayAudioBinding import com.philkes.notallyx.databinding.ActivityPlayAudioBinding
import com.philkes.notallyx.presentation.activity.LockedActivity import com.philkes.notallyx.presentation.activity.LockedActivity
import com.philkes.notallyx.presentation.add import com.philkes.notallyx.presentation.add
import com.philkes.notallyx.utils.IO.getExternalAudioDirectory import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.utils.audio.AudioPlayService import com.philkes.notallyx.utils.audio.AudioPlayService
import com.philkes.notallyx.utils.audio.LocalBinder import com.philkes.notallyx.utils.audio.LocalBinder
import com.philkes.notallyx.utils.getExternalAudioDirectory
import com.philkes.notallyx.utils.getUriForFile
import com.philkes.notallyx.utils.wrapWithChooser
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
@ -30,6 +35,7 @@ class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
private var service: AudioPlayService? = null private var service: AudioPlayService? = null
private lateinit var connection: ServiceConnection private lateinit var connection: ServiceConnection
private lateinit var exportFileActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var audio: Audio private lateinit var audio: Audio
@ -38,7 +44,10 @@ class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
binding = ActivityPlayAudioBinding.inflate(layoutInflater) binding = ActivityPlayAudioBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
audio = requireNotNull(intent.getParcelableExtra(AUDIO)) audio =
requireNotNull(
intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_AUDIO, Audio::class.java) }
)
binding.AudioControlView.setDuration(audio.duration) binding.AudioControlView.setDuration(audio.duration)
val intent = Intent(this, AudioPlayService::class.java) val intent = Intent(this, AudioPlayService::class.java)
@ -69,6 +78,13 @@ class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
} }
setupToolbar(binding) setupToolbar(binding)
exportFileActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri -> writeAudioToUri(uri) }
}
}
} }
override fun onDestroy() { override fun onDestroy() {
@ -84,13 +100,6 @@ class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
} }
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_EXPORT_FILE && resultCode == RESULT_OK) {
data?.data?.let { uri -> writeAudioToUri(uri) }
}
}
private fun setupToolbar(binding: ActivityPlayAudioBinding) { private fun setupToolbar(binding: ActivityPlayAudioBinding) {
binding.Toolbar.setNavigationOnClickListener { onBackPressed() } binding.Toolbar.setNavigationOnClickListener { onBackPressed() }
@ -105,26 +114,25 @@ class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
val audioRoot = application.getExternalAudioDirectory() val audioRoot = application.getExternalAudioDirectory()
val file = if (audioRoot != null) File(audioRoot, audio.name) else null val file = if (audioRoot != null) File(audioRoot, audio.name) else null
if (file != null && file.exists()) { if (file != null && file.exists()) {
val uri = FileProvider.getUriForFile(this, "$packageName.provider", file) val uri = getUriForFile(file)
val intent = val intent =
Intent(Intent.ACTION_SEND).apply { Intent(Intent.ACTION_SEND)
type = "audio/mp4" .apply {
putExtra(Intent.EXTRA_STREAM, uri) type = "audio/mp4"
} putExtra(Intent.EXTRA_STREAM, uri)
}
val chooser = Intent.createChooser(intent, null) .wrapWithChooser(this@PlayAudioActivity)
startActivity(chooser) startActivity(intent)
} }
} }
private fun delete() { private fun delete() {
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this)
.setMessage(R.string.delete_audio_recording_forever) .setMessage(R.string.delete_audio_recording_forever)
.setNegativeButton(R.string.cancel, null) .setCancelButton()
.setPositiveButton(R.string.delete) { _, _ -> .setPositiveButton(R.string.delete) { _, _ ->
val intent = Intent() val intent = Intent()
intent.putExtra(AUDIO, audio) intent.putExtra(EXTRA_AUDIO, audio)
setResult(RESULT_OK, intent) setResult(RESULT_OK, intent)
finish() finish()
} }
@ -136,16 +144,18 @@ class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
val file = if (audioRoot != null) File(audioRoot, audio.name) else null val file = if (audioRoot != null) File(audioRoot, audio.name) else null
if (file != null && file.exists()) { if (file != null && file.exists()) {
val intent = val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply { Intent(Intent.ACTION_CREATE_DOCUMENT)
type = "audio/mp4" .apply {
addCategory(Intent.CATEGORY_OPENABLE) type = "audio/mp4"
} addCategory(Intent.CATEGORY_OPENABLE)
}
.wrapWithChooser(this)
val formatter = DateFormat.getDateTimeInstance(DateFormat.FULL, DateFormat.SHORT) val formatter = DateFormat.getDateTimeInstance(DateFormat.FULL, DateFormat.SHORT)
val title = formatter.format(audio.timestamp) val title = formatter.format(audio.timestamp)
intent.putExtra(Intent.EXTRA_TITLE, title) intent.putExtra(Intent.EXTRA_TITLE, title)
startActivityForResult(intent, REQUEST_EXPORT_FILE) exportFileActivityResultLauncher.launch(intent)
} }
} }
@ -193,7 +203,6 @@ class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
} }
companion object { companion object {
const val AUDIO = "AUDIO" const val EXTRA_AUDIO = "notallyx.intent.extra.AUDIO"
private const val REQUEST_EXPORT_FILE = 50
} }
} }

View file

@ -5,21 +5,25 @@ import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import androidx.activity.OnBackPressedCallback
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.lifecycle.Observer
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.databinding.ActivityRecordAudioBinding import com.philkes.notallyx.databinding.ActivityRecordAudioBinding
import com.philkes.notallyx.presentation.activity.LockedActivity import com.philkes.notallyx.presentation.activity.LockedActivity
import com.philkes.notallyx.utils.IO.getTempAudioFile
import com.philkes.notallyx.utils.audio.AudioRecordService import com.philkes.notallyx.utils.audio.AudioRecordService
import com.philkes.notallyx.utils.audio.LocalBinder import com.philkes.notallyx.utils.audio.LocalBinder
import com.philkes.notallyx.utils.audio.Status import com.philkes.notallyx.utils.audio.Status
import com.philkes.notallyx.utils.getTempAudioFile
@RequiresApi(24) @RequiresApi(24)
class RecordAudioActivity : LockedActivity<ActivityRecordAudioBinding>() { class RecordAudioActivity : LockedActivity<ActivityRecordAudioBinding>() {
private var service: AudioRecordService? = null private var service: AudioRecordService? = null
private lateinit var connection: ServiceConnection private lateinit var connection: ServiceConnection
private lateinit var serviceStatusObserver: Observer<Status>
private lateinit var cancelRecordCallback: OnBackPressedCallback
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -33,7 +37,7 @@ class RecordAudioActivity : LockedActivity<ActivityRecordAudioBinding>() {
object : ServiceConnection { object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, binder: IBinder) { override fun onServiceConnected(name: ComponentName, binder: IBinder) {
service = (binder as LocalBinder<AudioRecordService>).getService() service = (binder as LocalBinder<AudioRecordService>).getService()
updateUI(binding, requireNotNull(service)) service?.status?.observe(this@RecordAudioActivity, serviceStatusObserver)
} }
override fun onServiceDisconnected(name: ComponentName?) {} override fun onServiceDisconnected(name: ComponentName?) {}
@ -44,12 +48,11 @@ class RecordAudioActivity : LockedActivity<ActivityRecordAudioBinding>() {
binding.Main.setOnClickListener { binding.Main.setOnClickListener {
val service = this.service val service = this.service
if (service != null) { if (service != null) {
when (service.status) { when (service.status.value) {
Status.PAUSED -> service.resume() Status.PAUSED -> service.resume()
Status.READY -> service.start() Status.READY -> service.start()
Status.RECORDING -> service.pause() Status.RECORDING -> service.pause()
} }
updateUI(binding, service)
} }
} }
@ -60,13 +63,30 @@ class RecordAudioActivity : LockedActivity<ActivityRecordAudioBinding>() {
} }
} }
binding.Toolbar.setNavigationOnClickListener { onBackPressed() } binding.Toolbar.setNavigationOnClickListener { onBackPressedDispatcher.onBackPressed() }
cancelRecordCallback =
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
MaterialAlertDialogBuilder(this@RecordAudioActivity)
.setMessage(R.string.save_recording)
.setPositiveButton(R.string.save) { _, _ -> stopRecording(service!!) }
.setNegativeButton(R.string.discard) { _, _ -> discard(service!!) }
.show()
}
}
onBackPressedDispatcher.addCallback(cancelRecordCallback)
serviceStatusObserver = Observer { status ->
updateUI(binding, service!!)
cancelRecordCallback.isEnabled = status != Status.READY
}
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
if (service != null) { service?.let {
unbindService(connection) unbindService(connection)
it.status.removeObserver(serviceStatusObserver)
service = null service = null
} }
if (isFinishing) { if (isFinishing) {
@ -75,19 +95,6 @@ class RecordAudioActivity : LockedActivity<ActivityRecordAudioBinding>() {
} }
} }
override fun onBackPressed() {
val service = this.service
if (service != null) {
if (service.status != Status.READY) {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.save_recording)
.setPositiveButton(R.string.save) { _, _ -> stopRecording(service) }
.setNegativeButton(R.string.discard) { _, _ -> discard(service) }
.show()
} else super.onBackPressed()
} else super.onBackPressed()
}
private fun discard(service: AudioRecordService) { private fun discard(service: AudioRecordService) {
service.stop() service.stop()
getTempAudioFile().delete() getTempAudioFile().delete()
@ -102,7 +109,7 @@ class RecordAudioActivity : LockedActivity<ActivityRecordAudioBinding>() {
private fun updateUI(binding: ActivityRecordAudioBinding, service: AudioRecordService) { private fun updateUI(binding: ActivityRecordAudioBinding, service: AudioRecordService) {
binding.Timer.base = service.getBase() binding.Timer.base = service.getBase()
when (service.status) { when (service.status.value) {
Status.READY -> { Status.READY -> {
binding.Stop.isEnabled = false binding.Stop.isEnabled = false
binding.Main.setText(R.string.start) binding.Main.setText(R.string.start)

View file

@ -3,8 +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 android.widget.Toast
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
@ -14,14 +12,13 @@ import com.philkes.notallyx.databinding.ActivityLabelBinding
import com.philkes.notallyx.databinding.DialogInputBinding import com.philkes.notallyx.databinding.DialogInputBinding
import com.philkes.notallyx.presentation.activity.LockedActivity import com.philkes.notallyx.presentation.activity.LockedActivity
import com.philkes.notallyx.presentation.add import com.philkes.notallyx.presentation.add
import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.showAndFocus import com.philkes.notallyx.presentation.showAndFocus
import com.philkes.notallyx.presentation.view.main.SelectableLabelAdapter import com.philkes.notallyx.presentation.showToast
import com.philkes.notallyx.presentation.viewmodel.LabelModel import com.philkes.notallyx.presentation.view.main.label.SelectableLabelAdapter
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?) {
@ -29,12 +26,12 @@ class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
binding = ActivityLabelBinding.inflate(layoutInflater) binding = ActivityLabelBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
val savedList = savedInstanceState?.getStringArrayList(SELECTED_LABELS) val savedList = savedInstanceState?.getStringArrayList(EXTRA_SELECTED_LABELS)
val passedList = requireNotNull(intent.getStringArrayListExtra(SELECTED_LABELS)) val passedList = requireNotNull(intent.getStringArrayListExtra(EXTRA_SELECTED_LABELS))
selectedLabels = savedList ?: passedList selectedLabels = savedList ?: passedList
val result = Intent() val result = Intent()
result.putExtra(SELECTED_LABELS, selectedLabels) result.putExtra(EXTRA_SELECTED_LABELS, selectedLabels)
setResult(RESULT_OK, result) setResult(RESULT_OK, result)
setupToolbar() setupToolbar()
@ -43,7 +40,7 @@ class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
outState.putStringArrayList(SELECTED_LABELS, selectedLabels) outState.putStringArrayList(EXTRA_SELECTED_LABELS, selectedLabels)
} }
private fun setupToolbar() { private fun setupToolbar() {
@ -59,19 +56,19 @@ class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this)
.setTitle(R.string.add_label) .setTitle(R.string.add_label)
.setView(binding.root) .setView(binding.root)
.setNegativeButton(R.string.cancel, null) .setCancelButton()
.setPositiveButton(R.string.save) { dialog, _ -> .setPositiveButton(R.string.save) { dialog, _ ->
val value = binding.EditText.text.toString().trim() val value = binding.EditText.text.toString().trim()
if (value.isNotEmpty()) { if (value.isNotEmpty()) {
val label = Label(value) val label = Label(value)
model.insertLabel(label) { success -> baseModel.insertLabel(label) { success ->
if (success) { if (success) {
dialog.dismiss() dialog.dismiss()
} else Toast.makeText(this, R.string.label_exists, Toast.LENGTH_LONG).show() } else showToast(R.string.label_exists)
} }
} }
} }
.showAndFocus(binding.EditText) .showAndFocus(binding.EditText, allowFullSize = true)
} }
private fun setupRecyclerView() { private fun setupRecyclerView() {
@ -87,7 +84,7 @@ class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
} }
} }
binding.RecyclerView.apply { binding.MainListView.apply {
setHasFixedSize(true) setHasFixedSize(true)
adapter = labelAdapter adapter = labelAdapter
addItemDecoration( addItemDecoration(
@ -95,7 +92,7 @@ class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
) )
} }
model.labels.observe(this) { labels -> baseModel.labels.observe(this) { labels ->
labelAdapter.submitList(labels) labelAdapter.submitList(labels)
if (labels.isEmpty()) { if (labels.isEmpty()) {
binding.EmptyState.visibility = View.VISIBLE binding.EmptyState.visibility = View.VISIBLE
@ -104,6 +101,6 @@ class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
} }
companion object { companion object {
const val SELECTED_LABELS = "SELECTED_LABELS" const val EXTRA_SELECTED_LABELS = "notallyx.intent.extra.SELECTED_LABELS"
} }
} }

View file

@ -5,7 +5,9 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import android.widget.Toast
import androidx.core.content.FileProvider import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.os.BundleCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper import androidx.recyclerview.widget.PagerSnapHelper
@ -17,10 +19,13 @@ import com.philkes.notallyx.data.model.Converters
import com.philkes.notallyx.data.model.FileAttachment import com.philkes.notallyx.data.model.FileAttachment
import com.philkes.notallyx.databinding.ActivityViewImageBinding import com.philkes.notallyx.databinding.ActivityViewImageBinding
import com.philkes.notallyx.presentation.activity.LockedActivity import com.philkes.notallyx.presentation.activity.LockedActivity
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_SELECTED_BASE_NOTE
import com.philkes.notallyx.presentation.add import com.philkes.notallyx.presentation.add
import com.philkes.notallyx.presentation.view.Constants import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.view.note.image.ImageAdapter import com.philkes.notallyx.presentation.view.note.image.ImageAdapter
import com.philkes.notallyx.utils.IO.getExternalImagesDirectory import com.philkes.notallyx.utils.getExternalImagesDirectory
import com.philkes.notallyx.utils.getUriForFile
import com.philkes.notallyx.utils.wrapWithChooser
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
@ -32,36 +37,47 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
private var currentImage: FileAttachment? = null private var currentImage: FileAttachment? = null
private lateinit var deletedImages: ArrayList<FileAttachment> private lateinit var deletedImages: ArrayList<FileAttachment>
private lateinit var exportFileActivityResultLauncher: ActivityResultLauncher<Intent>
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = ActivityViewImageBinding.inflate(layoutInflater) binding = ActivityViewImageBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
val savedList = savedInstanceState?.getParcelableArrayList<FileAttachment>(DELETED_IMAGES) val savedList =
savedInstanceState?.let {
BundleCompat.getParcelableArrayList(
it,
EXTRA_DELETED_IMAGES,
FileAttachment::class.java,
)
}
deletedImages = savedList ?: ArrayList() deletedImages = savedList ?: ArrayList()
val result = Intent() val resultIntent = Intent()
result.putExtra(DELETED_IMAGES, deletedImages) resultIntent.putExtra(EXTRA_DELETED_IMAGES, deletedImages)
setResult(RESULT_OK, result) setResult(RESULT_OK, resultIntent)
val savedImage = savedInstanceState?.getParcelable<FileAttachment>(CURRENT_IMAGE) val savedImage =
savedInstanceState?.let {
BundleCompat.getParcelable(it, CURRENT_IMAGE, FileAttachment::class.java)
}
if (savedImage != null) { if (savedImage != null) {
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(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(Constants.SelectedBaseNote, 0) val id = intent.getLongExtra(EXTRA_SELECTED_BASE_NOTE, 0)
database.observe(this@ViewImageActivity) { database.observe(this@ViewImageActivity) {
lifecycleScope.launch { lifecycleScope.launch {
@ -72,31 +88,31 @@ 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)
} }
} }
exportFileActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri -> writeImageToUri(uri) }
}
}
} }
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
outState.apply { outState.apply {
putParcelable(CURRENT_IMAGE, currentImage) putParcelable(CURRENT_IMAGE, currentImage)
putParcelableArrayList(DELETED_IMAGES, deletedImages) putParcelableArrayList(EXTRA_DELETED_IMAGES, deletedImages)
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_EXPORT_FILE && resultCode == RESULT_OK) {
data?.data?.let { uri -> writeImageToUri(uri) }
} }
} }
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() {
@ -107,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) {
@ -144,21 +160,21 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
val mediaRoot = application.getExternalImagesDirectory() val mediaRoot = application.getExternalImagesDirectory()
val file = if (mediaRoot != null) File(mediaRoot, image.localName) else null val file = if (mediaRoot != null) File(mediaRoot, image.localName) else null
if (file != null && file.exists()) { if (file != null && file.exists()) {
val uri = FileProvider.getUriForFile(this, "$packageName.provider", file) val uri = getUriForFile(file)
val intent = val intent =
Intent(Intent.ACTION_SEND).apply { Intent(Intent.ACTION_SEND)
type = image.mimeType .apply {
putExtra(Intent.EXTRA_STREAM, uri) type = image.mimeType
putExtra(Intent.EXTRA_STREAM, uri)
// Necessary for sharesheet to show a preview of the image // Necessary for sharesheet to show a preview of the image
// Check -> // Check ->
// https://commonsware.com/blog/2021/01/07/action_send-share-sheet-clipdata.html // https://commonsware.com/blog/2021/01/07/action_send-share-sheet-clipdata.html
clipData = ClipData.newRawUri(null, uri) clipData = ClipData.newRawUri(null, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
val chooser = Intent.createChooser(intent, null) .wrapWithChooser(this@ViewImageActivity)
startActivity(chooser) startActivity(intent)
} }
} }
@ -167,13 +183,15 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
val file = if (mediaRoot != null) File(mediaRoot, image.localName) else null val file = if (mediaRoot != null) File(mediaRoot, image.localName) else null
if (file != null && file.exists()) { if (file != null && file.exists()) {
val intent = val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply { Intent(Intent.ACTION_CREATE_DOCUMENT)
type = image.mimeType .apply {
addCategory(Intent.CATEGORY_OPENABLE) type = image.mimeType
putExtra(Intent.EXTRA_TITLE, "NotallyX Image") addCategory(Intent.CATEGORY_OPENABLE)
} putExtra(Intent.EXTRA_TITLE, "NotallyX Image")
}
.wrapWithChooser(this)
currentImage = image currentImage = image
startActivityForResult(intent, REQUEST_EXPORT_FILE) exportFileActivityResultLauncher.launch(intent)
} }
} }
@ -201,7 +219,7 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
private fun delete(position: Int, adapter: ImageAdapter) { private fun delete(position: Int, adapter: ImageAdapter) {
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this)
.setMessage(R.string.delete_image_forever) .setMessage(R.string.delete_image_forever)
.setNegativeButton(R.string.cancel, null) .setCancelButton()
.setPositiveButton(R.string.delete) { _, _ -> .setPositiveButton(R.string.delete) { _, _ ->
val image = adapter.items.removeAt(position) val image = adapter.items.removeAt(position)
deletedImages.add(image) deletedImages.add(image)
@ -214,9 +232,8 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
} }
companion object { companion object {
const val POSITION = "POSITION" const val EXTRA_POSITION = "notallyx.intent.extra.POSITION"
const val CURRENT_IMAGE = "CURRENT_IMAGE" const val CURRENT_IMAGE = "CURRENT_IMAGE"
const val DELETED_IMAGES = "DELETED_IMAGES" const val EXTRA_DELETED_IMAGES = "notallyx.intent.extra.DELETED_IMAGES"
private const val REQUEST_EXPORT_FILE = 40
} }
} }

View file

@ -0,0 +1,24 @@
package com.philkes.notallyx.presentation.activity.note.reminders
import android.app.DatePickerDialog
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import com.philkes.notallyx.utils.now
import java.util.Calendar
import java.util.Date
class DatePickerFragment(
private val date: Date?,
private val listener: DatePickerDialog.OnDateSetListener,
) : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val now = now()
val c = date?.let { Calendar.getInstance().apply { time = it } } ?: now
val year = c.get(Calendar.YEAR)
val month = c.get(Calendar.MONTH)
val day = c.get(Calendar.DAY_OF_MONTH)
return DatePickerDialog(requireContext(), listener, year, month, day)
}
}

View file

@ -0,0 +1,145 @@
package com.philkes.notallyx.presentation.activity.note.reminders
import android.app.AlarmManager
import android.app.Application
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.getSystemService
import com.philkes.notallyx.R
import com.philkes.notallyx.data.NotallyDatabase
import com.philkes.notallyx.data.model.Reminder
import com.philkes.notallyx.utils.canScheduleAlarms
import com.philkes.notallyx.utils.cancelReminder
import com.philkes.notallyx.utils.createChannelIfNotExists
import com.philkes.notallyx.utils.getOpenNotePendingIntent
import com.philkes.notallyx.utils.scheduleReminder
import com.philkes.notallyx.utils.truncate
import java.util.Date
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* [BroadcastReceiver] for sending notifications via [NotificationManager] for [Reminder]s.
* Reschedules reminders on [Intent.ACTION_BOOT_COMPLETED] or if
* [AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED] has changed and exact alarms
* are allowed. For [Reminder] that have [Reminder.repetition] it automatically reschedules the next
* alarm.
*/
class ReminderReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
Log.d(TAG, "onReceive: ${intent?.action}")
if (intent == null || context == null) {
return
}
val canScheduleExactAlarms = context.canScheduleAlarms()
if (intent.action == null) {
if (!canScheduleExactAlarms) {
return
}
val reminderId = intent.getLongExtra(EXTRA_REMINDER_ID, -1L)
val noteId = intent.getLongExtra(EXTRA_NOTE_ID, -1L)
notify(context, noteId, reminderId)
} else {
when {
canScheduleExactAlarms && intent.action == Intent.ACTION_BOOT_COMPLETED ->
rescheduleAlarms(context)
intent.action ==
AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED -> {
if (canScheduleExactAlarms) {
rescheduleAlarms(context)
} else {
cancelAlarms(context)
}
}
}
}
}
private fun notify(context: Context, noteId: Long, reminderId: Long) {
Log.d(TAG, "notify: noteId: $noteId reminderId: $reminderId")
CoroutineScope(Dispatchers.IO).launch {
val database =
NotallyDatabase.getDatabase(context.applicationContext as Application, false).value
val manager = context.getSystemService<NotificationManager>()!!
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
manager.createChannelIfNotExists(
NOTIFICATION_CHANNEL_ID,
importance = NotificationManager.IMPORTANCE_HIGH,
)
}
database.getBaseNoteDao().get(noteId)?.let { note ->
val notification =
NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.notebook)
.setContentTitle(note.title) // Set title from intent
.setContentText(note.body.truncate(200)) // Set content text from intent
.setPriority(NotificationCompat.PRIORITY_HIGH)
.addAction(
R.drawable.visibility,
context.getString(R.string.open_note),
context.getOpenNotePendingIntent(note),
)
.build()
note.reminders
.find { it.id == reminderId }
?.let { reminder: Reminder ->
manager.notify(note.id.toString(), reminderId.toInt(), notification)
context.scheduleReminder(note.id, reminder, forceRepetition = true)
}
}
}
}
private fun rescheduleAlarms(context: Context) {
CoroutineScope(Dispatchers.IO).launch {
val database =
NotallyDatabase.getDatabase(context.applicationContext as Application, false).value
val now = Date()
val noteReminders = database.getBaseNoteDao().getAllReminders()
val noteRemindersWithFutureNotify =
noteReminders.flatMap { (noteId, reminders) ->
reminders
.filter { reminder ->
reminder.repetition != null || reminder.dateTime.after(now)
}
.map { reminder -> Pair(noteId, reminder) }
}
Log.d(TAG, "rescheduleAlarms: ${noteRemindersWithFutureNotify.size} alarms")
noteRemindersWithFutureNotify.forEach { (noteId, reminder) ->
context.scheduleReminder(noteId, reminder)
}
}
}
private fun cancelAlarms(context: Context) {
CoroutineScope(Dispatchers.IO).launch {
val database =
NotallyDatabase.getDatabase(context.applicationContext as Application, false).value
val noteReminders = database.getBaseNoteDao().getAllReminders()
val noteRemindersWithFutureNotify =
noteReminders.flatMap { (noteId, reminders) ->
reminders.map { reminder -> Pair(noteId, reminder.id) }
}
Log.d(TAG, "cancelAlarms: ${noteRemindersWithFutureNotify.size} alarms")
noteRemindersWithFutureNotify.forEach { (noteId, reminderId) ->
context.cancelReminder(noteId, reminderId)
}
}
}
companion object {
private const val TAG = "ReminderReceiver"
private const val NOTIFICATION_CHANNEL_ID = "Reminders"
const val EXTRA_REMINDER_ID = "notallyx.intent.extra.REMINDER_ID"
const val EXTRA_NOTE_ID = "notallyx.intent.extra.NOTE_ID"
}
}

View file

@ -0,0 +1,328 @@
package com.philkes.notallyx.presentation.activity.note.reminders
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.text.Editable
import android.view.View
import android.widget.Button
import android.widget.RadioButton
import android.widget.TimePicker
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.Reminder
import com.philkes.notallyx.data.model.Repetition
import com.philkes.notallyx.data.model.RepetitionTimeUnit
import com.philkes.notallyx.data.model.toCalendar
import com.philkes.notallyx.data.model.toText
import com.philkes.notallyx.databinding.ActivityRemindersBinding
import com.philkes.notallyx.databinding.DialogReminderCustomRepetitionBinding
import com.philkes.notallyx.databinding.DialogReminderRepetitionBinding
import com.philkes.notallyx.presentation.activity.LockedActivity
import com.philkes.notallyx.presentation.add
import com.philkes.notallyx.presentation.checkAlarmPermission
import com.philkes.notallyx.presentation.checkNotificationPermission
import com.philkes.notallyx.presentation.initListView
import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.showAndFocus
import com.philkes.notallyx.presentation.view.main.reminder.ReminderAdapter
import com.philkes.notallyx.presentation.view.main.reminder.ReminderListener
import com.philkes.notallyx.presentation.viewmodel.NotallyModel
import com.philkes.notallyx.utils.canScheduleAlarms
import com.philkes.notallyx.utils.now
import java.util.Calendar
import kotlinx.coroutines.launch
class RemindersActivity : LockedActivity<ActivityRemindersBinding>(), ReminderListener {
private lateinit var alarmPermissionActivityResultLauncher: ActivityResultLauncher<Intent>
private val model: NotallyModel by viewModels()
private lateinit var reminderAdapter: ReminderAdapter
private var selectedReminder: Reminder? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityRemindersBinding.inflate(layoutInflater)
setContentView(binding.root)
setupToolbar()
setupRecyclerView()
alarmPermissionActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (canScheduleAlarms()) {
showDatePickerDialog(selectedReminder)
}
}
val noteId = intent.getLongExtra(NOTE_ID, 0L)
lifecycleScope.launch {
model.setState(noteId)
if (model.reminders.value.isEmpty()) {
showDatePickerDialog()
} else if (!canScheduleAlarms()) {
checkNotificationPermission(
REQUEST_NOTIFICATION_PERMISSION_REQUEST_CODE,
alsoCheckAlarmPermission = true,
) {}
}
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
REQUEST_NOTIFICATION_PERMISSION_ON_OPEN_REQUEST_CODE -> {
if (
grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED
) {
checkAlarmPermission(alarmPermissionActivityResultLauncher) {
showDatePickerDialog(selectedReminder)
}
}
}
}
}
private fun setupToolbar() {
binding.Toolbar.apply {
setNavigationOnClickListener { finish() }
menu.add(R.string.add_reminder, R.drawable.add) { showDatePickerDialog() }
}
}
private fun setupRecyclerView() {
reminderAdapter = ReminderAdapter(this)
binding.MainListView.apply {
initListView(this@RemindersActivity)
adapter = reminderAdapter
}
model.reminders.observe(this) { reminders ->
reminderAdapter.submitList(reminders)
if (reminders.isEmpty()) {
binding.EmptyState.visibility = View.VISIBLE
} else binding.EmptyState.visibility = View.INVISIBLE
}
}
private fun showDatePickerDialog(reminder: Reminder? = null, calendar: Calendar? = null) {
selectedReminder = reminder
checkNotificationPermission(
REQUEST_NOTIFICATION_PERMISSION_ON_OPEN_REQUEST_CODE,
alsoCheckAlarmPermission = true,
alarmPermissionResultLauncher = alarmPermissionActivityResultLauncher,
) {
DatePickerFragment(calendar?.time ?: reminder?.dateTime) { _, year, month, day ->
val usedCalendar = calendar ?: reminder?.dateTime?.toCalendar() ?: now()
usedCalendar.set(year, month, day)
showTimePickerDialog(reminder, usedCalendar)
}
.show(supportFragmentManager, "reminderDatePicker")
}
}
private fun showTimePickerDialog(reminder: Reminder? = null, calendar: Calendar) {
TimePickerFragment(
calendar,
object : TimePickerListener {
override fun onBack() {
showDatePickerDialog(reminder, calendar)
}
override fun onTimeSet(view: TimePicker?, hourOfDay: Int, minute: Int) {
calendar.set(Calendar.HOUR_OF_DAY, hourOfDay)
calendar.set(Calendar.MINUTE, minute)
showRepetitionDialog(reminder, calendar) { updatedRepetition ->
val updatedReminder =
Reminder(
reminder?.id ?: NEW_REMINDER_ID,
calendar.time,
updatedRepetition,
)
if (reminder != null) {
lifecycleScope.launch { model.updateReminder(updatedReminder) }
} else {
lifecycleScope.launch { model.addReminder(updatedReminder) }
}
}
}
},
)
.show(supportFragmentManager, "reminderTimePicker")
}
private fun showRepetitionDialog(
reminder: Reminder? = null,
calendar: Calendar,
fromCustomRepetitionDialog: Boolean = false,
onRepetitionSelected: (Repetition?) -> Unit,
) {
val dialogView =
DialogReminderRepetitionBinding.inflate(layoutInflater).apply {
if (reminder == null && fromCustomRepetitionDialog) {
None.isChecked = true
} else {
reminder?.repetition.apply {
when {
this == null -> None.isChecked = true
value == 1 && unit == RepetitionTimeUnit.DAYS -> Daily.isChecked = true
value == 1 && unit == RepetitionTimeUnit.WEEKS ->
Weekly.isChecked = true
value == 1 && unit == RepetitionTimeUnit.MONTHS ->
Monthly.isChecked = true
value == 1 && unit == RepetitionTimeUnit.YEARS ->
Yearly.isChecked = true
fromCustomRepetitionDialog -> Custom.isChecked = true
else -> {
showCustomRepetitionDialog(reminder, calendar, onRepetitionSelected)
return
}
}
}
}
}
val dialog =
MaterialAlertDialogBuilder(this)
.setTitle(R.string.repetition)
.setView(dialogView.root)
.setPositiveButton(R.string.save) { _, _ ->
val repetition =
when (dialogView.RepetitionOptions.checkedRadioButtonId) {
R.id.None -> null
R.id.Daily -> Repetition(1, RepetitionTimeUnit.DAYS)
R.id.Weekly -> Repetition(1, RepetitionTimeUnit.WEEKS)
R.id.Monthly -> Repetition(1, RepetitionTimeUnit.MONTHS)
R.id.Yearly -> Repetition(1, RepetitionTimeUnit.YEARS)
R.id.Custom -> reminder?.repetition?.copy()
else -> null
}
onRepetitionSelected(repetition)
}
.setNegativeButton(R.string.back) { _, _ ->
showTimePickerDialog(reminder, calendar)
}
.show()
val positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
dialogView.apply {
Custom.setOnClickListener {
dialog.dismiss()
showCustomRepetitionDialog(reminder, calendar, onRepetitionSelected)
}
None.setOnCheckedEnableButton(positiveButton)
Daily.setOnCheckedEnableButton(positiveButton)
Weekly.setOnCheckedEnableButton(positiveButton)
Monthly.setOnCheckedEnableButton(positiveButton)
Yearly.setOnCheckedEnableButton(positiveButton)
}
}
private fun showCustomRepetitionDialog(
reminder: Reminder? = null,
calendar: Calendar,
onRepetitionSelected: (Repetition?) -> Unit,
) {
val dialogView =
DialogReminderCustomRepetitionBinding.inflate(layoutInflater).apply {
reminder?.repetition?.let {
when (it.unit) {
RepetitionTimeUnit.MINUTES -> Minutes
RepetitionTimeUnit.HOURS -> Hours
RepetitionTimeUnit.DAYS -> Days
RepetitionTimeUnit.WEEKS -> Weeks
RepetitionTimeUnit.MONTHS -> Months
RepetitionTimeUnit.YEARS -> Years
}.isChecked = true
Value.setText(it.value.toString())
}
}
val dialog =
MaterialAlertDialogBuilder(this)
.setTitle(R.string.repetition_custom)
.setView(dialogView.root)
.setPositiveButton(R.string.save) { _, _ ->
val value = dialogView.Value.text.toString().toIntOrNull() ?: 1
val selectedTimeUnit =
when (dialogView.TimeUnitGroup.checkedRadioButtonId) {
R.id.Minutes -> RepetitionTimeUnit.MINUTES
R.id.Hours -> RepetitionTimeUnit.HOURS
R.id.Days -> RepetitionTimeUnit.DAYS
R.id.Weeks -> RepetitionTimeUnit.WEEKS
R.id.Months -> RepetitionTimeUnit.MONTHS
R.id.Years -> RepetitionTimeUnit.YEARS
else -> null
}
onRepetitionSelected(selectedTimeUnit?.let { Repetition(value, it) })
}
.setBackgroundInsetBottom(0)
.setBackgroundInsetTop(0)
.setNegativeButton(R.string.back) { dialog, _ ->
dialog.dismiss()
showRepetitionDialog(
reminder,
calendar,
fromCustomRepetitionDialog = true,
onRepetitionSelected,
)
}
.showAndFocus(dialogView.Value, allowFullSize = true)
val positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
dialogView.Value.doAfterTextChanged { text ->
positiveButton.isEnabled = text.hasValueBiggerZero()
}
positiveButton.isEnabled = reminder?.repetition != null
}
private fun RadioButton.setOnCheckedEnableButton(button: Button) {
setOnCheckedChangeListener { _, isChecked ->
if (isChecked) {
button.isEnabled = true
}
}
}
private fun Editable?.hasValueBiggerZero() =
(!isNullOrEmpty() && toString().toIntOrNull()?.let { it > 0 } ?: false)
private fun confirmDeletion(reminder: Reminder) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.delete_reminder)
.setMessage(
"${reminder.dateTime.toText()}\n${reminder.repetition?.toText(this) ?: getString(R.string.reminder_no_repetition)}"
)
.setPositiveButton(R.string.delete) { _, _ ->
lifecycleScope.launch { model.removeReminder(reminder) }
}
.setCancelButton()
.show()
}
override fun delete(reminder: Reminder) {
confirmDeletion(reminder)
}
override fun edit(reminder: Reminder) {
showDatePickerDialog(reminder)
}
companion object {
const val NOTE_ID = "NOTE_ID"
const val REQUEST_NOTIFICATION_PERMISSION_ON_OPEN_REQUEST_CODE = 101
const val REQUEST_NOTIFICATION_PERMISSION_REQUEST_CODE = 102
const val NEW_REMINDER_ID = -1L
}
}

View file

@ -0,0 +1,32 @@
package com.philkes.notallyx.presentation.activity.note.reminders
import android.app.Dialog
import android.app.TimePickerDialog
import android.content.DialogInterface
import android.os.Bundle
import android.text.format.DateFormat
import androidx.fragment.app.DialogFragment
import com.philkes.notallyx.R
import java.util.Calendar
class TimePickerFragment(private val calendar: Calendar, private val listener: TimePickerListener) :
DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val hour = calendar.get(Calendar.HOUR_OF_DAY)
val minute = calendar.get(Calendar.MINUTE)
val dialog =
TimePickerDialog(activity, listener, hour, minute, DateFormat.is24HourFormat(activity))
dialog.setButton(
DialogInterface.BUTTON_NEGATIVE,
requireContext().getText(R.string.back),
) { _, _ ->
listener.onBack()
}
return dialog
}
}
interface TimePickerListener : TimePickerDialog.OnTimeSetListener {
fun onBack()
}

View file

@ -1,6 +0,0 @@
package com.philkes.notallyx.presentation.view
object Constants {
const val SelectedLabel = "SelectedLabel"
const val SelectedBaseNote = "SelectedBaseNote"
}

View file

@ -10,29 +10,26 @@ 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
import com.philkes.notallyx.presentation.view.misc.NotesSorting.autoSortByModifiedDate import com.philkes.notallyx.presentation.view.misc.ItemListener
import com.philkes.notallyx.presentation.view.misc.NotesSorting.autoSortByTitle import com.philkes.notallyx.presentation.viewmodel.preference.DateFormat
import com.philkes.notallyx.presentation.view.misc.SortDirection import com.philkes.notallyx.presentation.viewmodel.preference.NotesSort
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortBy
import java.io.File import java.io.File
class BaseNoteAdapter( class BaseNoteAdapter(
private val selectedIds: Set<Long>, private val selectedIds: Set<Long>,
private val dateFormat: String, private val dateFormat: DateFormat,
private val sortedBy: String, private var notesSort: NotesSort,
private val textSize: String, private val preferences: BaseNoteVHPreferences,
private val maxItems: Int,
private val maxLines: Int,
private val maxTitle: Int,
private val imageRoot: File?, private val imageRoot: File?,
private val listener: ListItemListener, private val listener: ItemListener,
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var list = private var list = SortedList(Item::class.java, notesSort.createCallback())
SortedList(Item::class.java, BaseNoteCreationDateSort(this, SortDirection.ASC))
override fun getItemViewType(position: Int): Int { override fun getItemViewType(position: Int): Int {
return when (list[position]) { return when (list[position]) {
@ -53,7 +50,7 @@ class BaseNoteAdapter(
item, item,
imageRoot, imageRoot,
selectedIds.contains(item.id), selectedIds.contains(item.id),
sortedBy, notesSort.sortedBy,
) )
} }
} }
@ -77,19 +74,14 @@ class BaseNoteAdapter(
} }
else -> { else -> {
val binding = RecyclerBaseNoteBinding.inflate(inflater, parent, false) val binding = RecyclerBaseNoteBinding.inflate(inflater, parent, false)
BaseNoteVH(binding, dateFormat, textSize, maxItems, maxLines, maxTitle, listener) BaseNoteVH(binding, dateFormat, preferences, listener)
} }
} }
} }
fun setSorting(sortBy: String, sortDirection: SortDirection) { fun setNotesSort(notesSort: NotesSort) {
val sortCallback = this.notesSort = notesSort
when (sortBy) { replaceSortCallback(notesSort.createCallback())
autoSortByTitle -> BaseNoteTitleSort(this, sortDirection)
autoSortByModifiedDate -> BaseNoteModifiedDateSort(this, sortDirection)
else -> BaseNoteCreationDateSort(this, sortDirection)
}
replaceSorting(sortCallback)
} }
fun getItem(position: Int): Item? { fun getItem(position: Int): Item? {
@ -103,7 +95,17 @@ class BaseNoteAdapter(
list.replaceAll(items) list.replaceAll(items)
} }
private fun replaceSorting(sortCallback: SortedListAdapterCallback<Item>) { private fun NotesSort.createCallback() =
when (sortedBy) {
NotesSortBy.TITLE -> BaseNoteTitleSort(this@BaseNoteAdapter, sortDirection)
NotesSortBy.MODIFIED_DATE ->
BaseNoteModifiedDateSort(this@BaseNoteAdapter, sortDirection)
NotesSortBy.CREATION_DATE ->
BaseNoteCreationDateSort(this@BaseNoteAdapter, sortDirection)
NotesSortBy.COLOR -> BaseNoteColorSort(this@BaseNoteAdapter, sortDirection)
}
private fun replaceSortCallback(sortCallback: SortedListAdapterCallback<Item>) {
val mutableList = mutableListOf<Item>() val mutableList = mutableListOf<Item>()
for (i in 0 until list.size()) { for (i in 0 until list.size()) {
mutableList.add(list[i]) mutableList.add(list[i])
@ -115,7 +117,7 @@ class BaseNoteAdapter(
private fun handleCheck(holder: RecyclerView.ViewHolder, position: Int) { private fun handleCheck(holder: RecyclerView.ViewHolder, position: Int) {
val baseNote = list[position] as BaseNote val baseNote = list[position] as BaseNote
(holder as BaseNoteVH).updateCheck(selectedIds.contains(baseNote.id)) (holder as BaseNoteVH).updateCheck(selectedIds.contains(baseNote.id), baseNote.color)
} }
private fun <T> SortedList<T>.toList(): List<T> { private fun <T> SortedList<T>.toList(): List<T> {

View file

@ -2,12 +2,14 @@ package com.philkes.notallyx.presentation.view.main
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.util.TypedValue import android.util.TypedValue
import android.view.View import android.view.View.GONE
import android.view.View.VISIBLE
import android.widget.LinearLayout import android.widget.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.updatePadding
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.DataSource
@ -18,36 +20,44 @@ import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target import com.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
import com.philkes.notallyx.data.model.Type import com.philkes.notallyx.data.model.Type
import com.philkes.notallyx.data.model.hasUpcomingNotification
import com.philkes.notallyx.databinding.RecyclerBaseNoteBinding import com.philkes.notallyx.databinding.RecyclerBaseNoteBinding
import com.philkes.notallyx.presentation.applySpans import com.philkes.notallyx.presentation.applySpans
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.getQuantityString import com.philkes.notallyx.presentation.getQuantityString
import com.philkes.notallyx.presentation.view.misc.NotesSorting.autoSortByCreationDate import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews
import com.philkes.notallyx.presentation.view.misc.NotesSorting.autoSortByModifiedDate import com.philkes.notallyx.presentation.view.misc.ItemListener
import com.philkes.notallyx.presentation.view.misc.TextSize import com.philkes.notallyx.presentation.viewmodel.preference.DateFormat
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortBy
import com.philkes.notallyx.utils.Operations import com.philkes.notallyx.presentation.viewmodel.preference.TextSize
import java.io.File import java.io.File
data class BaseNoteVHPreferences(
val textSize: TextSize,
val maxItems: Int,
val maxLines: Int,
val maxTitleLines: Int,
val hideLabels: Boolean,
val hideImages: Boolean,
)
class BaseNoteVH( class BaseNoteVH(
private val binding: RecyclerBaseNoteBinding, private val binding: RecyclerBaseNoteBinding,
private val dateFormat: String, private val dateFormat: DateFormat,
private val textSize: String, private val preferences: BaseNoteVHPreferences,
private val maxItems: Int, listener: ItemListener,
maxLines: Int,
maxTitle: Int,
listener: ListItemListener,
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
init { init {
val title = TextSize.getDisplayTitleSize(textSize) val title = preferences.textSize.displayTitleSize
val body = TextSize.getDisplayBodySize(textSize) val body = preferences.textSize.displayBodySize
binding.apply { binding.apply {
Title.setTextSize(TypedValue.COMPLEX_UNIT_SP, title) Title.setTextSize(TypedValue.COMPLEX_UNIT_SP, title)
@ -59,73 +69,107 @@ class BaseNoteVH(
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, body) view.setTextSize(TypedValue.COMPLEX_UNIT_SP, body)
} }
Title.maxLines = maxTitle Title.maxLines = preferences.maxTitleLines
Note.maxLines = maxLines Note.maxLines = preferences.maxLines
root.setOnClickListener { listener.onClick(adapterPosition) } root.setOnClickListener { listener.onClick(absoluteAdapterPosition) }
root.setOnLongClickListener { root.setOnLongClickListener {
listener.onLongClick(adapterPosition) listener.onLongClick(absoluteAdapterPosition)
return@setOnLongClickListener true return@setOnLongClickListener true
} }
} }
} }
fun updateCheck(checked: Boolean) { fun updateCheck(checked: Boolean, color: String) {
if (checked) {
binding.root.strokeWidth = 3.dp
} else {
binding.root.strokeWidth = if (color == BaseNote.COLOR_DEFAULT) 1.dp else 0
}
binding.root.isChecked = checked binding.root.isChecked = checked
} }
fun bind(baseNote: BaseNote, imageRoot: File?, checked: Boolean, sortBy: String) { fun bind(baseNote: BaseNote, imageRoot: File?, checked: Boolean, sortBy: NotesSortBy) {
updateCheck(checked) updateCheck(checked, baseNote.color)
when (baseNote.type) { when (baseNote.type) {
Type.NOTE -> bindNote(baseNote.body, baseNote.spans) 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) {
autoSortByCreationDate -> Pair(baseNote.timestamp, R.string.creation_date) NotesSortBy.CREATION_DATE -> Pair(baseNote.timestamp, R.string.creation_date)
autoSortByModifiedDate -> Pair(baseNote.modifiedTimestamp, R.string.modified_date) NotesSortBy.MODIFIED_DATE ->
Pair(baseNote.modifiedTimestamp, R.string.modified_date)
else -> Pair(null, null) else -> Pair(null, null)
} }
binding.Date.displayFormattedTimestamp(date, dateFormat, datePrefixResId) binding.Date.displayFormattedTimestamp(date, dateFormat, datePrefixResId)
setColor(baseNote.color)
setImages(baseNote.images, imageRoot) setImages(baseNote.images, imageRoot)
setFiles(baseNote.files) setFiles(baseNote.files)
binding.Title.apply { binding.Title.apply {
text = baseNote.title text = baseNote.title
isVisible = baseNote.title.isNotEmpty() isVisible = baseNote.title.isNotEmpty()
updatePadding(
bottom =
if (baseNote.hasNoContents() || shouldOnlyDisplayTitle(baseNote)) 0 else 8.dp
)
setCompoundDrawablesWithIntrinsicBounds(
if (baseNote.type == Type.LIST && preferences.maxItems < 1)
R.drawable.checkbox_small
else 0,
0,
0,
0,
)
} }
Operations.bindLabels(binding.LabelGroup, baseNote.labels, textSize) if (preferences.hideLabels) {
binding.LabelGroup.visibility = GONE
} else {
binding.LabelGroup.bindLabels(
baseNote.labels,
preferences.textSize,
binding.Note.isVisible || binding.Title.isVisible,
)
}
if (isEmpty(baseNote)) { if (baseNote.isEmpty()) {
binding.Title.apply { binding.Title.apply {
setText(getEmptyMessage(baseNote)) setText(baseNote.getEmptyMessage())
visibility = View.VISIBLE isVisible = true
}
}
setColor(baseNote.color)
binding.RemindersView.isVisible = baseNote.reminders.any { it.hasUpcomingNotification() }
}
private fun bindNote(body: String, spans: List<SpanRepresentation>, isTitleEmpty: Boolean) {
binding.LinearLayout.visibility = GONE
binding.Note.apply {
text = body.applySpans(spans)
if (preferences.maxLines < 1) {
isVisible = isTitleEmpty
maxLines = if (isTitleEmpty) 1 else preferences.maxLines
} else {
isVisible = body.isNotEmpty()
} }
} }
} }
private fun bindNote(body: String, spans: List<SpanRepresentation>) { private fun bindList(items: List<ListItem>, isTitleEmpty: Boolean) {
binding.LinearLayout.visibility = View.GONE
binding.Note.apply {
text = body.applySpans(spans)
isVisible = body.isNotEmpty()
}
}
private fun bindList(items: List<ListItem>) {
binding.apply { binding.apply {
Note.visibility = View.GONE Note.visibility = GONE
if (items.isEmpty()) { if (items.isEmpty()) {
LinearLayout.visibility = View.GONE LinearLayout.visibility = GONE
} else { } else {
LinearLayout.visibility = View.VISIBLE LinearLayout.visibility = VISIBLE
val filteredList = items.take(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) {
@ -133,47 +177,43 @@ class BaseNoteVH(
(view as TextView).apply { (view as TextView).apply {
text = item.body text = item.body
handleChecked(this, item.checked) handleChecked(this, item.checked)
visibility = View.VISIBLE visibility = VISIBLE
if (item.isChild) { if (item.isChild) {
val layoutParams = layoutParams as LinearLayout.LayoutParams updateLayoutParams<LinearLayout.LayoutParams> {
layoutParams.marginStart = 20.dp(context) marginStart = 20.dp
setLayoutParams(layoutParams) }
}
if (index == filteredList.lastIndex) {
updatePadding(bottom = 0)
} }
} }
} else view.visibility = View.GONE } else view.visibility = GONE
} }
} }
if (items.size > maxItems) { if (preferences.maxItems > 0 && items.size > preferences.maxItems) {
ItemsRemaining.apply { ItemsRemaining.apply {
visibility = View.VISIBLE visibility = VISIBLE
text = (items.size - maxItems).toString() text = (items.size - preferences.maxItems).toString()
} }
} else ItemsRemaining.visibility = View.GONE } else ItemsRemaining.visibility = GONE
} }
} }
} }
private fun setColor(color: Color) { private fun setColor(color: String) {
binding.root.apply { 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)
} else {
strokeColor = 0
val colorInt = Operations.extractColor(color, context)
setCardBackgroundColor(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 = View.VISIBLE ImageView.visibility = VISIBLE
Message.visibility = View.GONE Message.visibility = GONE
val image = images[0] val image = images[0]
val file = if (mediaRoot != null) File(mediaRoot, image.localName) else null val file = if (mediaRoot != null) File(mediaRoot, image.localName) else null
@ -192,7 +232,7 @@ class BaseNoteVH(
target: Target<Drawable>?, target: Target<Drawable>?,
isFirstResource: Boolean, isFirstResource: Boolean,
): Boolean { ): Boolean {
Message.visibility = View.VISIBLE Message.visibility = VISIBLE
return false return false
} }
@ -211,15 +251,15 @@ class BaseNoteVH(
if (images.size > 1) { if (images.size > 1) {
ImageViewMore.apply { ImageViewMore.apply {
text = images.size.toString() text = images.size.toString()
visibility = View.VISIBLE visibility = VISIBLE
} }
} else { } else {
ImageViewMore.visibility = View.GONE ImageViewMore.visibility = GONE
} }
} else { } else {
ImageView.visibility = View.GONE ImageView.visibility = GONE
Message.visibility = View.GONE Message.visibility = GONE
ImageViewMore.visibility = View.GONE ImageViewMore.visibility = GONE
Glide.with(ImageView).clear(ImageView) Glide.with(ImageView).clear(ImageView)
} }
} }
@ -228,37 +268,37 @@ class BaseNoteVH(
private fun setFiles(files: List<FileAttachment>) { private fun setFiles(files: List<FileAttachment>) {
binding.apply { binding.apply {
if (files.isNotEmpty()) { if (files.isNotEmpty()) {
FileViewLayout.visibility = View.VISIBLE FileViewLayout.visibility = VISIBLE
FileView.text = files[0].originalName FileView.text = files[0].originalName
if (files.size > 1) { if (files.size > 1) {
FileViewMore.apply { FileViewMore.apply {
text = getQuantityString(R.plurals.more_files, files.size - 1) text = getQuantityString(R.plurals.more_files, files.size - 1)
visibility = View.VISIBLE visibility = VISIBLE
} }
} else { } else {
FileViewMore.visibility = View.GONE FileViewMore.visibility = GONE
} }
} else { } else {
FileViewLayout.visibility = View.GONE FileViewLayout.visibility = GONE
} }
} }
} }
private fun isEmpty(baseNote: BaseNote): Boolean { private fun shouldOnlyDisplayTitle(baseNote: BaseNote) =
return with(baseNote) { when (baseNote.type) {
when (type) { Type.NOTE -> preferences.maxLines < 1
Type.NOTE -> title.isBlank() && body.isBlank() && images.isEmpty() Type.LIST -> preferences.maxItems < 1
Type.LIST -> title.isBlank() && items.isEmpty() && images.isEmpty()
}
} }
}
private fun getEmptyMessage(baseNote: BaseNote): Int { private fun BaseNote.isEmpty() = title.isBlank() && hasNoContents() && images.isEmpty()
return when (baseNote.type) {
private fun BaseNote.hasNoContents() = body.isEmpty() && items.isEmpty()
private fun BaseNote.getEmptyMessage() =
when (type) {
Type.NOTE -> R.string.empty_note Type.NOTE -> R.string.empty_note
Type.LIST -> R.string.empty_list Type.LIST -> R.string.empty_list
} }
}
private fun handleChecked(textView: TextView, checked: Boolean) { private fun handleChecked(textView: TextView, checked: Boolean) {
if (checked) { if (checked) {

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.note.listitem.ListItemListener import com.philkes.notallyx.presentation.view.misc.ItemListener
class ColorAdapter(private val listener: ListItemListener) : RecyclerView.Adapter<ColorVH>() { class ColorAdapter(
private val colors: List<String>,
private val colors = Color.values() 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,20 +1,56 @@
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.view.note.listitem.ListItemListener import com.philkes.notallyx.presentation.dp
import com.philkes.notallyx.utils.Operations import com.philkes.notallyx.presentation.extractColor
import com.philkes.notallyx.presentation.getColorFromAttr
import com.philkes.notallyx.presentation.getContrastFontColor
import com.philkes.notallyx.presentation.view.misc.ItemListener
class ColorVH(private val binding: RecyclerColorBinding, listener: ListItemListener) : class ColorVH(private val binding: RecyclerColorBinding, listener: ItemListener) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
init { init {
binding.CardView.setOnClickListener { listener.onClick(adapterPosition) } 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 = Operations.extractColor(color, binding.root.context) val showAddIcon = color == BaseNote.COLOR_NEW
binding.CardView.setCardBackgroundColor(value) val context = binding.root.context
val value =
if (showAddIcon) context.getColorFromAttr(R.attr.colorOnSurface)
else context.extractColor(color)
val controlsColor = context.getContrastFontColor(value)
binding.apply {
CardView.apply {
setCardBackgroundColor(value)
contentDescription = color
if (isSelected) {
strokeWidth = 4.dp
strokeColor = controlsColor
} else {
strokeWidth = 1.dp
strokeColor = controlsColor
}
}
CardIcon.apply {
if (showAddIcon) {
setImageResource(R.drawable.add)
} else if (isSelected) {
setImageResource(R.drawable.checked_circle)
}
imageTintList = ColorStateList.valueOf(controlsColor)
isVisible = showAddIcon || isSelected
}
}
} }
} }

View file

@ -1,23 +0,0 @@
package com.philkes.notallyx.presentation.view.main
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.ListAdapter
import com.philkes.notallyx.databinding.RecyclerLabelBinding
import com.philkes.notallyx.presentation.view.misc.StringDiffCallback
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener
class LabelAdapter(private val listener: ListItemListener) :
ListAdapter<String, LabelVH>(StringDiffCallback()) {
override fun onBindViewHolder(holder: LabelVH, position: Int) {
val label = getItem(position)
holder.bind(label)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LabelVH {
val inflater = LayoutInflater.from(parent.context)
val binding = RecyclerLabelBinding.inflate(inflater, parent, false)
return LabelVH(binding, listener)
}
}

View file

@ -1,23 +0,0 @@
package com.philkes.notallyx.presentation.view.main
import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.databinding.RecyclerLabelBinding
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener
class LabelVH(private val binding: RecyclerLabelBinding, listener: ListItemListener) :
RecyclerView.ViewHolder(binding.root) {
init {
binding.root.apply {
setOnClickListener { listener.onClick(adapterPosition) }
setOnLongClickListener {
listener.onLongClick(adapterPosition)
return@setOnLongClickListener true
}
}
}
fun bind(value: String) {
binding.root.text = value
}
}

View file

@ -0,0 +1,35 @@
package com.philkes.notallyx.presentation.view.main.label
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import com.philkes.notallyx.databinding.RecyclerLabelBinding
class LabelAdapter(private val listener: LabelListener) :
ListAdapter<LabelData, LabelVH>(DiffCallback) {
override fun onBindViewHolder(holder: LabelVH, position: Int) {
val label = getItem(position)
holder.bind(label)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LabelVH {
val inflater = LayoutInflater.from(parent.context)
val binding = RecyclerLabelBinding.inflate(inflater, parent, false)
return LabelVH(binding, listener)
}
}
data class LabelData(val label: String, var visibleInNavigation: Boolean)
private object DiffCallback : DiffUtil.ItemCallback<LabelData>() {
override fun areItemsTheSame(oldItem: LabelData, newItem: LabelData): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: LabelData, newItem: LabelData): Boolean {
return oldItem == newItem
}
}

View file

@ -0,0 +1,12 @@
package com.philkes.notallyx.presentation.view.main.label
interface LabelListener {
fun onClick(position: Int)
fun onEdit(position: Int)
fun onDelete(position: Int)
fun onToggleVisibility(position: Int)
}

View file

@ -0,0 +1,27 @@
package com.philkes.notallyx.presentation.view.main.label
import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.R
import com.philkes.notallyx.databinding.RecyclerLabelBinding
class LabelVH(private val binding: RecyclerLabelBinding, listener: LabelListener) :
RecyclerView.ViewHolder(binding.root) {
init {
binding.apply {
LabelText.setOnClickListener { listener.onClick(absoluteAdapterPosition) }
EditButton.setOnClickListener { listener.onEdit(absoluteAdapterPosition) }
DeleteButton.setOnClickListener { listener.onDelete(absoluteAdapterPosition) }
VisibilityButton.setOnClickListener {
listener.onToggleVisibility(absoluteAdapterPosition)
}
}
}
fun bind(value: LabelData) {
binding.LabelText.text = value.label
binding.VisibilityButton.setImageResource(
if (value.visibleInNavigation) R.drawable.visibility else R.drawable.visibility_off
)
}
}

View file

@ -1,4 +1,4 @@
package com.philkes.notallyx.presentation.view.main package com.philkes.notallyx.presentation.view.main.label
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup

View file

@ -1,4 +1,4 @@
package com.philkes.notallyx.presentation.view.main package com.philkes.notallyx.presentation.view.main.label
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.databinding.RecyclerSelectableLabelBinding import com.philkes.notallyx.databinding.RecyclerSelectableLabelBinding
@ -10,7 +10,7 @@ class SelectableLabelVH(
init { init {
binding.root.setOnCheckedChangeListener { _, isChecked -> binding.root.setOnCheckedChangeListener { _, isChecked ->
onChecked(adapterPosition, isChecked) onChecked(absoluteAdapterPosition, isChecked)
} }
} }

View file

@ -0,0 +1,40 @@
package com.philkes.notallyx.presentation.view.main.reminder
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import com.philkes.notallyx.data.dao.NoteReminder
import com.philkes.notallyx.databinding.RecyclerNoteReminderBinding
class NoteReminderAdapter(private val listener: NoteReminderListener) :
ListAdapter<NoteReminder, NoteReminderVH>(NoteReminderDiffCallback()) {
override fun onBindViewHolder(holder: NoteReminderVH, position: Int) {
val reminder = getItem(position)
holder.bind(reminder)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NoteReminderVH {
val inflater = LayoutInflater.from(parent.context)
val binding = RecyclerNoteReminderBinding.inflate(inflater, parent, false)
return NoteReminderVH(binding, listener)
}
}
interface NoteReminderListener {
fun openReminder(reminder: NoteReminder)
fun openNote(reminder: NoteReminder)
}
class NoteReminderDiffCallback : DiffUtil.ItemCallback<NoteReminder>() {
override fun areItemsTheSame(oldItem: NoteReminder, newItem: NoteReminder): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: NoteReminder, newItem: NoteReminder): Boolean {
return oldItem == newItem
}
}

View file

@ -0,0 +1,29 @@
package com.philkes.notallyx.presentation.view.main.reminder
import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.R
import com.philkes.notallyx.data.dao.NoteReminder
import com.philkes.notallyx.data.model.findNextNotificationDate
import com.philkes.notallyx.data.model.toText
import com.philkes.notallyx.databinding.RecyclerNoteReminderBinding
class NoteReminderVH(
private val binding: RecyclerNoteReminderBinding,
private val listener: NoteReminderListener,
) : RecyclerView.ViewHolder(binding.root) {
fun bind(value: NoteReminder) {
binding.apply {
Layout.setOnClickListener { listener.openReminder(value) }
val context = itemView.context
NoteTitle.text = value.title.ifEmpty { context.getText(R.string.empty_note) }
val nextNotificationDate = value.reminders.findNextNotificationDate()
NextNotification.text =
nextNotificationDate?.let {
"${context.getText(R.string.next)}: ${nextNotificationDate.toText()}"
} ?: context.getString(R.string.elapsed)
Reminders.text = value.reminders.size.toString()
OpenNote.setOnClickListener { listener.openNote(value) }
}
}
}

View file

@ -0,0 +1,40 @@
package com.philkes.notallyx.presentation.view.main.reminder
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import com.philkes.notallyx.data.model.Reminder
import com.philkes.notallyx.databinding.RecyclerReminderBinding
class ReminderAdapter(private val listener: ReminderListener) :
ListAdapter<Reminder, ReminderVH>(ReminderDiffCallback()) {
override fun onBindViewHolder(holder: ReminderVH, position: Int) {
val reminder = getItem(position)
holder.bind(reminder)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ReminderVH {
val inflater = LayoutInflater.from(parent.context)
val binding = RecyclerReminderBinding.inflate(inflater, parent, false)
return ReminderVH(binding, listener)
}
}
interface ReminderListener {
fun delete(reminder: Reminder)
fun edit(reminder: Reminder)
}
class ReminderDiffCallback : DiffUtil.ItemCallback<Reminder>() {
override fun areItemsTheSame(oldItem: Reminder, newItem: Reminder): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Reminder, newItem: Reminder): Boolean {
return oldItem == newItem
}
}

View file

@ -0,0 +1,24 @@
package com.philkes.notallyx.presentation.view.main.reminder
import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.Reminder
import com.philkes.notallyx.data.model.toText
import com.philkes.notallyx.databinding.RecyclerReminderBinding
class ReminderVH(
private val binding: RecyclerReminderBinding,
private val listener: ReminderListener,
) : RecyclerView.ViewHolder(binding.root) {
fun bind(value: Reminder) {
binding.apply {
DateTime.text = value.dateTime.toText()
Repetition.text =
value.repetition?.toText(itemView.context)
?: itemView.context.getText(R.string.reminder_no_repetition)
EditButton.setOnClickListener { listener.edit(value) }
DeleteButton.setOnClickListener { listener.delete(value) }
}
}
}

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

@ -2,13 +2,15 @@ package com.philkes.notallyx.presentation.view.main.sorting
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.data.model.BaseNote import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.presentation.view.misc.SortDirection import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection
class BaseNoteCreationDateSort(adapter: RecyclerView.Adapter<*>?, sortDirection: SortDirection) : 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

@ -2,13 +2,15 @@ package com.philkes.notallyx.presentation.view.main.sorting
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.data.model.BaseNote import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.presentation.view.misc.SortDirection import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection
class BaseNoteModifiedDateSort(adapter: RecyclerView.Adapter<*>?, sortDirection: SortDirection) : 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

@ -2,13 +2,15 @@ package com.philkes.notallyx.presentation.view.main.sorting
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.data.model.BaseNote import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.presentation.view.misc.SortDirection import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection
class BaseNoteTitleSort(adapter: RecyclerView.Adapter<*>?, sortDirection: SortDirection) : 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

@ -5,9 +5,9 @@ import androidx.recyclerview.widget.SortedListAdapterCallback
import com.philkes.notallyx.data.model.BaseNote import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Header 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.view.misc.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

@ -3,10 +3,9 @@ package com.philkes.notallyx.presentation.view.misc
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.KeyEvent import android.view.KeyEvent
import androidx.appcompat.widget.AppCompatEditText
class EditTextAutoClearFocus(context: Context, attributeSet: AttributeSet) : class EditTextAutoClearFocus(context: Context, attributeSet: AttributeSet) :
AppCompatEditText(context, attributeSet) { HighlightableEditText(context, attributeSet) {
override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean { override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) {

View file

@ -1,193 +0,0 @@
package com.philkes.notallyx.presentation.view.misc
import android.content.Context
import android.text.Editable
import android.text.Spanned
import android.text.TextWatcher
import android.text.style.CharacterStyle
import android.text.style.URLSpan
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatEditText
import com.philkes.notallyx.data.model.isNoteUrl
import com.philkes.notallyx.data.model.isWebUrl
import com.philkes.notallyx.presentation.clone
import com.philkes.notallyx.presentation.createTextWatcherWithHistory
import com.philkes.notallyx.presentation.removeSelectionFromSpan
import com.philkes.notallyx.utils.changehistory.ChangeHistory
import com.philkes.notallyx.utils.changehistory.EditTextWithHistoryChange
/**
* [AppCompatEditText] whose changes (text edits or span changes) are pushed to [changeHistory].
* *
*/
class EditTextWithHistory(context: Context, attrs: AttributeSet) :
AppCompatEditText(context, attrs) {
var isActionModeOn = false
private var changeHistory: ChangeHistory? = null
private var updateModel: ((text: Editable) -> Unit)? = null
private var textWatcher: TextWatcher? = null
/**
* If this is called every future text or span change is pushed to [changeHistory].
*
* @param updateModel Function that is called when undo/redo of [changeHistory] is triggered *
*/
fun initHistory(changeHistory: ChangeHistory, updateModel: (text: Editable) -> Unit) {
this.textWatcher?.let { removeTextChangedListener(it) }
this.changeHistory = changeHistory
this.updateModel = updateModel
this.textWatcher =
createTextWatcherWithHistory(
changeHistory,
{ text, start, count ->
val changedText = text.substring(start, start + count)
if (changedText.isWebUrl() || changedText.isNoteUrl()) {
this.text?.setSpan(
URLSpan(changedText),
start,
start + count,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
)
}
},
) { text: Editable ->
updateModel(text.clone())
}
this.textWatcher?.let { addTextChangedListener(it) }
}
override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
if (!isActionModeOn) {
super.onWindowFocusChanged(hasWindowFocus)
}
}
@Deprecated(
"You should not access text Editable directly, use other member functions to edit/read text properties.",
replaceWith = ReplaceWith("changeText/applyWithoutTextWatcher/..."),
)
override fun getText(): Editable? {
return super.getText()
}
fun getTextClone(): Editable {
return super.getText()!!.clone()
}
override fun setText(text: CharSequence?, type: BufferType?) {
applyWithoutTextWatcher { super.setText(text, type) }
}
fun applyWithoutTextWatcher(
callback: EditTextWithHistory.() -> Unit
): Pair<Editable, Editable> {
val textBefore = super.getText()!!.clone()
val editTextWatcher = textWatcher
editTextWatcher?.let { removeTextChangedListener(it) }
callback()
editTextWatcher?.let { addTextChangedListener(it) }
return Pair(textBefore, super.getText()!!.clone())
}
fun getSpanRange(span: CharacterStyle): Pair<Int, Int> {
val text = super.getText()!!
return Pair(text.getSpanStart(span), text.getSpanEnd(span))
}
fun getSpanText(span: CharacterStyle): String {
val (spanStart, spanEnd) = getSpanRange(span)
return super.getText()!!.substring(spanStart, spanEnd)
}
fun getSelectionText(): String? {
if (selectionStart == -1 || selectionEnd == -1) {
return null
}
return super.getText()!!.substring(selectionStart, selectionEnd)
}
fun clearFormatting(start: Int = selectionStart, end: Int = selectionEnd) {
changeTextWithHistory { text -> text.removeSelectionFromSpan(start, end) }
}
/**
* Removes [span] from `text`.
*
* @param removeText if this is `true` the text of the [span] is removed from `text`.
*/
fun removeSpan(span: CharacterStyle, removeText: Boolean = false) {
val (start, end) = getSpanRange(span)
changeTextWithHistory { text ->
text.removeSelectionFromSpan(start, end)
if (removeText) {
text.delete(start, end)
}
}
}
fun addSpan(spanText: String, span: CharacterStyle, position: Int = selectionStart) {
changeTextWithHistory { text ->
text.insert(position, spanText)
text.setSpan(
span,
position,
position + spanText.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
)
}
}
/**
* Replaces [oldSpan] with [newSpan].
*
* @param spanText if this is not `null`, the spans text is also updated,
*/
fun updateSpan(oldSpan: CharacterStyle, newSpan: CharacterStyle, spanText: String?) {
val (oldSpanStart, oldSpanEnd) = getSpanRange(oldSpan)
changeTextWithHistory { text ->
text.removeSpan(oldSpan)
if (spanText != null) {
text.replace(oldSpanStart, oldSpanEnd, spanText)
text.setSpan(
newSpan,
oldSpanStart,
oldSpanStart + spanText.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
)
} else {
text.setSpan(newSpan, oldSpanStart, oldSpanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
}
fun applySpan(span: CharacterStyle, start: Int = selectionStart, end: Int = selectionEnd) {
changeTextWithHistory { text ->
text.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
/**
* Can be used to change `text` with according [EditTextWithHistoryChange] pushed automatically
* to [changeHistory]. This method is used by all other members functions.
*/
fun changeTextWithHistory(callback: (text: Editable) -> Unit) {
val (textBefore, textAfter) = changeText(callback)
updateModel?.invoke(textAfter.clone())
changeHistory?.push(
EditTextWithHistoryChange(this, textBefore, textAfter) { text ->
updateModel?.invoke(text.clone())
}
)
}
/**
* Can be used to change `text` without triggering the [TextWatcher], which would push a
* [EditTextWithHistoryChange] to [changeHistory].
*
* @return Clones of `text` before the changes and `text` after. *
*/
fun changeText(callback: (text: Editable) -> Unit): Pair<Editable, Editable> {
return applyWithoutTextWatcher { callback(super.getText()!!) }
}
}

View file

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

View file

@ -0,0 +1,99 @@
package com.philkes.notallyx.presentation.view.misc
import android.content.Context
import android.text.Spanned
import android.text.style.BackgroundColorSpan
import android.text.style.CharacterStyle
import android.util.AttributeSet
import androidx.annotation.ColorInt
import androidx.appcompat.widget.AppCompatEditText
import com.philkes.notallyx.presentation.removeSelectionFromSpans
import com.philkes.notallyx.presentation.withAlpha
/**
* [AppCompatEditText] whose changes (text edits or span changes) are pushed to [changeHistory].
* *
*/
open class HighlightableEditText(context: Context, attrs: AttributeSet) :
EditTextWithWatcher(context, attrs) {
fun getSpanRange(span: CharacterStyle): Pair<Int, Int> {
val text = super.getText()!!
return Pair(text.getSpanStart(span), text.getSpanEnd(span))
}
/**
* Removes [span] from `text`.
*
* @param removeText if this is `true` the text of the [span] is removed from `text`.
*/
protected fun removeSpan(span: CharacterStyle, removeText: Boolean = false) {
val (start, end) = getSpanRange(span)
text?.removeSelectionFromSpans(start, end)
if (removeText) {
text?.delete(start, end)
}
}
protected fun applySpan(
span: CharacterStyle,
start: Int = selectionStart,
end: Int = selectionEnd,
) {
text?.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
private val highlightedSpans: MutableList<CharacterStyle> = mutableListOf()
private var selectedHighlightedSpan: CharacterStyle? = null
fun clearHighlights() {
highlightedSpans.apply {
forEach { span -> removeSpan(span) }
clear()
}
selectedHighlightedSpan = null
}
/**
* Visibly highlight text from [startIdx] to [endIdx]. If [selected] is true the text is
* highlighted uniquely. There can only be one [selected] highlight.
*
* @return Vertical offset to highlighted text line.
*/
fun highlight(startIdx: Int, endIdx: Int, selected: Boolean): Int? {
// TODO: Could be replaced with EditText.highlights? (API >= 34)
if (selected) {
selectedHighlightedSpan?.unselect()
}
highlightedSpans
.filter { getSpanRange(it) == Pair(startIdx, endIdx) }
.forEach {
removeSpan(it)
highlightedSpans.remove(it)
}
val span = HighlightSpan(if (selected) highlightColor else highlightColor.withAlpha(0.1f))
applySpan(span, startIdx, endIdx)
highlightedSpans.add(span)
if (selected) {
selectedHighlightedSpan = span
}
return layout?.let {
val line = layout.getLineForOffset(startIdx)
layout.getLineTop(line)
}
}
fun unselectHighlight() {
selectedHighlightedSpan?.unselect()
}
private fun CharacterStyle.unselect() {
val (previousHighlightedStartIdx, previousHighlightedEndIdx) = getSpanRange(this)
if (previousHighlightedStartIdx != -1) {
removeSpan(this)
highlight(previousHighlightedStartIdx, previousHighlightedEndIdx, false)
}
}
}
class HighlightSpan(@ColorInt color: Int) : BackgroundColorSpan(color)

Some files were not shown because too many files have changed in this diff Show more