Compare commits

..

7 commits
main ... v7.0.0

Author SHA1 Message Date
PhilKes
b6125011e3 Temporarily disable flaky test in ListManagerWithChangeHistoryTest 2025-01-27 17:50:33 +01:00
PhilKes
673e489469 Bump v7.0.0 2025-01-27 17:42:38 +01:00
Phil
5c36be6d2e
Merge pull request #318 from PhilKes/translation/update
Update german + french + czech translations
2025-01-27 17:37:21 +01:00
PhilKes
8b4b92ccb9 Update fr/strings.xml 2025-01-27 17:35:20 +01:00
PhilKes
1d72a86ef6 Update cs/strings.xml 2025-01-27 17:33:34 +01:00
PhilKes
aa2f58c383 Update de/strings.xml 2025-01-27 17:31:21 +01:00
Phil
a116c8b2a1
Merge pull request #313 from PhilKes/fix/import-backups-folder
Prompt user to choose backupsFolder on import
2025-01-26 18:15:41 +01:00
253 changed files with 3529 additions and 307241 deletions

View file

@ -22,7 +22,7 @@ body:
- type: input - type: input
id: android-version id: android-version
attributes: attributes:
label: Android Version (API Level) label: Android Version
description: What Android version are you using? description: What Android version are you using?
- type: textarea - type: textarea
id: logs id: logs

View file

@ -1,56 +0,0 @@
name: Deploy to GitHub Pages
on:
push:
branches:
- main
paths:
- documentation/**
# Review gh actions docs if you want to further define triggers, paths, etc
# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#on
jobs:
build:
name: Build Docusaurus
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 18
cache-dependency-path: documentation/yarn.lock
cache: yarn
- name: Install dependencies
working-directory: documentation
run: yarn install --frozen-lockfile
- name: Build website
working-directory: documentation
run: yarn build
- name: Upload Build Artifact
uses: actions/upload-pages-artifact@v3
with:
path: documentation/build
deploy:
name: Deploy to GitHub Pages
needs: build
# Grant GITHUB_TOKEN the permissions required to make a Pages deployment
permissions:
pages: write # to deploy to Pages
id-token: write # to verify the deployment originates from an appropriate source
# Deploy to the github-pages environment
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

3
.gitignore vendored
View file

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

View file

@ -1,371 +0,0 @@
# Changelog
## [v7.4.0](https://github.com/PhilKes/NotallyX/tree/v7.4.0) (2025-04-18)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v7.3.1...v7.4.0)
### Added Features
- Don't force capitalization when adding a label [\#532](https://github.com/PhilKes/NotallyX/issues/532)
- Add a screen protection against screenshot attempts [\#386](https://github.com/PhilKes/NotallyX/issues/386)
### Fixed Bugs
- Share pure text note error [\#544](https://github.com/PhilKes/NotallyX/issues/544)
- Crash when deleting checked items in a list [\#539](https://github.com/PhilKes/NotallyX/issues/539)
- Keyboard don't open after closing it on Android 7 [\#537](https://github.com/PhilKes/NotallyX/issues/537)
- Unable to open links before changing view mode [\#527](https://github.com/PhilKes/NotallyX/issues/527)
- Reminder popup cut on small screens [\#522](https://github.com/PhilKes/NotallyX/issues/522)
- Auto Backup failed [\#514](https://github.com/PhilKes/NotallyX/issues/514)
## [v7.3.1](https://github.com/PhilKes/NotallyX/tree/v7.3.1) (2025-04-08)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v7.3.0...v7.3.1)
### Fixed Bugs
- Button to close note search doesn't work [\#519](https://github.com/PhilKes/NotallyX/issues/519)
- app crashes when pressing label [\#517](https://github.com/PhilKes/NotallyX/issues/517)
## [v7.3.0](https://github.com/PhilKes/NotallyX/tree/v7.3.0) (2025-04-07)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v7.2.1...v7.3.0)
### Added Features
- Persist viewMode of each note individually [\#497](https://github.com/PhilKes/NotallyX/issues/497)
- Read-only mode by default and new notes [\#495](https://github.com/PhilKes/NotallyX/issues/495)
- Hide notes based on labels [\#401](https://github.com/PhilKes/NotallyX/issues/401)
- An archived note should be visible in its label's folder. [\#398](https://github.com/PhilKes/NotallyX/issues/398)
- Sharing notes from the app [\#380](https://github.com/PhilKes/NotallyX/issues/380)
- Add support for json notes import [\#377](https://github.com/PhilKes/NotallyX/issues/377)
- Sharing images to the app [\#281](https://github.com/PhilKes/NotallyX/issues/281)
- Strikethrough checked items lists [\#250](https://github.com/PhilKes/NotallyX/issues/250)
- Click on list element to check it [\#248](https://github.com/PhilKes/NotallyX/issues/248)
- Add long press actions to undo/redo buttons [\#244](https://github.com/PhilKes/NotallyX/issues/244)
- Convert Note \<=\> List [\#190](https://github.com/PhilKes/NotallyX/issues/190)
- Edit labels inside notes [\#180](https://github.com/PhilKes/NotallyX/issues/180)
- Support Wallpaper color themes [\#175](https://github.com/PhilKes/NotallyX/issues/175)
- View Mode [\#76](https://github.com/PhilKes/NotallyX/issues/76)
### Fixed Bugs
- Android 7.0 Navigation bar color issue [\#515](https://github.com/PhilKes/NotallyX/issues/515)
- Search Mode loop for Android \< 9.0 [\#508](https://github.com/PhilKes/NotallyX/issues/508)
- BaseNote.viewMode database migration error [\#505](https://github.com/PhilKes/NotallyX/issues/505)
- New list items can't be added with linebreak/enter [\#496](https://github.com/PhilKes/NotallyX/issues/496)
- Undo changes more than the last changed character [\#472](https://github.com/PhilKes/NotallyX/issues/472)
- Auto Backup failed [\#468](https://github.com/PhilKes/NotallyX/issues/468)
- Amount of backups to keep in periodic backups are not respected if nextcloud mount is used. [\#133](https://github.com/PhilKes/NotallyX/issues/133)
## [v7.2.1](https://github.com/PhilKes/NotallyX/tree/v7.2.1) (2025-03-18)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v7.2.0...v7.2.1)
### Added Features
- Note not automatically saved when App is killed by system [\#446](https://github.com/PhilKes/NotallyX/issues/446)
### Fixed Bugs
- Auto Backup failed [\#456](https://github.com/PhilKes/NotallyX/issues/456)
## [v7.2.1](https://github.com/PhilKes/NotallyX/tree/v7.2.1) (2025-03-18)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v7.2.0...v7.2.1)
### Added Features
- Note not automatically saved when App is killed by system [\#446](https://github.com/PhilKes/NotallyX/issues/446)
### Fixed Bugs
- Auto Backup failed [\#456](https://github.com/PhilKes/NotallyX/issues/456)
## [v7.2.0](https://github.com/PhilKes/NotallyX/tree/v7.2.0) (2025-03-08)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v7.1.0...v7.2.0)
### Added Features
- Sort notes by color [\#442](https://github.com/PhilKes/NotallyX/issues/442)
### Fixed Bugs
- Unable to locate the 'Uncheck all' option [\#444](https://github.com/PhilKes/NotallyX/issues/444)
- List crash when last unchecked item moved [\#436](https://github.com/PhilKes/NotallyX/issues/436)
- Pasting multi line text in empty lists crash [\#434](https://github.com/PhilKes/NotallyX/issues/434)
- Quickly tapping delete button crash [\#428](https://github.com/PhilKes/NotallyX/issues/428)
- First list item can keep parent property [\#427](https://github.com/PhilKes/NotallyX/issues/427)
- \(List\) Move last unchecked item above parent bug [\#425](https://github.com/PhilKes/NotallyX/issues/425)
- Dragging child item to same position breaks parent association [\#422](https://github.com/PhilKes/NotallyX/issues/422)
- Disabling list auto sorting crash [\#421](https://github.com/PhilKes/NotallyX/issues/421)
- Crash while indenting a checklist item [\#419](https://github.com/PhilKes/NotallyX/issues/419)
- List swipe as subtask crash [\#418](https://github.com/PhilKes/NotallyX/issues/418)
- List crash [\#413](https://github.com/PhilKes/NotallyX/issues/413)
- Checked parent + subtask places between item [\#410](https://github.com/PhilKes/NotallyX/issues/410)
- App crashed while screen was off [\#408](https://github.com/PhilKes/NotallyX/issues/408)
- Unchecked items can't be deleted [\#407](https://github.com/PhilKes/NotallyX/issues/407)
- Some list items can't be set to subtask after unchecked [\#406](https://github.com/PhilKes/NotallyX/issues/406)
- List items deleted [\#405](https://github.com/PhilKes/NotallyX/issues/405)
- List item parent task becomes subtask [\#404](https://github.com/PhilKes/NotallyX/issues/404)
- Check empty item crash [\#403](https://github.com/PhilKes/NotallyX/issues/403)
- Drag problem in long checklist [\#396](https://github.com/PhilKes/NotallyX/issues/396)
- List swap items bug [\#395](https://github.com/PhilKes/NotallyX/issues/395)
- Background crashes [\#323](https://github.com/PhilKes/NotallyX/issues/323)
## [v7.1.0](https://github.com/PhilKes/NotallyX/tree/v7.1.0) (2025-02-20)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v7.0.0...v7.1.0)
### Added Features
- Dark mode for note colors [\#352](https://github.com/PhilKes/NotallyX/issues/352)
- Add "new color" option when deleting a color [\#347](https://github.com/PhilKes/NotallyX/issues/347)
- Make "Start view" as default view [\#339](https://github.com/PhilKes/NotallyX/issues/339)
- Display first item as title for lists without title [\#317](https://github.com/PhilKes/NotallyX/issues/317)
- Remove delete option from top bar since it's already in the bottom more menu [\#316](https://github.com/PhilKes/NotallyX/issues/316)
- Add "Export" to bottom menu [\#315](https://github.com/PhilKes/NotallyX/issues/315)
- Move "Hide labels" switch up below the labels slider [\#311](https://github.com/PhilKes/NotallyX/issues/311)
- Display widgets with the notes' colors [\#300](https://github.com/PhilKes/NotallyX/issues/300)
- Allow Pinning a Specific Label as the Starting Page [\#269](https://github.com/PhilKes/NotallyX/issues/269)
- Move checked / unchecked items in list [\#251](https://github.com/PhilKes/NotallyX/issues/251)
- Moving all labels to the sidebar [\#240](https://github.com/PhilKes/NotallyX/issues/240)
- Add "no label" category [\#219](https://github.com/PhilKes/NotallyX/issues/219)
- Manual color selection [\#187](https://github.com/PhilKes/NotallyX/issues/187)
- Pure Dark Mode [\#16](https://github.com/PhilKes/NotallyX/issues/16)
### Fixed Bugs
- Moving group of task at first position wrong order [\#392](https://github.com/PhilKes/NotallyX/issues/392)
- Parent task not checked in specific case [\#391](https://github.com/PhilKes/NotallyX/issues/391)
- Add auto-backup no error notification [\#381](https://github.com/PhilKes/NotallyX/issues/381)
- Backup on save setting not restored [\#373](https://github.com/PhilKes/NotallyX/issues/373)
- Start View setting not restored [\#367](https://github.com/PhilKes/NotallyX/issues/367)
- \(List\) Child item can't be place after the item below it [\#362](https://github.com/PhilKes/NotallyX/issues/362)
- Android 9 Crash java.lang.NoSuchMethodError: getTextSelectHandleLeft\(\) [\#358](https://github.com/PhilKes/NotallyX/issues/358)
- List items order bug [\#357](https://github.com/PhilKes/NotallyX/issues/357)
- Lists parent-child items crash [\#356](https://github.com/PhilKes/NotallyX/issues/356)
- List new items wrong position [\#354](https://github.com/PhilKes/NotallyX/issues/354)
- Replacement color message title limited to 2 lines [\#348](https://github.com/PhilKes/NotallyX/issues/348)
- \(Lists\) Delete checked items and undo crash [\#331](https://github.com/PhilKes/NotallyX/issues/331)
- Sort List items strange bug [\#330](https://github.com/PhilKes/NotallyX/issues/330)
- Backup folder re-select prompt closes automatically [\#324](https://github.com/PhilKes/NotallyX/issues/324)
- Biometric lock can't be enabled [\#259](https://github.com/PhilKes/NotallyX/issues/259)
## [v7.0.0](https://github.com/PhilKes/NotallyX/tree/v7.0.0) (2025-01-27)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.4.1...v7.0.0)
### Added Features
- Add Reminders menu to navigation panel [\#294](https://github.com/PhilKes/NotallyX/issues/294)
- Extend notes colors to full screen [\#264](https://github.com/PhilKes/NotallyX/issues/264)
- Auto-backup on note modification [\#203](https://github.com/PhilKes/NotallyX/issues/203)
- Option to show full date inside the note regardless of chosen date format [\#111](https://github.com/PhilKes/NotallyX/issues/111)
- Reminder for Notes [\#85](https://github.com/PhilKes/NotallyX/issues/85)
### Fixed Bugs
- Restoring settings and backup folder [\#310](https://github.com/PhilKes/NotallyX/issues/310)
- Settings can't be imported [\#307](https://github.com/PhilKes/NotallyX/issues/307)
- Label create/edit dialog broken [\#302](https://github.com/PhilKes/NotallyX/issues/302)
- Rotating screen moves the cursor to the end of note [\#293](https://github.com/PhilKes/NotallyX/issues/293)
- Creating an empty note corrupts auto backup archive [\#288](https://github.com/PhilKes/NotallyX/issues/288)
- Changes to check lists \(checking items and removing items\) reverts when screen rotatates [\#287](https://github.com/PhilKes/NotallyX/issues/287)
- Auto-backups stop exporting after set limit is reached [\#270](https://github.com/PhilKes/NotallyX/issues/270)
- Link is not saved if it's the last edit [\#267](https://github.com/PhilKes/NotallyX/issues/267)
- Labels hidden in overview not applied when importing JSON [\#266](https://github.com/PhilKes/NotallyX/issues/266)
- An unexpected error occurred. Sorry for the inconvenience [\#262](https://github.com/PhilKes/NotallyX/issues/262)
- List subtasks bugs [\#207](https://github.com/PhilKes/NotallyX/issues/207)
- Remove link also remove text [\#201](https://github.com/PhilKes/NotallyX/issues/201)
- Biometric Lock crash [\#177](https://github.com/PhilKes/NotallyX/issues/177)
- Import from Evernote and Google Keep not working [\#134](https://github.com/PhilKes/NotallyX/issues/134)
## [v6.4.1](https://github.com/PhilKes/NotallyX/tree/v6.4.1) (2025-01-17)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.4.0...v6.4.1)
### Added Features
- Bottom AppBar cutoff + chinese translations [\#176](https://github.com/PhilKes/NotallyX/issues/176)
### Fixed Bugs
- Crash loop when enabling biometrics [\#256](https://github.com/PhilKes/NotallyX/issues/256)
- Crash when creating notes in 6.4.0 [\#255](https://github.com/PhilKes/NotallyX/issues/255)
## [v6.4.0](https://github.com/PhilKes/NotallyX/tree/v6.4.0) (2025-01-17)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.3.0...v6.4.0)
### Added Features
- Prevent word wrap in titles [\#220](https://github.com/PhilKes/NotallyX/issues/220)
- Move more top menu to bottom appbar [\#206](https://github.com/PhilKes/NotallyX/issues/206)
- App settings backup [\#204](https://github.com/PhilKes/NotallyX/issues/204)
- Add a touch bar to scroll quickly [\#202](https://github.com/PhilKes/NotallyX/issues/202)
- Select all notes menu button [\#186](https://github.com/PhilKes/NotallyX/issues/186)
- One line entries in main menu [\#185](https://github.com/PhilKes/NotallyX/issues/185)
- Some suggestions [\#183](https://github.com/PhilKes/NotallyX/issues/183)
### Fixed Bugs
- Cutting text during search causes app crash [\#245](https://github.com/PhilKes/NotallyX/issues/245)
- Long notes can't be shared [\#243](https://github.com/PhilKes/NotallyX/issues/243)
- Trigger disable/enable dataOnExternalStorage on settings import [\#231](https://github.com/PhilKes/NotallyX/issues/231)
- Fast scrollbar in notes overview lags [\#230](https://github.com/PhilKes/NotallyX/issues/230)
- Handle user unenrolling device biometrics while biometric lock is enabled [\#229](https://github.com/PhilKes/NotallyX/issues/229)
- Crash on import if no file explorer app installed [\#227](https://github.com/PhilKes/NotallyX/issues/227)
- Assign label inactive checkboxes [\#221](https://github.com/PhilKes/NotallyX/issues/221)
- List icon bug in main view [\#215](https://github.com/PhilKes/NotallyX/issues/215)
- Import plain text issues [\#209](https://github.com/PhilKes/NotallyX/issues/209)
- "Auto backup period in days" cursor buggy [\#192](https://github.com/PhilKes/NotallyX/issues/192)
- Unable to view full link while editing [\#181](https://github.com/PhilKes/NotallyX/issues/181)
- Scrollbar missing ... [\#178](https://github.com/PhilKes/NotallyX/issues/178)
- Widget is invisible when placed [\#156](https://github.com/PhilKes/NotallyX/issues/156)
**Closed issues:**
- Items in Lists [\#184](https://github.com/PhilKes/NotallyX/issues/184)
## [v6.3.0](https://github.com/PhilKes/NotallyX/tree/v6.3.0) (2024-12-23)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.2.2...v6.3.0)
### Added Features
- Possibility to directly report bug when crash occurs [\#170](https://github.com/PhilKes/NotallyX/issues/170)
- "Link Note" text for notes without a title [\#166](https://github.com/PhilKes/NotallyX/issues/166)
- Improve app theme's color contrasts [\#163](https://github.com/PhilKes/NotallyX/issues/163)
- Paste text containing link does not convert to clickable link + polish translation [\#157](https://github.com/PhilKes/NotallyX/issues/157)
- Bottom navigation to increase accessibility for one handed usage [\#129](https://github.com/PhilKes/NotallyX/issues/129)
- Search in selected note [\#108](https://github.com/PhilKes/NotallyX/issues/108)
### Fixed Bugs
- App crashes when enabling biometric lock [\#168](https://github.com/PhilKes/NotallyX/issues/168)
- Barely visible Navigation buttons on Motorola devices in Light Theme [\#161](https://github.com/PhilKes/NotallyX/issues/161)
## [v6.2.2](https://github.com/PhilKes/NotallyX/tree/v6.2.2) (2024-12-09)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.2.1...v6.2.2)
### Added Features
- Encrypted backups [\#151](https://github.com/PhilKes/NotallyX/issues/151)
- Parse list items when pasting a list formatted text into a list note [\#150](https://github.com/PhilKes/NotallyX/issues/150)
- Chinese display of options interface [\#149](https://github.com/PhilKes/NotallyX/issues/149)
### Fixed Bugs
- List notes deleting item even though text is not empty on backspace pressed [\#142](https://github.com/PhilKes/NotallyX/issues/142)
## [v6.2.1](https://github.com/PhilKes/NotallyX/tree/v6.2.1) (2024-12-06)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.2.0...v6.2.1)
### Added Features
- Add change color option inside of a Note [\#140](https://github.com/PhilKes/NotallyX/issues/140)
- Let user choose whether add items in a todo-list at the bottom or top of already existing items [\#132](https://github.com/PhilKes/NotallyX/issues/132)
- Migrate Theme to Material 3 [\#104](https://github.com/PhilKes/NotallyX/issues/104)
### Fixed Bugs
- Exporting multiple notes with same title overwrites files [\#144](https://github.com/PhilKes/NotallyX/issues/144)
- Single notes export as "Untitled.txt" [\#143](https://github.com/PhilKes/NotallyX/issues/143)
## [v6.2.0](https://github.com/PhilKes/NotallyX/tree/v6.2.0) (2024-12-03)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.1.2...v6.2.0)
### Added Features
- Replace positions of "Link Note" and "Select all" [\#136](https://github.com/PhilKes/NotallyX/issues/136)
- Allow to add Notes in Label View [\#128](https://github.com/PhilKes/NotallyX/issues/128)
- Empty notes deleted forever [\#118](https://github.com/PhilKes/NotallyX/issues/118)
- Sync devices using Syncthing [\#109](https://github.com/PhilKes/NotallyX/issues/109)
- Import from txt files [\#103](https://github.com/PhilKes/NotallyX/issues/103)
- Add more Export formats [\#62](https://github.com/PhilKes/NotallyX/issues/62)
### Fixed Bugs
- Using delete option from inside a note won't delete the note [\#131](https://github.com/PhilKes/NotallyX/issues/131)
- The app crashes when creating a link [\#112](https://github.com/PhilKes/NotallyX/issues/112)
- Pinning does not work [\#110](https://github.com/PhilKes/NotallyX/issues/110)
## [v6.1.2](https://github.com/PhilKes/NotallyX/tree/v6.1.2) (2024-11-19)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.1.1...v6.1.2)
### Fixed Bugs
- F-Droid can't build [\#126](https://github.com/PhilKes/NotallyX/issues/126)
- Undo in list can be quite destructive [\#125](https://github.com/PhilKes/NotallyX/issues/125)
- Actions like pin/label/archive only work from overview, not individual note/list [\#124](https://github.com/PhilKes/NotallyX/issues/124)
- Jumbled notes after opening settings [\#100](https://github.com/PhilKes/NotallyX/issues/100)
## [v6.1.1](https://github.com/PhilKes/NotallyX/tree/v6.1.1) (2024-11-14)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.0...v6.1.1)
### Added Features
- Show delete option in top bar after selecting a note [\#105](https://github.com/PhilKes/NotallyX/issues/105)
- Clear previous search term by default [\#96](https://github.com/PhilKes/NotallyX/issues/96)
- Color the navigation bar [\#95](https://github.com/PhilKes/NotallyX/issues/95)
- Make multiselection easier [\#91](https://github.com/PhilKes/NotallyX/issues/91)
- Auto Discard Empty Notes [\#86](https://github.com/PhilKes/NotallyX/issues/86)
- Pin/label multiple Notes [\#78](https://github.com/PhilKes/NotallyX/issues/78)
- Hide widget notes when app locked. [\#75](https://github.com/PhilKes/NotallyX/issues/75)
- Creating categories [\#72](https://github.com/PhilKes/NotallyX/issues/72)
- Add Search Bar to Archived and Deleted Notes Sections [\#68](https://github.com/PhilKes/NotallyX/issues/68)
- Ability to swipe ListItem on DragHandle [\#67](https://github.com/PhilKes/NotallyX/issues/67)
- Show Last Modified Dates in Notes [\#60](https://github.com/PhilKes/NotallyX/issues/60)
- Import from major note apps [\#53](https://github.com/PhilKes/NotallyX/issues/53)
- Display Labels in NavigationView [\#49](https://github.com/PhilKes/NotallyX/issues/49)
- Unneeded Notification Permission on Image Upload [\#34](https://github.com/PhilKes/NotallyX/issues/34)
- Linking Notes Within Notes [\#32](https://github.com/PhilKes/NotallyX/issues/32)
### Fixed Bugs
- Deleted notes won't "delete forever" [\#117](https://github.com/PhilKes/NotallyX/issues/117)
- Empty Auto Backup [\#101](https://github.com/PhilKes/NotallyX/issues/101)
- Selector Moves Left in Widget When Selecting Untitled Notes [\#63](https://github.com/PhilKes/NotallyX/issues/63)
## [v6.0](https://github.com/PhilKes/NotallyX/tree/v6.0) (2024-10-28)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/a29bff9a2d1adcbea47cb024ab21426bd678c016...v6.0)
### Added Features
- Change icon and add monochrome icon [\#44](https://github.com/PhilKes/NotallyX/issues/44)
- Improve copy&paste behaviour [\#40](https://github.com/PhilKes/NotallyX/issues/40)
- Option to Change a Note in Widget [\#36](https://github.com/PhilKes/NotallyX/issues/36)
- Improve auto-backups [\#31](https://github.com/PhilKes/NotallyX/issues/31)
- Lock Notes via PIN/Fingerprint [\#30](https://github.com/PhilKes/NotallyX/issues/30)
- More options for sorting Notes in Overview [\#29](https://github.com/PhilKes/NotallyX/issues/29)
- Support subtasks in Widgets \(for list notes\) [\#6](https://github.com/PhilKes/NotallyX/issues/6)
- Improving Image Display on Notes [\#15](https://github.com/PhilKes/NotallyX/issues/15)
- File attachment [\#9](https://github.com/PhilKes/NotallyX/issues/9)
- Highlighting Completed Tasks in widget [\#17](https://github.com/PhilKes/NotallyX/issues/17)
- Encrypt backups [\#18](https://github.com/PhilKes/NotallyX/issues/18)
- Undo deleting/archiving notes [\#19](https://github.com/PhilKes/NotallyX/issues/19)
### Fixed Bugs
- Title Change Doesnt Update Last Modified Date [\#61](https://github.com/PhilKes/NotallyX/issues/61)
- Biometric Lock Improvement [\#58](https://github.com/PhilKes/NotallyX/issues/58)
- App crashes when pin lock is enabled [\#50](https://github.com/PhilKes/NotallyX/issues/50)
- Undo action ignored Spans [\#47](https://github.com/PhilKes/NotallyX/issues/47)
- Crash After Importing a Note [\#13](https://github.com/PhilKes/NotallyX/issues/13)
- Tasks Disappear When Changing App Language [\#4](https://github.com/PhilKes/NotallyX/issues/4)
- Unable to Swipe Back After Adding Tasks [\#5](https://github.com/PhilKes/NotallyX/issues/5)
- App Crash When Importing Notes and Opening a Task Note [\#7](https://github.com/PhilKes/NotallyX/issues/7)
- improving subtasks [\#8](https://github.com/PhilKes/NotallyX/issues/8)
\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*

View file

@ -4,9 +4,9 @@
<b>NotallyX | Minimalistic note taking app</b> <b>NotallyX | Minimalistic note taking app</b>
<p> <p>
<center> <center>
<a href='https://play.google.com/store/apps/details?id=com.philkes.notallyx&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' src='https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png' height='80'/></a>
<a href="https://f-droid.org/en/packages/com.philkes.notallyx"><img alt='IzzyOnDroid' height='80' src='https://fdroid.gitlab.io/artwork/badge/get-it-on.png' /></a> <a href="https://f-droid.org/en/packages/com.philkes.notallyx"><img alt='IzzyOnDroid' height='80' src='https://fdroid.gitlab.io/artwork/badge/get-it-on.png' /></a>
<a href="https://apt.izzysoft.de/fdroid/index/apk/com.philkes.notallyx"><img alt='F-Droid' height='80' src='https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png' /></a> <a href="https://apt.izzysoft.de/fdroid/index/apk/com.philkes.notallyx"><img alt='F-Droid' height='80' src='https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png' /></a>
<a href="https://github.com/PhilKes/NotallyX/issues/120"><img alt="JoinTesters" height="80" src="fastlane/join-testers.png" /></a>
</center> </center>
</p> </p>
</h2> </h2>
@ -27,7 +27,7 @@
[Notally](https://github.com/OmGodse/Notally), but eXtended [Notally](https://github.com/OmGodse/Notally), but eXtended
* Create **rich text** notes with support for bold, italics, mono space and strike-through * Create **rich text** notes with support for bold, italics, mono space and strike-through
* Create **task lists** and order them with subtasks (+ auto-sort checked items to the end) * Create **task lists** and order them with subtasks
* Set **reminders** with notifications for important notes * Set **reminders** with notifications for important notes
* Complement your notes with any type of file such as **pictures**, PDFs, etc. * Complement your notes with any type of file such as **pictures**, PDFs, etc.
* **Sort notes** by title, last modified date, creation date * **Sort notes** by title, last modified date, creation date

View file

@ -1,7 +1,5 @@
import com.android.build.gradle.internal.tasks.factory.dependsOn import com.android.build.gradle.internal.tasks.factory.dependsOn
import com.ncorti.ktfmt.gradle.tasks.KtfmtFormatTask import com.ncorti.ktfmt.gradle.tasks.KtfmtFormatTask
import org.apache.commons.configuration2.PropertiesConfiguration
import org.apache.commons.configuration2.io.FileHandler
plugins { plugins {
id("com.android.application") id("com.android.application")
@ -10,27 +8,25 @@ plugins {
id("com.google.devtools.ksp") id("com.google.devtools.ksp")
id("com.ncorti.ktfmt.gradle") version "0.20.1" id("com.ncorti.ktfmt.gradle") version "0.20.1"
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.0" id("org.jetbrains.kotlin.plugin.serialization") version "1.9.0"
id("io.github.philkes.android-translations-converter") version "1.0.5" id("io.github.philkes.android-translations-converter") version "1.0.4"
} }
android { android {
namespace = "com.philkes.notallyx" namespace = "com.philkes.notallyx"
compileSdk = 34 compileSdk = 34
ndkVersion = "29.0.13113456"
defaultConfig { defaultConfig {
applicationId = "com.philkes.notallyx" applicationId = "com.philkes.notallyx"
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = project.findProperty("app.versionCode").toString().toInt() versionCode = 7000
versionName = project.findProperty("app.versionName").toString() versionName = "7.0.0"
resourceConfigurations += listOf( resourceConfigurations += listOf(
"en", "ca", "cs", "da", "de", "el", "es", "fr", "hu", "in", "it", "ja", "my", "nb", "nl", "nn", "pl", "pt-rBR", "pt-rPT", "ro", "ru", "sk", "sv", "tl", "tr", "uk", "vi", "zh-rCN", "zh-rTW" "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?.clear() vectorDrawables.generatedDensities?.clear()
ndk {
debugSymbolLevel= "FULL"
}
} }
ksp { ksp {
arg("room.generateKotlin", "true") arg("room.generateKotlin", "true")
arg("room.schemaLocation", "$projectDir/schemas") arg("room.schemaLocation", "$projectDir/schemas")
@ -134,46 +130,12 @@ tasks.register<Copy>("installLocalGitHooks") {
tasks.preBuild.dependsOn(tasks.named("installLocalGitHooks"), tasks.exportTranslationsToExcel) tasks.preBuild.dependsOn(tasks.named("installLocalGitHooks"), tasks.exportTranslationsToExcel)
tasks.register("generateChangelogs") {
doLast {
val githubToken = providers.gradleProperty("CHANGELOG_GITHUB_TOKEN").orNull
val command = mutableListOf(
"bash",
rootProject.file("generate-changelogs.sh").absolutePath,
"v${project.findProperty("app.lastVersionName").toString()}",
rootProject.file("CHANGELOG.md").absolutePath
)
if (!githubToken.isNullOrEmpty()) {
command.add(githubToken)
} else {
println("CHANGELOG_GITHUB_TOKEN not found, which limits the allowed amount of Github API calls")
}
exec {
commandLine(command)
standardOutput = System.out
errorOutput = System.err
}
val config = PropertiesConfiguration()
val fileHandler = FileHandler(config).apply {
file = rootProject.file("gradle.properties")
load()
}
val currentVersionName = config.getProperty("app.versionName")
config.setProperty("app.lastVersionName", currentVersionName)
fileHandler.save()
println("Updated app.lastVersionName to $currentVersionName")
}
}
afterEvaluate { afterEvaluate {
tasks.named("bundleRelease").configure { tasks.named("bundleRelease").configure {
dependsOn(tasks.named("testReleaseUnitTest")) dependsOn(tasks.named("testReleaseUnitTest"))
} }
tasks.named("assembleRelease").configure { tasks.named("assembleRelease").configure {
dependsOn(tasks.named("testReleaseUnitTest")) dependsOn(tasks.named("testReleaseUnitTest"))
finalizedBy(tasks.named("generateChangelogs"))
} }
} }
@ -190,12 +152,11 @@ dependencies {
implementation("androidx.security:security-crypto:1.1.0-alpha06") implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("androidx.sqlite:sqlite-ktx:2.4.0") implementation("androidx.sqlite:sqlite-ktx:2.4.0")
implementation("androidx.work:work-runtime:2.9.1") implementation("androidx.work:work-runtime:2.9.1")
implementation("androidx.biometric:biometric:1.1.0")
implementation("cat.ereza:customactivityoncrash:2.4.0") implementation("cat.ereza:customactivityoncrash:2.4.0")
implementation("com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0") implementation("com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0")
implementation("com.github.bumptech.glide:glide:4.15.1") implementation("com.github.bumptech.glide:glide:4.15.1")
implementation("cn.Leaqi:SwipeDrawer:1.6") implementation("cn.Leaqi:SwipeDrawer:1.6")
implementation("com.github.skydoves:colorpickerview:2.3.0")
implementation("com.google.android.material:material:1.12.0") implementation("com.google.android.material:material:1.12.0")
implementation("com.google.code.findbugs:jsr305:3.0.2") implementation("com.google.code.findbugs:jsr305:3.0.2")
implementation("me.zhanghai.android.fastscroll:library:1.3.0") implementation("me.zhanghai.android.fastscroll:library:1.3.0")

File diff suppressed because it is too large Load diff

View file

@ -11,12 +11,14 @@
#-keepclassmembers class fqcn.of.javascript.interface.for.webview { #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *; # public *;
#} #}
-keepattributes LineNumberTable,SourceFile
-renamesourcefileattribute SourceFile
-dontobfuscate
-optimizations !code/simplification/arithmetic,!field/*,!class/merging/*,!code/allocation/variable
-printmapping obfuscation/mapping.txt
# 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
-keep class ** extends androidx.navigation.Navigator -keep class ** extends androidx.navigation.Navigator
-keep class ** implements org.ocpsoft.prettytime.TimeUnit -keep class ** implements org.ocpsoft.prettytime.TimeUnit

View file

@ -1,158 +0,0 @@
{
"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

@ -1,164 +0,0 @@
{
"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,15 +6,16 @@
<uses-permission <uses-permission
android:name="android.permission.ACCESS_NETWORK_STATE" android:name="android.permission.ACCESS_NETWORK_STATE"
tools:node="remove" /> tools:node="remove" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" /> <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
@ -68,30 +69,9 @@
<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="*/*" /> <data android:mimeType="text/plain" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="file" android:mimeType="text/*" />
<data android:scheme="content" android:mimeType="text/*" />
<data android:scheme="file" android:mimeType="application/json" />
<data android:scheme="content" android:mimeType="application/json" />
<data android:scheme="file" android:mimeType="application/xml" />
<data android:scheme="content" android:mimeType="application/xml" />
</intent-filter>
</activity> </activity>
<activity android:name=".presentation.activity.note.ViewImageActivity" /> <activity android:name=".presentation.activity.note.ViewImageActivity" />

View file

@ -1,118 +0,0 @@
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,18 +1,14 @@
package com.philkes.notallyx package com.philkes.notallyx
import android.app.Activity
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.os.Build import android.os.Build
import android.os.Bundle
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.work.WorkInfo import androidx.work.WorkInfo
import androidx.work.WorkManager import androidx.work.WorkManager
import com.google.android.material.color.DynamicColors
import com.philkes.notallyx.presentation.setEnabledSecureFlag
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
@ -36,7 +32,7 @@ import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class NotallyXApplication : Application(), Application.ActivityLifecycleCallbacks { class NotallyXApplication : Application() {
private lateinit var biometricLockObserver: Observer<BiometricLock> private lateinit var biometricLockObserver: Observer<BiometricLock>
private lateinit var preferences: NotallyXPreferences private lateinit var preferences: NotallyXPreferences
@ -46,17 +42,11 @@ class NotallyXApplication : Application(), Application.ActivityLifecycleCallback
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
registerActivityLifecycleCallbacks(this)
if (isTestRunner()) return if (isTestRunner()) return
preferences = NotallyXPreferences.getInstance(this) preferences = NotallyXPreferences.getInstance(this)
if (preferences.useDynamicColors.value) { preferences.theme.observeForever { theme ->
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)
@ -69,9 +59,6 @@ class NotallyXApplication : Application(), Application.ActivityLifecycleCallback
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
) )
} }
if (oldTheme != null) {
WidgetProvider.updateWidgets(this, locked = locked.value)
}
} }
preferences.backupsFolder.observeForeverWithPrevious { (backupFolderBefore, backupFolder) -> preferences.backupsFolder.observeForeverWithPrevious { (backupFolderBefore, backupFolder) ->
@ -172,20 +159,4 @@ class NotallyXApplication : Application(), Application.ActivityLifecycleCallback
return Build.FINGERPRINT.equals("robolectric", ignoreCase = true) return Build.FINGERPRINT.equals("robolectric", ignoreCase = true)
} }
} }
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
activity.setEnabledSecureFlag(preferences.secureFlag.value)
}
override fun onActivityStarted(activity: Activity) {}
override fun onActivityResumed(activity: Activity) {}
override fun onActivityPaused(activity: Activity) {}
override fun onActivityStopped(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {}
} }

View file

@ -16,11 +16,8 @@ import com.philkes.notallyx.data.dao.BaseNoteDao
import com.philkes.notallyx.data.dao.CommonDao import com.philkes.notallyx.data.dao.CommonDao
import com.philkes.notallyx.data.dao.LabelDao import com.philkes.notallyx.data.dao.LabelDao
import com.philkes.notallyx.data.model.BaseNote import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Color
import com.philkes.notallyx.data.model.Converters import com.philkes.notallyx.data.model.Converters
import com.philkes.notallyx.data.model.Label import com.philkes.notallyx.data.model.Label
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.data.model.toColorString
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
@ -29,11 +26,10 @@ import com.philkes.notallyx.utils.getExternalMediaDirectory
import com.philkes.notallyx.utils.security.SQLCipherUtils import com.philkes.notallyx.utils.security.SQLCipherUtils
import com.philkes.notallyx.utils.security.getInitializedCipherForDecryption import com.philkes.notallyx.utils.security.getInitializedCipherForDecryption
import java.io.File import java.io.File
import net.sqlcipher.database.SQLiteDatabase
import net.sqlcipher.database.SupportFactory import net.sqlcipher.database.SupportFactory
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@Database(entities = [BaseNote::class, Label::class], version = 9) @Database(entities = [BaseNote::class, Label::class], version = 7)
abstract class NotallyDatabase : RoomDatabase() { abstract class NotallyDatabase : RoomDatabase() {
abstract fun getLabelDao(): LabelDao abstract fun getLabelDao(): LabelDao
@ -47,7 +43,7 @@ abstract class NotallyDatabase : RoomDatabase() {
} }
private var biometricLockObserver: Observer<BiometricLock>? = null private var biometricLockObserver: Observer<BiometricLock>? = null
private var dataInPublicFolderObserver: Observer<Boolean>? = null private var externalDataFolderObserver: Observer<Boolean>? = null
companion object { companion object {
@ -79,16 +75,7 @@ abstract class NotallyDatabase : RoomDatabase() {
return context.getDatabasePath(DATABASE_NAME) return context.getDatabasePath(DATABASE_NAME)
} }
fun getInternalDatabaseFiles(context: ContextWrapper): List<File> { fun getCurrentDatabaseName(context: ContextWrapper): String {
val directory = context.getDatabasePath(DATABASE_NAME).parentFile
return listOf(
File(directory, DATABASE_NAME),
File(directory, "$DATABASE_NAME-shm"),
File(directory, "$DATABASE_NAME-wal"),
)
}
private fun getCurrentDatabaseName(context: ContextWrapper): String {
return if (NotallyXPreferences.getInstance(context).dataInPublicFolder.value) { return if (NotallyXPreferences.getInstance(context).dataInPublicFolder.value) {
getExternalDatabaseFile(context).absolutePath getExternalDatabaseFile(context).absolutePath
} else { } else {
@ -109,10 +96,6 @@ abstract class NotallyDatabase : RoomDatabase() {
} }
} }
fun getFreshDatabase(context: ContextWrapper): NotallyDatabase {
return createInstance(context, NotallyXPreferences.getInstance(context), false)
}
private fun createInstance( private fun createInstance(
context: ContextWrapper, context: ContextWrapper,
preferences: NotallyXPreferences, preferences: NotallyXPreferences,
@ -131,14 +114,11 @@ abstract class NotallyDatabase : RoomDatabase() {
Migration5, Migration5,
Migration6, Migration6,
Migration7, Migration7,
Migration8,
Migration9,
) )
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
SQLiteDatabase.loadLibs(context)
if (preferences.biometricLock.value == BiometricLock.ENABLED) { if (preferences.biometricLock.value == BiometricLock.ENABLED) {
if ( if (
SQLCipherUtils.getDatabaseState(getCurrentDatabaseFile(context)) == SQLCipherUtils.getDatabaseState(context, getCurrentDatabaseName(context)) ==
SQLCipherUtils.State.ENCRYPTED SQLCipherUtils.State.ENCRYPTED
) { ) {
initializeDecryption(preferences, instanceBuilder) initializeDecryption(preferences, instanceBuilder)
@ -147,7 +127,7 @@ abstract class NotallyDatabase : RoomDatabase() {
} }
} else { } else {
if ( if (
SQLCipherUtils.getDatabaseState(getCurrentDatabaseFile(context)) == SQLCipherUtils.getDatabaseState(context, getCurrentDatabaseName(context)) ==
SQLCipherUtils.State.ENCRYPTED SQLCipherUtils.State.ENCRYPTED
) { ) {
preferences.biometricLock.save(BiometricLock.ENABLED) preferences.biometricLock.save(BiometricLock.ENABLED)
@ -170,18 +150,18 @@ abstract class NotallyDatabase : RoomDatabase() {
instance.biometricLockObserver!! instance.biometricLockObserver!!
) )
instance.dataInPublicFolderObserver = Observer { instance.externalDataFolderObserver = Observer {
NotallyDatabase.instance?.value?.dataInPublicFolderObserver?.let { NotallyDatabase.instance?.value?.externalDataFolderObserver?.let {
preferences.dataInPublicFolder.removeObserver(it) preferences.dataInPublicFolder.removeObserver(it)
} }
val newInstance = createInstance(context, preferences, true) val newInstance = createInstance(context, preferences, true)
NotallyDatabase.instance?.postValue(newInstance) NotallyDatabase.instance?.postValue(newInstance)
preferences.dataInPublicFolder.observeForeverSkipFirst( preferences.dataInPublicFolder.observeForeverSkipFirst(
newInstance.dataInPublicFolderObserver!! newInstance.externalDataFolderObserver!!
) )
} }
preferences.dataInPublicFolder.observeForeverSkipFirst( preferences.dataInPublicFolder.observeForeverSkipFirst(
instance.dataInPublicFolderObserver!! instance.externalDataFolderObserver!!
) )
} }
return instance return instance
@ -249,28 +229,5 @@ abstract class NotallyDatabase : RoomDatabase() {
) )
} }
} }
object Migration8 : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
val cursor = db.query("SELECT id, color FROM BaseNote")
while (cursor.moveToNext()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow("id"))
val colorString = cursor.getString(cursor.getColumnIndexOrThrow("color"))
val color = Color.valueOfOrDefault(colorString)
val hexColor = color.toColorString()
db.execSQL("UPDATE BaseNote SET color = ? WHERE id = ?", arrayOf(hexColor, id))
}
cursor.close()
}
}
object Migration9 : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE `BaseNote` ADD COLUMN `viewMode` TEXT NOT NULL DEFAULT '${NoteViewMode.EDIT.name}'"
)
}
}
} }
} }

View file

@ -11,6 +11,7 @@ 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
@ -73,8 +74,6 @@ interface BaseNoteDao {
@Query("SELECT id, reminders FROM BaseNote WHERE reminders IS NOT NULL AND reminders != '[]'") @Query("SELECT id, reminders FROM BaseNote WHERE reminders IS NOT NULL AND reminders != '[]'")
suspend fun getAllReminders(): List<NoteIdReminder> suspend fun getAllReminders(): List<NoteIdReminder>
@Query("SELECT color FROM BaseNote WHERE id = :id ") fun getColorOfNote(id: Long): String?
@Query( @Query(
"SELECT id, title, type, reminders FROM BaseNote WHERE reminders IS NOT NULL AND reminders != '[]'" "SELECT id, title, type, reminders FROM BaseNote WHERE reminders IS NOT NULL AND reminders != '[]'"
) )
@ -95,15 +94,8 @@ 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: String) suspend fun updateColor(ids: LongArray, color: Color)
@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)
@ -163,19 +155,14 @@ 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, setOf(Folder.NOTES, Folder.ARCHIVED)) val result = getBaseNotesByLabel(label, Folder.NOTES)
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 IN (:folders) AND labels LIKE '%' || :label || '%' ORDER BY folder DESC, pinned DESC, timestamp DESC" "SELECT * FROM BaseNote WHERE folder = :folder AND labels LIKE '%' || :label || '%' ORDER BY pinned DESC, timestamp DESC"
) )
fun getBaseNotesByLabel(label: String, folders: Collection<Folder>): LiveData<List<BaseNote>> fun getBaseNotesByLabel(label: String, folder: 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)
@ -185,42 +172,16 @@ 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( fun getBaseNotesByKeyword(keyword: String, folder: Folder): LiveData<List<BaseNote>> {
keyword: String, val result = getBaseNotesByKeywordImpl(keyword, folder)
folder: Folder,
label: String?,
): LiveData<List<BaseNote>> {
val result =
when (label) {
null -> getBaseNotesByKeywordUnlabeledImpl(keyword, folder)
"" -> getBaseNotesByKeywordImpl(keyword, folder)
else -> getBaseNotesByKeywordImpl(keyword, folder, label)
}
return result.map { list -> list.filter { baseNote -> matchesKeyword(baseNote, keyword) } } return result.map { list -> list.filter { baseNote -> matchesKeyword(baseNote, keyword) } }
} }
@Query(
"SELECT * FROM BaseNote WHERE folder = :folder AND labels LIKE '%' || :label || '%' AND (title LIKE '%' || :keyword || '%' OR body LIKE '%' || :keyword || '%' OR items LIKE '%' || :keyword || '%') ORDER BY pinned DESC, timestamp DESC"
)
fun getBaseNotesByKeywordImpl(
keyword: String,
folder: Folder,
label: String,
): LiveData<List<BaseNote>>
@Query( @Query(
"SELECT * FROM BaseNote WHERE folder = :folder AND (title LIKE '%' || :keyword || '%' OR body LIKE '%' || :keyword || '%' OR items LIKE '%' || :keyword || '%' OR labels LIKE '%' || :keyword || '%') ORDER BY pinned DESC, timestamp DESC" "SELECT * FROM BaseNote WHERE folder = :folder AND (title LIKE '%' || :keyword || '%' OR body LIKE '%' || :keyword || '%' OR items LIKE '%' || :keyword || '%' OR labels LIKE '%' || :keyword || '%') ORDER BY pinned DESC, timestamp DESC"
) )
fun getBaseNotesByKeywordImpl(keyword: String, folder: Folder): LiveData<List<BaseNote>> fun getBaseNotesByKeywordImpl(keyword: String, folder: Folder): LiveData<List<BaseNote>>
@Query(
"SELECT * FROM BaseNote WHERE folder = :folder AND labels == '[]' AND (title LIKE '%' || :keyword || '%' OR body LIKE '%' || :keyword || '%' OR items LIKE '%' || :keyword || '%') ORDER BY pinned DESC, timestamp DESC"
)
fun getBaseNotesByKeywordUnlabeledImpl(
keyword: String,
folder: Folder,
): LiveData<List<BaseNote>>
private fun matchesKeyword(baseNote: BaseNote, keyword: String): Boolean { private fun matchesKeyword(baseNote: BaseNote, keyword: String): Boolean {
if (baseNote.title.contains(keyword, true)) { if (baseNote.title.contains(keyword, true)) {
return true return true

View file

@ -24,7 +24,4 @@ interface LabelDao {
@Query("SELECT value FROM Label ORDER BY value") fun getAll(): LiveData<List<String>> @Query("SELECT value FROM Label ORDER BY value") fun getAll(): LiveData<List<String>>
@Query("SELECT value FROM Label ORDER BY value") suspend fun getArrayOfAll(): Array<String> @Query("SELECT value FROM Label ORDER BY value") suspend fun getArrayOfAll(): Array<String>
@Query("SELECT EXISTS(SELECT 1 FROM Label WHERE value = :value)")
suspend fun exists(value: String): Boolean
} }

View file

@ -9,7 +9,6 @@ import com.philkes.notallyx.R
import com.philkes.notallyx.data.NotallyDatabase import com.philkes.notallyx.data.NotallyDatabase
import com.philkes.notallyx.data.imports.evernote.EvernoteImporter import com.philkes.notallyx.data.imports.evernote.EvernoteImporter
import com.philkes.notallyx.data.imports.google.GoogleKeepImporter import com.philkes.notallyx.data.imports.google.GoogleKeepImporter
import com.philkes.notallyx.data.imports.txt.JsonImporter
import com.philkes.notallyx.data.imports.txt.PlainTextImporter import com.philkes.notallyx.data.imports.txt.PlainTextImporter
import com.philkes.notallyx.data.model.Audio import com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.data.model.FileAttachment import com.philkes.notallyx.data.model.FileAttachment
@ -40,7 +39,6 @@ class NotesImporter(private val app: Application, private val database: NotallyD
ImportSource.GOOGLE_KEEP -> GoogleKeepImporter() ImportSource.GOOGLE_KEEP -> GoogleKeepImporter()
ImportSource.EVERNOTE -> EvernoteImporter() ImportSource.EVERNOTE -> EvernoteImporter()
ImportSource.PLAIN_TEXT -> PlainTextImporter() ImportSource.PLAIN_TEXT -> PlainTextImporter()
ImportSource.JSON -> JsonImporter()
}.import(app, uri, tempDir, progress) }.import(app, uri, tempDir, progress)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "import: failed", e) Log.e(TAG, "import: failed", e)
@ -155,13 +153,6 @@ enum class ImportSource(
null, null,
R.drawable.text_file, R.drawable.text_file,
), ),
JSON(
R.string.json_files,
FOLDER_OR_FILE_MIMETYPE,
R.string.json_files_help,
null,
R.drawable.file_json,
),
} }
const val FOLDER_OR_FILE_MIMETYPE = "FOLDER_OR_FILE" const val FOLDER_OR_FILE_MIMETYPE = "FOLDER_OR_FILE"

View file

@ -14,10 +14,10 @@ import com.philkes.notallyx.data.imports.evernote.EvernoteImporter.Companion.par
import com.philkes.notallyx.data.imports.parseBodyAndSpansFromHtml import com.philkes.notallyx.data.imports.parseBodyAndSpansFromHtml
import com.philkes.notallyx.data.model.Audio import com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.data.model.BaseNote import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Color
import com.philkes.notallyx.data.model.FileAttachment import com.philkes.notallyx.data.model.FileAttachment
import com.philkes.notallyx.data.model.Folder import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.data.model.ListItem import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.data.model.Type import com.philkes.notallyx.data.model.Type
import com.philkes.notallyx.utils.log import com.philkes.notallyx.utils.log
import com.philkes.notallyx.utils.startsWithAnyOf import com.philkes.notallyx.utils.startsWithAnyOf
@ -143,7 +143,7 @@ fun EvernoteNote.mapToBaseNote(): BaseNote {
type = if (tasks.isEmpty()) Type.NOTE else Type.LIST, type = if (tasks.isEmpty()) Type.NOTE else Type.LIST,
folder = Folder.NOTES, // There is no archive in Evernote, also deleted notes are not folder = Folder.NOTES, // There is no archive in Evernote, also deleted notes are not
// exported // exported
color = BaseNote.COLOR_DEFAULT, // TODO: possible in Evernote? color = Color.DEFAULT, // TODO: possible in Evernote?
title = title, title = title,
pinned = false, // not exported from Evernote pinned = false, // not exported from Evernote
timestamp = parseTimestamp(created), timestamp = parseTimestamp(created),
@ -156,7 +156,6 @@ fun EvernoteNote.mapToBaseNote(): BaseNote {
files = files, files = files,
audios = audios, audios = audios,
reminders = mutableListOf(), reminders = mutableListOf(),
NoteViewMode.EDIT,
) )
} }

View file

@ -11,10 +11,10 @@ import com.philkes.notallyx.data.imports.ImportStage
import com.philkes.notallyx.data.imports.parseBodyAndSpansFromHtml import com.philkes.notallyx.data.imports.parseBodyAndSpansFromHtml
import com.philkes.notallyx.data.model.Audio import com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.data.model.BaseNote import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Color
import com.philkes.notallyx.data.model.FileAttachment import com.philkes.notallyx.data.model.FileAttachment
import com.philkes.notallyx.data.model.Folder import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.data.model.ListItem import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.data.model.Type import com.philkes.notallyx.data.model.Type
import com.philkes.notallyx.utils.listFilesRecursive import com.philkes.notallyx.utils.listFilesRecursive
import com.philkes.notallyx.utils.log import com.philkes.notallyx.utils.log
@ -150,7 +150,7 @@ class GoogleKeepImporter : ExternalImporter {
googleKeepNote.isArchived -> Folder.ARCHIVED googleKeepNote.isArchived -> Folder.ARCHIVED
else -> Folder.NOTES else -> Folder.NOTES
}, },
color = BaseNote.COLOR_DEFAULT, // Ignoring color mapping color = Color.DEFAULT, // Ignoring color mapping
title = googleKeepNote.title, title = googleKeepNote.title,
pinned = googleKeepNote.isPinned, pinned = googleKeepNote.isPinned,
timestamp = googleKeepNote.createdTimestampUsec / 1000, timestamp = googleKeepNote.createdTimestampUsec / 1000,
@ -163,7 +163,6 @@ class GoogleKeepImporter : ExternalImporter {
files = files, files = files,
audios = audios, audios = audios,
reminders = mutableListOf(), reminders = mutableListOf(),
NoteViewMode.EDIT,
) )
} }

View file

@ -1,12 +1,12 @@
package com.philkes.notallyx.data.imports.google package com.philkes.notallyx.data.imports.google
import com.philkes.notallyx.data.model.BaseNote import com.philkes.notallyx.data.model.Color
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 = BaseNote.COLOR_DEFAULT, val color: String = Color.DEFAULT.name,
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

@ -1,52 +0,0 @@
package com.philkes.notallyx.data.imports.txt
import android.app.Application
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.MutableLiveData
import com.philkes.notallyx.data.imports.ExternalImporter
import com.philkes.notallyx.data.imports.ImportProgress
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.toBaseNote
import com.philkes.notallyx.utils.MIME_TYPE_JSON
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
class JsonImporter : ExternalImporter {
override fun import(
app: Application,
source: Uri,
destination: File,
progress: MutableLiveData<ImportProgress>?,
): Pair<List<BaseNote>, File?> {
val notes = mutableListOf<BaseNote>()
fun readJsonFiles(file: DocumentFile) {
when {
file.isDirectory -> {
file.listFiles().forEach { readJsonFiles(it) }
}
file.isFile -> {
if (file.type != MIME_TYPE_JSON) {
return
}
val fileNameWithoutExtension = file.name?.substringBeforeLast(".") ?: ""
val content =
app.contentResolver.openInputStream(file.uri)?.use { inputStream ->
BufferedReader(InputStreamReader(inputStream)).use { reader ->
reader.readText()
}
} ?: ""
notes.add(content.toBaseNote().copy(id = 0L, title = fileNameWithoutExtension))
}
}
}
val file =
if (source.pathSegments.firstOrNull() == "tree") {
DocumentFile.fromTreeUri(app, source)
} else DocumentFile.fromSingleUri(app, source)
file?.let { readJsonFiles(it) }
return Pair(notes, null)
}
}

View file

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

View file

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

View file

@ -12,45 +12,5 @@ enum class Color {
DUSK, DUSK,
FLOWER, FLOWER,
BLOSSOM, BLOSSOM,
CLAY; CLAY,
companion object {
fun allColorStrings() = entries.map { it.toColorString() }.toList()
fun valueOfOrDefault(value: String) =
try {
Color.valueOf(value)
} catch (e: Exception) {
DEFAULT
}
}
} }
fun Color.toColorString() =
when (this) {
Color.DEFAULT -> BaseNote.COLOR_DEFAULT
Color.CORAL -> "#FAAFA9"
Color.ORANGE -> "#FFCC80"
Color.SAND -> "#FFF8B9"
Color.STORM -> "#AFCCDC"
Color.FOG -> "#D3E4EC"
Color.SAGE -> "#B4DED4"
Color.MINT -> "#E2F6D3"
Color.DUSK -> "#D3BFDB"
Color.FLOWER -> "#F8BBD0"
Color.BLOSSOM -> "#F5E2DC"
Color.CLAY -> "#E9E3D3"
}
fun String.parseToColorString() =
try {
android.graphics.Color.parseColor(this)
this
} catch (_: Exception) {
try {
val colorEnum = Color.valueOf(this)
colorEnum.toColorString()
} catch (e: Exception) {
BaseNote.COLOR_DEFAULT
}
}

View file

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

View file

@ -5,14 +5,5 @@ import java.io.Serializable
enum class Folder : Serializable { enum class Folder : Serializable {
NOTES, NOTES,
DELETED, DELETED,
ARCHIVED; ARCHIVED,
companion object {
fun valueOfOrDefault(value: String) =
try {
valueOf(value)
} catch (e: Exception) {
NOTES
}
}
} }

View file

@ -35,7 +35,6 @@ 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,7 +1,5 @@
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
} }
@ -10,17 +8,35 @@ fun ListItem.findChild(childId: Int): ListItem? {
return this.children.find { child -> child.id == childId } return this.children.find { child -> child.id == childId }
} }
fun ListItem.check(checked: Boolean, checkChildren: Boolean = true) { fun List<ListItem>.areAllChecked(except: ListItem? = null): Boolean {
this.checked = checked return this.none { !it.checked && it != except }
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 ListItem.shouldParentBeUnchecked(): Boolean { fun List<ListItem>.findParentPosition(childPosition: Int): Int? {
return children.isNotEmpty() && !children.areAllChecked() && checked for (position in childPosition - 1 downTo 0) {
} if (!this[position].isChild) {
return position
fun ListItem.shouldParentBeChecked(): Boolean { }
return children.isNotEmpty() && children.areAllChecked() && !checked }
return null
} }

View file

@ -5,7 +5,6 @@ import android.text.Html
import androidx.core.text.toHtml import androidx.core.text.toHtml
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.data.dao.NoteIdReminder import com.philkes.notallyx.data.dao.NoteIdReminder
import com.philkes.notallyx.data.model.BaseNote.Companion.COLOR_DEFAULT
import com.philkes.notallyx.presentation.applySpans import com.philkes.notallyx.presentation.applySpans
import java.text.DateFormat import java.text.DateFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -13,7 +12,6 @@ import java.util.Calendar
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
private const val NOTE_URL_PREFIX = "note://" private const val NOTE_URL_PREFIX = "note://"
@ -43,15 +41,7 @@ fun String.getNoteTypeFromUrl(): Type {
val FileAttachment.isImage: Boolean val FileAttachment.isImage: Boolean
get() { get() {
return mimeType.isImageMimeType return mimeType.startsWith("image/")
}
val String.isImageMimeType: Boolean
get() {
return startsWith("image/")
}
val String.isAudioMimeType: Boolean
get() {
return startsWith("audio/")
} }
fun BaseNote.toTxt(includeTitle: Boolean = true, includeCreationDate: Boolean = true) = fun BaseNote.toTxt(includeTitle: Boolean = true, includeCreationDate: Boolean = true) =
@ -77,11 +67,10 @@ fun BaseNote.toJson(): String {
val jsonObject = val jsonObject =
JSONObject() JSONObject()
.put("type", type.name) .put("type", type.name)
.put("color", color) .put("color", color.name)
.put("title", title) .put("title", title)
.put("pinned", pinned) .put("pinned", pinned)
.put("timestamp", timestamp) .put("date-created", timestamp)
.put("modifiedTimestamp", modifiedTimestamp)
.put("labels", JSONArray(labels)) .put("labels", JSONArray(labels))
when (type) { when (type) {
@ -94,85 +83,10 @@ fun BaseNote.toJson(): String {
jsonObject.put("items", Converters.itemsToJSONArray(items)) jsonObject.put("items", Converters.itemsToJSONArray(items))
} }
} }
jsonObject.put("reminders", Converters.remindersToJSONArray(reminders))
jsonObject.put("viewMode", viewMode.name)
return jsonObject.toString(2) return jsonObject.toString(2)
} }
fun String.toBaseNote(): BaseNote {
val jsonObject = JSONObject(this)
val id = jsonObject.getLongOrDefault("id", -1L)
val type = Type.valueOfOrDefault(jsonObject.getStringOrDefault("type", Type.NOTE.name))
val folder = Folder.valueOfOrDefault(jsonObject.getStringOrDefault("folder", Folder.NOTES.name))
val color =
jsonObject.getStringOrDefault("color", COLOR_DEFAULT).takeIf { it.isValid() }
?: COLOR_DEFAULT
val title = jsonObject.getStringOrDefault("title", "")
val pinned = jsonObject.getBooleanOrDefault("pinned", false)
val timestamp = jsonObject.getLongOrDefault("timestamp", System.currentTimeMillis())
val modifiedTimestamp = jsonObject.getLongOrDefault("modifiedTimestamp", timestamp)
val labels = Converters.jsonToLabels(jsonObject.getArrayOrEmpty("labels"))
val body = jsonObject.getStringOrDefault("body", "")
val spans = Converters.jsonToSpans(jsonObject.getArrayOrEmpty("spans"))
val items = Converters.jsonToItems(jsonObject.getArrayOrEmpty("items"))
val images = Converters.jsonToFiles(jsonObject.getArrayOrEmpty("images"))
val files = Converters.jsonToFiles(jsonObject.getArrayOrEmpty("files"))
val audios = Converters.jsonToAudios(jsonObject.getArrayOrEmpty("audios"))
val reminders = Converters.jsonToReminders(jsonObject.getArrayOrEmpty("reminders"))
val viewMode = NoteViewMode.valueOfOrDefault(jsonObject.getStringOrDefault("viewMode", ""))
return BaseNote(
id,
type,
folder,
color,
title,
pinned,
timestamp,
modifiedTimestamp,
labels,
body,
spans,
items,
images,
files,
audios,
reminders,
viewMode,
)
}
private fun JSONObject.getStringOrDefault(key: String, defaultValue: String): String {
return try {
getString(key)
} catch (exception: JSONException) {
defaultValue
}
}
private fun JSONObject.getArrayOrEmpty(key: String): JSONArray {
return try {
getJSONArray(key)
} catch (exception: JSONException) {
JSONArray("[]")
}
}
private fun JSONObject.getBooleanOrDefault(key: String, defaultValue: Boolean): Boolean {
return try {
getBoolean(key)
} catch (exception: JSONException) {
defaultValue
}
}
private fun JSONObject.getLongOrDefault(key: String, defaultValue: Long): Long {
return try {
getLong(key)
} catch (exception: JSONException) {
defaultValue
}
}
fun BaseNote.toHtml(showDateCreated: Boolean) = buildString { fun BaseNote.toHtml(showDateCreated: Boolean) = buildString {
val date = DateFormat.getDateInstance(DateFormat.FULL).format(timestamp) val date = DateFormat.getDateInstance(DateFormat.FULL).format(timestamp)
val title = Html.escapeHtml(title) val title = Html.escapeHtml(title)
@ -308,15 +222,3 @@ fun List<ListItem>.toText() = buildString {
} }
fun Collection<ListItem>.deepCopy() = map { it.copy(children = mutableListOf()) } fun Collection<ListItem>.deepCopy() = map { it.copy(children = mutableListOf()) }
fun ColorString.isValid() =
when (this) {
COLOR_DEFAULT -> true
else ->
try {
android.graphics.Color.parseColor(this)
true
} catch (e: Exception) {
false
}
}

View file

@ -1,18 +0,0 @@
package com.philkes.notallyx.data.model
import com.philkes.notallyx.R
import com.philkes.notallyx.presentation.viewmodel.preference.StaticTextProvider
enum class NoteViewMode(override val textResId: Int) : StaticTextProvider {
READ_ONLY(R.string.read_only),
EDIT(R.string.edit);
companion object {
fun valueOfOrDefault(value: String) =
try {
NoteViewMode.valueOf(value)
} catch (e: Exception) {
EDIT
}
}
}

View file

@ -21,15 +21,14 @@ class SearchResult(
value = emptyList() value = emptyList()
} }
fun fetch(keyword: String, folder: Folder, label: String?) { fun fetch(keyword: String, folder: Folder) {
job?.cancel() job?.cancel()
liveData?.removeObserver(observer) liveData?.removeObserver(observer)
job = job =
scope.launch { scope.launch {
liveData = baseNoteDao.getBaseNotesByKeyword(keyword, folder, label) liveData =
// if (keyword.isNotEmpty()) if (keyword.isNotEmpty()) baseNoteDao.getBaseNotesByKeyword(keyword, folder)
// baseNoteDao.getBaseNotesByKeyword(keyword, folder, label) else baseNoteDao.getFrom(folder)
// else baseNoteDao.getFrom(folder)
liveData?.observeForever(observer) liveData?.observeForever(observer)
} }
} }

View file

@ -2,14 +2,5 @@ package com.philkes.notallyx.data.model
enum class Type { enum class Type {
NOTE, NOTE,
LIST; LIST,
companion object {
fun valueOfOrDefault(value: String) =
try {
Type.valueOf(value)
} catch (e: Exception) {
NOTE
}
}
} }

View file

@ -8,8 +8,6 @@ import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.content.res.Resources
import android.graphics.PorterDuff
import android.graphics.Typeface import android.graphics.Typeface
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
@ -23,7 +21,6 @@ import android.text.TextWatcher
import android.text.style.CharacterStyle import android.text.style.CharacterStyle
import android.text.style.StrikethroughSpan import android.text.style.StrikethroughSpan
import android.text.style.StyleSpan import android.text.style.StyleSpan
import android.text.style.SuggestionSpan
import android.text.style.TypefaceSpan import android.text.style.TypefaceSpan
import android.text.style.URLSpan import android.text.style.URLSpan
import android.util.TypedValue import android.util.TypedValue
@ -39,7 +36,6 @@ import android.view.WindowManager
import android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE import android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.Button
import android.widget.CompoundButton import android.widget.CompoundButton
import android.widget.EditText import android.widget.EditText
import android.widget.ImageButton import android.widget.ImageButton
@ -53,29 +49,22 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.drawable.DrawableCompat import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.text.getSpans
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.marginBottom import androidx.core.view.marginBottom
import androidx.core.view.marginTop import androidx.core.view.marginTop
import androidx.core.view.setPadding import androidx.core.view.setPadding
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.core.widget.TextViewCompat
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import com.google.android.material.card.MaterialCardView
import com.google.android.material.checkbox.MaterialCheckBox import com.google.android.material.checkbox.MaterialCheckBox
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup import com.google.android.material.chip.ChipGroup
@ -84,23 +73,21 @@ import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.RelativeCornerSize import com.google.android.material.shape.RelativeCornerSize
import com.google.android.material.shape.RoundedCornerTreatment import com.google.android.material.shape.RoundedCornerTreatment
import com.google.android.material.shape.ShapeAppearanceModel import com.google.android.material.shape.ShapeAppearanceModel
import com.google.android.material.textfield.MaterialAutoCompleteTextView
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.data.imports.ImportProgress import com.philkes.notallyx.data.imports.ImportProgress
import com.philkes.notallyx.data.imports.ImportStage import com.philkes.notallyx.data.imports.ImportStage
import com.philkes.notallyx.data.model.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.SpanRepresentation import com.philkes.notallyx.data.model.SpanRepresentation
import com.philkes.notallyx.databinding.DialogInputBinding import com.philkes.notallyx.databinding.DialogColorBinding
import com.philkes.notallyx.databinding.DialogProgressBinding import com.philkes.notallyx.databinding.DialogProgressBinding
import com.philkes.notallyx.databinding.LabelBinding import com.philkes.notallyx.databinding.LabelBinding
import com.philkes.notallyx.presentation.activity.main.MainActivity import com.philkes.notallyx.presentation.view.main.ColorAdapter
import com.philkes.notallyx.presentation.view.misc.ItemListener
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
import com.philkes.notallyx.presentation.view.misc.Progress import com.philkes.notallyx.presentation.view.misc.Progress
import com.philkes.notallyx.presentation.view.misc.StylableEditTextWithHistory import com.philkes.notallyx.presentation.view.misc.StylableEditTextWithHistory
import com.philkes.notallyx.presentation.view.note.listitem.ListManager import com.philkes.notallyx.presentation.view.note.listitem.ListManager
import com.philkes.notallyx.presentation.view.note.listitem.adapter.ListItemVH
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
import com.philkes.notallyx.presentation.viewmodel.preference.DateFormat import com.philkes.notallyx.presentation.viewmodel.preference.DateFormat
import com.philkes.notallyx.presentation.viewmodel.preference.TextSize import com.philkes.notallyx.presentation.viewmodel.preference.TextSize
import com.philkes.notallyx.utils.changehistory.ChangeHistory import com.philkes.notallyx.utils.changehistory.ChangeHistory
@ -122,7 +109,7 @@ fun String.applySpans(representations: List<SpanRepresentation>): Editable {
-> ->
try { try {
if (bold) { if (bold) {
editable.setSpan(createBoldSpan(), start, end) editable.setSpan(StyleSpan(Typeface.BOLD), start, end)
} }
if (italic) { if (italic) {
editable.setSpan(StyleSpan(Typeface.ITALIC), start, end) editable.setSpan(StyleSpan(Typeface.ITALIC), start, end)
@ -144,13 +131,6 @@ fun String.applySpans(representations: List<SpanRepresentation>): Editable {
return editable return editable
} }
fun createBoldSpan() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
StyleSpan(Typeface.BOLD, 700)
} else {
StyleSpan(Typeface.BOLD)
}
/** /**
* Adjusts or removes spans based on the selection range. * Adjusts or removes spans based on the selection range.
* *
@ -241,23 +221,14 @@ fun ViewGroup.addIconButton(
title: Int, title: Int,
drawable: Int, drawable: Int,
marginStart: Int = 10, marginStart: Int = 10,
onLongClick: View.OnLongClickListener? = null, onClick: ((item: View) -> Unit)? = null,
onClick: View.OnClickListener? = null, ): View {
): ImageButton {
val view = val view =
ImageButton(ContextThemeWrapper(context, R.style.AppTheme)).apply { ImageButton(ContextThemeWrapper(context, R.style.AppTheme)).apply {
setImageResource(drawable) setImageResource(drawable)
val titleText = context.getString(title) contentDescription = context.getString(title)
contentDescription = titleText setBackgroundResource(R.color.Transparent)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
tooltipText = titleText
}
val outValue = TypedValue()
context.theme.resolveAttribute(android.R.attr.actionBarItemBackground, outValue, true)
setBackgroundResource(outValue.resourceId)
setOnLongClickListener(onLongClick)
setOnClickListener(onClick) setOnClickListener(onClick)
scaleType = ImageView.ScaleType.FIT_CENTER scaleType = ImageView.ScaleType.FIT_CENTER
adjustViewBounds = true adjustViewBounds = true
layoutParams = layoutParams =
@ -265,8 +236,8 @@ fun ViewGroup.addIconButton(
LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT,
) )
.apply { setMargins(marginStart.dp, marginTop, 0, marginBottom) } .apply { setMargins(marginStart.dp(context), marginTop, 0, marginBottom) }
setPadding(8.dp) setPadding(8.dp(context))
} }
addView(view) addView(view)
return view return view
@ -284,11 +255,13 @@ fun TextView.displayFormattedTimestamp(
} else visibility = View.GONE } else visibility = View.GONE
} }
val Int.dp: Int fun Int.dp(context: Context): Int =
get() = (this * Resources.getSystem().displayMetrics.density + 0.5f).toInt() TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
val Float.dp: Int this.toFloat(),
get() = (this * Resources.getSystem().displayMetrics.density + 0.5f).toInt() context.resources.displayMetrics,
)
.toInt()
/** /**
* Creates a TextWatcher for an EditText that is part of a list. Everytime the text is changed, a * Creates a TextWatcher for an EditText that is part of a list. Everytime the text is changed, a
@ -305,11 +278,11 @@ fun EditText.createListTextWatcherWithHistory(
onTextChanged: ((text: CharSequence, start: Int, count: Int) -> Boolean)? = null, onTextChanged: ((text: CharSequence, start: Int, count: Int) -> Boolean)? = null,
) = ) =
object : TextWatcher { object : TextWatcher {
private lateinit var stateBefore: EditTextState
private var ignoreOriginalChange: Boolean = false private var ignoreOriginalChange: Boolean = false
private lateinit var textBefore: Editable
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
textBefore = text.clone() stateBefore = EditTextState(getText()!!.clone(), selectionStart)
} }
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
@ -317,14 +290,13 @@ fun EditText.createListTextWatcherWithHistory(
} }
override fun afterTextChanged(s: Editable?) { override fun afterTextChanged(s: Editable?) {
val textAfter = s!!.clone()
if (textAfter.hasNotChanged(textBefore)) {
return
}
if (!ignoreOriginalChange) { if (!ignoreOriginalChange) {
listManager.changeText( listManager.changeText(
this@createListTextWatcherWithHistory,
this,
positionGetter.invoke(), positionGetter.invoke(),
EditTextState(textAfter, selectionStart), EditTextState(getText()!!.clone(), selectionStart),
before = stateBefore,
) )
} }
} }
@ -348,9 +320,6 @@ fun StylableEditTextWithHistory.createTextWatcherWithHistory(
override fun afterTextChanged(s: Editable?) { override fun afterTextChanged(s: Editable?) {
val textAfter = requireNotNull(s).clone() val textAfter = requireNotNull(s).clone()
if (textAfter.hasNotChanged(stateBefore.text)) {
return
}
updateModel.invoke(textAfter) updateModel.invoke(textAfter)
changeHistory.push( changeHistory.push(
EditTextWithHistoryChange( EditTextWithHistoryChange(
@ -363,10 +332,6 @@ fun StylableEditTextWithHistory.createTextWatcherWithHistory(
} }
} }
fun Editable.hasNotChanged(before: Editable): Boolean {
return toString() == before.toString() && getSpans<SuggestionSpan>().isNotEmpty()
}
fun Editable.clone(): Editable = Editable.Factory.getInstance().newEditable(this) fun Editable.clone(): Editable = Editable.Factory.getInstance().newEditable(this)
fun View.getString(id: Int, vararg formatArgs: String): String { fun View.getString(id: Int, vararg formatArgs: String): String {
@ -389,16 +354,11 @@ fun RadioGroup.checkedTag(): Any {
return this.findViewById<RadioButton?>(this.checkedRadioButtonId).tag return this.findViewById<RadioButton?>(this.checkedRadioButtonId).tag
} }
fun Context.showKeyboard(view: View) { fun Activity.showKeyboard(view: View) {
ContextCompat.getSystemService(this, InputMethodManager::class.java) ContextCompat.getSystemService(this, InputMethodManager::class.java)
?.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) ?.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT)
} }
fun Context.hideKeyboard(view: View) {
ContextCompat.getSystemService(this, InputMethodManager::class.java)
?.hideSoftInputFromWindow(view.windowToken, 0)
}
fun MutableLiveData<out Progress>.setupProgressDialog(activity: Activity, titleId: Int) { fun MutableLiveData<out Progress>.setupProgressDialog(activity: Activity, titleId: Int) {
setupProgressDialog(activity, activity.layoutInflater, activity as LifecycleOwner, titleId) setupProgressDialog(activity, activity.layoutInflater, activity as LifecycleOwner, titleId)
} }
@ -439,13 +399,6 @@ fun <T, C> NotNullLiveData<T>.merge(liveData: NotNullLiveData<C>): MediatorLiveD
} }
} }
fun <T, C> NotNullLiveData<T>.merge(liveData: LiveData<C>): MediatorLiveData<Pair<T, C?>> {
return MediatorLiveData<Pair<T, C?>>().apply {
addSource(this@merge) { value1 -> value = Pair(value1, liveData.value) }
addSource(liveData) { value2 -> value = Pair(this@merge.value, value2) }
}
}
private fun <T : Progress> MutableLiveData<T>.setupProgressDialog( private fun <T : Progress> MutableLiveData<T>.setupProgressDialog(
context: Context, context: Context,
layoutInflater: LayoutInflater, layoutInflater: LayoutInflater,
@ -538,64 +491,6 @@ fun Activity.checkAlarmPermission(
} else onSuccess() } else onSuccess()
} }
fun Activity.setEnabledSecureFlag(enabled: Boolean) {
if (enabled) {
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE,
)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
fun Fragment.displayEditLabelDialog(
oldValue: String,
model: BaseNoteModel,
onUpdateLabel: ((oldLabel: String, newLabel: String) -> Unit)? = null,
) {
requireContext().displayEditLabelDialog(oldValue, model, layoutInflater, onUpdateLabel)
}
fun Activity.displayEditLabelDialog(
oldValue: String,
model: BaseNoteModel,
onUpdateLabel: ((oldLabel: String, newLabel: String) -> Unit)? = null,
) {
displayEditLabelDialog(oldValue, model, layoutInflater, onUpdateLabel)
}
fun Context.displayEditLabelDialog(
oldValue: String,
model: BaseNoteModel,
layoutInflater: LayoutInflater,
onUpdateLabel: ((oldLabel: String, newLabel: String) -> Unit)? = null,
) {
val dialogBinding = DialogInputBinding.inflate(layoutInflater)
dialogBinding.EditText.setText(oldValue)
MaterialAlertDialogBuilder(this)
.setView(dialogBinding.root)
.setTitle(R.string.edit_label)
.setCancelButton()
.setPositiveButton(R.string.save) { dialog, _ ->
val value = dialogBinding.EditText.text.toString().trim()
if (value.isNotEmpty()) {
model.updateLabel(oldValue, value) { success ->
if (success) {
onUpdateLabel?.invoke(oldValue, value)
dialog.dismiss()
} else showToast(R.string.label_exists)
}
}
}
.showAndFocus(dialogBinding.EditText, allowFullSize = true) { positiveButton ->
dialogBinding.EditText.doAfterTextChanged { text ->
positiveButton.isEnabled = !text.isNullOrEmpty()
}
positiveButton.isEnabled = oldValue.isNotEmpty()
}
}
private fun formatTimestamp(timestamp: Long, dateFormat: DateFormat): String { private fun formatTimestamp(timestamp: Long, dateFormat: DateFormat): String {
val date = Date(timestamp) val date = Date(timestamp)
return when (dateFormat) { return when (dateFormat) {
@ -604,12 +499,36 @@ private fun formatTimestamp(timestamp: Long, dateFormat: DateFormat): String {
} }
} }
fun Activity.showColorSelectDialog(
setNavigationbarLight: Boolean?,
callback: (selectedColor: Color) -> Unit,
) {
val dialog = MaterialAlertDialogBuilder(this).setTitle(R.string.change_color).create()
val colorAdapter =
ColorAdapter(
object : ItemListener {
override fun onClick(position: Int) {
dialog.dismiss()
callback(Color.entries[position])
}
override fun onLongClick(position: Int) {}
}
)
DialogColorBinding.inflate(layoutInflater).apply {
RecyclerView.adapter = colorAdapter
dialog.setView(root)
dialog.setOnShowListener {
setNavigationbarLight?.let { window?.apply { setLightStatusAndNavBar(it, root) } }
}
dialog.show()
}
}
fun MaterialAlertDialogBuilder.showAndFocus( fun MaterialAlertDialogBuilder.showAndFocus(
viewToFocus: View? = null, viewToFocus: View? = null,
selectAll: Boolean = false, selectAll: Boolean = false,
allowFullSize: Boolean = false, allowFullSize: Boolean = false,
onShowListener: DialogInterface.OnShowListener? = null,
applyToPositiveButton: ((positiveButton: Button) -> Unit)? = null,
): AlertDialog { ): AlertDialog {
if (allowFullSize) { if (allowFullSize) {
setBackgroundInsetEnd(0) setBackgroundInsetEnd(0)
@ -631,11 +550,7 @@ fun MaterialAlertDialogBuilder.showAndFocus(
WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT,
) )
} }
onShowListener?.let { setOnShowListener(it) }
show() show()
applyToPositiveButton?.let {
getButton(AlertDialog.BUTTON_POSITIVE)?.let { positiveButton -> it(positiveButton) }
}
} }
} }
@ -656,15 +571,10 @@ fun @receiver:ColorInt Int.withAlpha(alpha: Float): Int {
fun Context.getColorFromAttr(@AttrRes attr: Int): Int { fun Context.getColorFromAttr(@AttrRes attr: Int): Int {
val typedValue = TypedValue() val typedValue = TypedValue()
val resolved = theme.resolveAttribute(attr, typedValue, true) val resolved = theme.resolveAttribute(attr, typedValue, true)
if (!resolved) { if (resolved) {
throw IllegalArgumentException("Attribute not found in current theme") return typedValue.data // Returns the color as an Int
}
return if (typedValue.resourceId != 0) {
// It's a reference (@color/something), resolve it properly
ContextCompat.getColor(this, typedValue.resourceId)
} else { } else {
// It's a direct color value throw IllegalArgumentException("Attribute not found in current theme")
typedValue.data
} }
} }
@ -692,15 +602,6 @@ fun View.setControlsColorForAllViews(
overwriteBackground, overwriteBackground,
) // Recursive call for nested layouts ) // Recursive call for nested layouts
} }
if (this is MaterialCardView) {
checkedIconTint = ColorStateList.valueOf(controlsColor)
val colorStateList =
ColorStateList(
arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()),
intArrayOf(controlsColor, controlsColor.withAlpha(0.3f)),
)
setStrokeColor(colorStateList)
}
} else { } else {
val controlsStateList = val controlsStateList =
ColorStateList( ColorStateList(
@ -726,7 +627,15 @@ fun View.setControlsColorForAllViews(
val highlight = controlsColor.withAlpha(0.4f) val highlight = controlsColor.withAlpha(0.4f)
setHintTextColor(highlight) setHintTextColor(highlight)
highlightColor = highlight highlightColor = highlight
setSelectionHandleColor(controlsColor.withAlpha(0.8f)) val selectHandleColor = controlsColor.withAlpha(0.8f)
textSelectHandleLeft?.withTint(selectHandleColor)?.let {
setTextSelectHandleLeft(it)
}
textSelectHandleRight?.withTint(selectHandleColor)?.let {
setTextSelectHandleRight(it)
}
textSelectHandle?.withTint(selectHandleColor)?.let { setTextSelectHandle(it) }
textCursorDrawable?.let { DrawableCompat.setTint(it, selectHandleColor) }
} }
} }
if (this is CompoundButton) { if (this is CompoundButton) {
@ -747,70 +656,6 @@ fun View.setControlsColorForAllViews(
} }
} }
fun TextView.setSelectionHandleColor(@ColorInt color: Int) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
textSelectHandleLeft?.withTint(color)?.let { setTextSelectHandleLeft(it) }
textSelectHandleRight?.withTint(color)?.let { setTextSelectHandleRight(it) }
textSelectHandle?.withTint(color)?.let { setTextSelectHandle(it) }
textCursorDrawable?.let { DrawableCompat.setTint(it, color) }
} else {
setSelectHandleColor(color)
}
} catch (_: Exception) {}
}
/**
* This uses light-graylisted Android APIs. Verified that it works in devices running APIs 21
* and 28. Source: https://gist.github.com/carranca/7e3414622ad7fc6ef375c8cd8dc840c9
*/
private fun TextView.setSelectHandleColor(@ColorInt color: Int) {
// Retrieve a reference to this text field's android.widget.Editor
val editor = getEditor()
handles.forEach {
// Retrieve the field pointing to the drawable currently being used for the select handle
val resourceField = TextView::class.java.getDeclaredField(it.resourceFieldName)
resourceField.isAccessible = true
// Retrieve the drawable resource from that field
val drawableId = resourceField.getInt(this)
val drawable = ContextCompat.getDrawable(context, drawableId)
// Apply a filter on that drawable with the desired colour
drawable?.setColorFilter(color, PorterDuff.Mode.SRC_IN)
// Override the drawable being used by the Editor with our coloured drawable
val selectHandleField = editor.javaClass.getDeclaredField(it.selectHandleFieldName)
selectHandleField.isAccessible = true
selectHandleField.set(editor, drawable)
}
}
private class HandleDescriptor(val resourceFieldName: String, val selectHandleFieldName: String)
private val handles =
arrayOf(
HandleDescriptor(
resourceFieldName = "mTextSelectHandleRes",
selectHandleFieldName = "mSelectHandleCenter",
),
HandleDescriptor(
resourceFieldName = "mTextSelectHandleLeftRes",
selectHandleFieldName = "mSelectHandleLeft",
),
HandleDescriptor(
resourceFieldName = "mTextSelectHandleRightRes",
selectHandleFieldName = "mSelectHandleRight",
),
)
private fun TextView.getEditor(): Any {
val editorField = TextView::class.java.getDeclaredField("mEditor")
editorField.isAccessible = true
return editorField.get(this)
}
fun TextView.setCompoundDrawableTint(@ColorInt color: Int) { fun TextView.setCompoundDrawableTint(@ColorInt color: Int) {
compoundDrawablesRelative.forEach { drawable -> compoundDrawablesRelative.forEach { drawable ->
drawable?.let { DrawableCompat.setTint(DrawableCompat.wrap(it), color) } drawable?.let { DrawableCompat.setTint(DrawableCompat.wrap(it), color) }
@ -830,12 +675,21 @@ fun Drawable.withTint(@ColorInt color: Int): Drawable {
} }
@ColorInt @ColorInt
fun Context.getContrastFontColor(@ColorInt backgroundColor: Int): Int { private fun Context.getContrastFontColor(@ColorInt backgroundColor: Int): Int {
return if (backgroundColor.isLightColor()) ContextCompat.getColor(this, R.color.TextDark) return if (backgroundColor.isLightColor()) ContextCompat.getColor(this, R.color.TextDark)
else ContextCompat.getColor(this, R.color.TextLight) else ContextCompat.getColor(this, R.color.TextLight)
} }
fun @receiver:ColorInt Int.isLightColor() = ColorUtils.calculateLuminance(this) > 0.5 fun @receiver:ColorInt Int.isLightColor(): Boolean {
val red = android.graphics.Color.red(this) / 255.0
val green = android.graphics.Color.green(this) / 255.0
val blue = android.graphics.Color.blue(this) / 255.0
// Calculate relative luminance
val luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
return luminance > 0.5
}
fun MaterialAlertDialogBuilder.setCancelButton(listener: DialogInterface.OnClickListener? = null) = fun MaterialAlertDialogBuilder.setCancelButton(listener: DialogInterface.OnClickListener? = null) =
setNegativeButton(R.string.cancel, listener) setNegativeButton(R.string.cancel, listener)
@ -872,38 +726,34 @@ fun Context.showToast(message: CharSequence) =
Toast.makeText(this, message, Toast.LENGTH_LONG).show() Toast.makeText(this, message, Toast.LENGTH_LONG).show()
} }
fun Context.restartApplication(
fragmentIdToOpen: Int? = null,
extra: Pair<String, Boolean>? = null,
) {
val intent = packageManager.getLaunchIntentForPackage(packageName)
val componentName = intent!!.component
val mainIntent =
Intent.makeRestartActivityTask(componentName).apply {
fragmentIdToOpen?.let { putExtra(MainActivity.EXTRA_FRAGMENT_TO_OPEN, it) }
extra?.let { (key, value) -> putExtra(key, value) }
}
mainIntent.setPackage(packageName)
startActivity(mainIntent)
Runtime.getRuntime().exit(0)
}
@ColorInt
fun Context.extractColor(color: String): Int {
return when (color) {
BaseNote.COLOR_DEFAULT -> return getColorFromAttr(R.attr.colorSurface)
else -> android.graphics.Color.parseColor(color)
}
}
fun ViewGroup.addFastScroll(context: Context) { fun ViewGroup.addFastScroll(context: Context) {
FastScrollerBuilder(this) FastScrollerBuilder(this)
.useMd2Style() .useMd2Style()
.setTrackDrawable(ContextCompat.getDrawable(context, R.drawable.scroll_track)!!) .setTrackDrawable(ContextCompat.getDrawable(context, R.drawable.scroll_track)!!)
.setPadding(0, 0, 2.dp, 0) .setPadding(0, 0, 2.dp(context), 0)
.build() .build()
} }
@ColorInt
fun Context.extractColor(color: Color): Int {
val id =
when (color) {
Color.DEFAULT -> return getColorFromAttr(R.attr.colorSurface)
Color.CORAL -> R.color.Coral
Color.ORANGE -> R.color.Orange
Color.SAND -> R.color.Sand
Color.STORM -> R.color.Storm
Color.FOG -> R.color.Fog
Color.SAGE -> R.color.Sage
Color.MINT -> R.color.Mint
Color.DUSK -> R.color.Dusk
Color.FLOWER -> R.color.Flower
Color.BLOSSOM -> R.color.Blossom
Color.CLAY -> R.color.Clay
}
return ContextCompat.getColor(this, id)
}
fun Window.setLightStatusAndNavBar(value: Boolean, view: View = decorView) { fun Window.setLightStatusAndNavBar(value: Boolean, view: View = decorView) {
val windowInsetsControllerCompat = WindowInsetsControllerCompat(this, view) val windowInsetsControllerCompat = WindowInsetsControllerCompat(this, view)
windowInsetsControllerCompat.isAppearanceLightStatusBars = value windowInsetsControllerCompat.isAppearanceLightStatusBars = value
@ -915,8 +765,6 @@ fun ChipGroup.bindLabels(
textSize: TextSize, textSize: TextSize,
paddingTop: Boolean, paddingTop: Boolean,
color: Int? = null, color: Int? = null,
onClick: ((label: String) -> Unit)? = null,
onLongClick: ((label: String) -> Unit)? = null,
) { ) {
if (labels.isEmpty()) { if (labels.isEmpty()) {
visibility = View.GONE visibility = View.GONE
@ -924,7 +772,7 @@ fun ChipGroup.bindLabels(
apply { apply {
visibility = View.VISIBLE visibility = View.VISIBLE
removeAllViews() removeAllViews()
updatePadding(top = if (paddingTop) 8.dp else 0) updatePadding(top = if (paddingTop) 8.dp(context) else 0)
} }
val inflater = LayoutInflater.from(context) val inflater = LayoutInflater.from(context)
val labelSize = textSize.displayBodySize val labelSize = textSize.displayBodySize
@ -934,13 +782,6 @@ fun ChipGroup.bindLabels(
setTextSize(TypedValue.COMPLEX_UNIT_SP, labelSize) setTextSize(TypedValue.COMPLEX_UNIT_SP, labelSize)
text = label text = label
color?.let { setControlsContrastColorForAllViews(it) } color?.let { setControlsContrastColorForAllViews(it) }
onClick?.let { setOnClickListener { it(label) } }
onLongClick?.let {
setOnLongClickListener {
it(label)
true
}
}
} }
} }
} }
@ -966,43 +807,3 @@ fun RecyclerView.initListView(context: Context) {
addItemDecoration(DividerItemDecoration(context, RecyclerView.VERTICAL)) addItemDecoration(DividerItemDecoration(context, RecyclerView.VERTICAL))
setPadding(0, 0, 0, 0) setPadding(0, 0, 0, 0)
} }
val RecyclerView.focusedViewHolder
get() =
focusedChild?.let { view ->
val position = getChildAdapterPosition(view)
if (position == RecyclerView.NO_POSITION) {
null
} else {
findViewHolderForAdapterPosition(position)
}
}
fun RecyclerView.showKeyboardOnFocusedItem() {
(focusedViewHolder as? ListItemVH)?.let {
it.binding.root.context?.showKeyboard(it.binding.EditText)
}
}
fun RecyclerView.hideKeyboardOnFocusedItem() {
(focusedViewHolder as? ListItemVH)?.let {
it.binding.root.context?.hideKeyboard(it.binding.EditText)
}
}
fun MaterialAutoCompleteTextView.select(value: CharSequence) {
setText(value, false)
}
fun Context.createTextView(textResId: Int, padding: Int = 16.dp): TextView {
return AppCompatTextView(this).apply {
setText(textResId)
TextViewCompat.setTextAppearance(
this,
android.R.style.TextAppearance_Material_DialogWindowTitle,
)
updatePadding(padding, padding, padding, padding)
maxLines = Integer.MAX_VALUE
ellipsize = null
}
}

View file

@ -32,7 +32,7 @@ class ConfigureWidgetActivity : PickNoteActivity() {
preferences.updateWidget(id, baseNote.id, baseNote.type) preferences.updateWidget(id, baseNote.id, baseNote.type)
val manager = AppWidgetManager.getInstance(this) val manager = AppWidgetManager.getInstance(this)
WidgetProvider.updateWidget(application, manager, id, baseNote.id, baseNote.type) WidgetProvider.updateWidget(this, manager, id, baseNote.id, baseNote.type)
val success = Intent() val success = Intent()
success.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id) success.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id)

View file

@ -23,6 +23,7 @@ import com.philkes.notallyx.presentation.showToast
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.utils.security.disableBiometricLock
import com.philkes.notallyx.utils.security.showBiometricOrPinPrompt import com.philkes.notallyx.utils.security.showBiometricOrPinPrompt
abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() { abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() {
@ -33,7 +34,7 @@ abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() {
protected lateinit var binding: T protected lateinit var binding: T
protected lateinit var preferences: NotallyXPreferences protected lateinit var preferences: NotallyXPreferences
val baseModel: BaseNoteModel by viewModels() protected val baseModel: BaseNoteModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -64,10 +65,7 @@ abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() {
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
if ( if (preferences.biometricLock.value == BiometricLock.ENABLED) {
preferences.biometricLock.value == BiometricLock.ENABLED &&
notallyXApplication.locked.value
) {
hide() hide()
} }
} }
@ -86,7 +84,7 @@ abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() {
.setMessage(R.string.unlock_with_biometrics_not_setup) .setMessage(R.string.unlock_with_biometrics_not_setup)
.setPositiveButton(R.string.disable) { _, _ -> .setPositiveButton(R.string.disable) { _, _ ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
baseModel.disableBiometricLock() disableBiometricLock(baseModel)
} }
show() show()
} }
@ -106,7 +104,7 @@ abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() {
BIOMETRIC_ERROR_HW_NOT_PRESENT -> { BIOMETRIC_ERROR_HW_NOT_PRESENT -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
baseModel.disableBiometricLock() disableBiometricLock(baseModel)
showToast(R.string.biometrics_disable_success) showToast(R.string.biometrics_disable_success)
} }
show() show()

View file

@ -1,24 +1,33 @@
package com.philkes.notallyx.presentation.activity.main package com.philkes.notallyx.presentation.activity.main
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.print.PdfPrintListener
import android.transition.TransitionManager import android.transition.TransitionManager
import android.view.Menu import android.view.Menu
import android.view.Menu.CATEGORY_CONTAINER import android.view.Menu.CATEGORY_CONTAINER
import android.view.Menu.CATEGORY_SYSTEM import android.view.Menu.CATEGORY_SYSTEM
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.view.GravityCompat import androidx.core.view.GravityCompat
import androidx.core.view.children import androidx.core.widget.doAfterTextChanged
import androidx.documentfile.provider.DocumentFile
import androidx.drawerlayout.widget.DrawerLayout import androidx.drawerlayout.widget.DrawerLayout
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.navOptions import androidx.navigation.navOptions
import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.AppBarConfiguration
@ -31,31 +40,36 @@ import com.philkes.notallyx.R
import com.philkes.notallyx.data.NotallyDatabase import com.philkes.notallyx.data.NotallyDatabase
import com.philkes.notallyx.data.model.BaseNote import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Folder import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.data.model.Type
import com.philkes.notallyx.data.model.toText
import com.philkes.notallyx.databinding.ActivityMainBinding import com.philkes.notallyx.databinding.ActivityMainBinding
import com.philkes.notallyx.presentation.activity.LockedActivity import com.philkes.notallyx.presentation.activity.LockedActivity
import com.philkes.notallyx.presentation.activity.main.fragment.DisplayLabelFragment.Companion.EXTRA_DISPLAYED_LABEL import com.philkes.notallyx.presentation.activity.main.fragment.DisplayLabelFragment.Companion.EXTRA_DISPLAYED_LABEL
import com.philkes.notallyx.presentation.activity.main.fragment.NotallyFragment import com.philkes.notallyx.presentation.activity.main.fragment.NotallyFragment
import com.philkes.notallyx.presentation.activity.main.fragment.SearchFragment.Companion.EXTRA_INITIAL_FOLDER
import com.philkes.notallyx.presentation.activity.note.EditListActivity import com.philkes.notallyx.presentation.activity.note.EditListActivity
import com.philkes.notallyx.presentation.activity.note.EditNoteActivity import com.philkes.notallyx.presentation.activity.note.EditNoteActivity
import com.philkes.notallyx.presentation.add import com.philkes.notallyx.presentation.add
import com.philkes.notallyx.presentation.applySpans
import com.philkes.notallyx.presentation.getQuantityString import com.philkes.notallyx.presentation.getQuantityString
import com.philkes.notallyx.presentation.movedToResId import com.philkes.notallyx.presentation.movedToResId
import com.philkes.notallyx.presentation.setCancelButton import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.showColorSelectDialog
import com.philkes.notallyx.presentation.view.misc.MenuDialog
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
import com.philkes.notallyx.presentation.view.misc.tristatecheckbox.TriStateCheckBox import com.philkes.notallyx.presentation.view.misc.tristatecheckbox.TriStateCheckBox
import com.philkes.notallyx.presentation.view.misc.tristatecheckbox.setMultiChoiceTriStateItems import com.philkes.notallyx.presentation.view.misc.tristatecheckbox.setMultiChoiceTriStateItems
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel.Companion.CURRENT_LABEL_EMPTY
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel.Companion.CURRENT_LABEL_NONE
import com.philkes.notallyx.presentation.viewmodel.ExportMimeType import com.philkes.notallyx.presentation.viewmodel.ExportMimeType
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.START_VIEW_DEFAULT import com.philkes.notallyx.utils.backup.exportPdfFile
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.START_VIEW_UNLABELED import com.philkes.notallyx.utils.backup.exportPlainTextFile
import com.philkes.notallyx.utils.backup.exportNotes import com.philkes.notallyx.utils.getExportedPath
import com.philkes.notallyx.utils.getUriForFile
import com.philkes.notallyx.utils.nameWithoutExtension
import com.philkes.notallyx.utils.shareNote import com.philkes.notallyx.utils.shareNote
import com.philkes.notallyx.utils.showColorSelectDialog import com.philkes.notallyx.utils.wrapWithChooser
import kotlinx.coroutines.Dispatchers import java.io.File
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : LockedActivity<ActivityMainBinding>() { class MainActivity : LockedActivity<ActivityMainBinding>() {
@ -64,7 +78,6 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
private lateinit var exportFileActivityResultLauncher: ActivityResultLauncher<Intent> private lateinit var exportFileActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var exportNotesActivityResultLauncher: ActivityResultLauncher<Intent> private lateinit var exportNotesActivityResultLauncher: ActivityResultLauncher<Intent>
private var isStartViewFragment = false
private val actionModeCancelCallback = private val actionModeCancelCallback =
object : OnBackPressedCallback(true) { object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
@ -75,7 +88,6 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
var getCurrentFragmentNotes: (() -> Collection<BaseNote>?)? = null var getCurrentFragmentNotes: (() -> Collection<BaseNote>?)? = null
override fun onSupportNavigateUp(): Boolean { override fun onSupportNavigateUp(): Boolean {
baseModel.keyword = ""
return navController.navigateUp(configuration) return navController.navigateUp(configuration)
} }
@ -88,51 +100,16 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
setupMenu() setupMenu()
setupActionMode() setupActionMode()
setupNavigation() setupNavigation()
setupSearch()
setupActivityResultLaunchers() setupActivityResultLaunchers()
onBackPressedDispatcher.addCallback(this, actionModeCancelCallback)
val fragmentIdToLoad = intent.getIntExtra(EXTRA_FRAGMENT_TO_OPEN, -1) val fragmentIdToLoad = intent.getIntExtra(EXTRA_FRAGMENT_TO_OPEN, -1)
if (fragmentIdToLoad != -1) { if (fragmentIdToLoad != -1) {
navController.navigate(fragmentIdToLoad, intent.extras) val bundle = Bundle()
} else if (savedInstanceState == null) { navController.navigate(fragmentIdToLoad, bundle)
navigateToStartView()
} }
onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (baseModel.actionMode.enabled.value) {
return
}
if (
!isStartViewFragment &&
!intent.getBooleanExtra(EXTRA_SKIP_START_VIEW_ON_BACK, false)
) {
navigateToStartView()
} else {
finish()
}
}
},
)
onBackPressedDispatcher.addCallback(this, actionModeCancelCallback)
}
private fun getStartViewNavigation(): Pair<Int, Bundle> {
return when (val startView = preferences.startView.value) {
START_VIEW_DEFAULT -> Pair(R.id.Notes, Bundle())
START_VIEW_UNLABELED -> Pair(R.id.Unlabeled, Bundle())
else -> {
val bundle = Bundle().apply { putString(EXTRA_DISPLAYED_LABEL, startView) }
Pair(R.id.DisplayLabel, bundle)
}
}
}
private fun navigateToStartView() {
val (id, bundle) = getStartViewNavigation()
navController.navigate(id, bundle)
} }
private fun setupFAB() { private fun setupFAB() {
@ -167,8 +144,6 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
private fun setupMenu() { private fun setupMenu() {
binding.NavigationView.menu.apply { binding.NavigationView.menu.apply {
add(0, R.id.Notes, 0, R.string.notes).setCheckable(true).setIcon(R.drawable.home) add(0, R.id.Notes, 0, R.string.notes).setCheckable(true).setIcon(R.drawable.home)
addStaticLabelsMenuItems()
NotallyDatabase.getDatabase(application).observe(this@MainActivity) { database -> NotallyDatabase.getDatabase(application).observe(this@MainActivity) { database ->
labelsLiveData?.removeObservers(this@MainActivity) labelsLiveData?.removeObservers(this@MainActivity)
labelsLiveData = labelsLiveData =
@ -193,7 +168,7 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
.setCheckable(true) .setCheckable(true)
.setIcon(R.drawable.settings) .setIcon(R.drawable.settings)
} }
baseModel.preferences.labelsHidden.observe(this) { hiddenLabels -> baseModel.preferences.labelsHiddenInNavigation.observe(this) { hiddenLabels ->
hideLabelsInNavigation(hiddenLabels, baseModel.preferences.maxLabels.value) hideLabelsInNavigation(hiddenLabels, baseModel.preferences.maxLabels.value)
} }
baseModel.preferences.maxLabels.observe(this) { maxLabels -> baseModel.preferences.maxLabels.observe(this) { maxLabels ->
@ -201,29 +176,21 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
} }
} }
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) { private fun Menu.setupLabelsMenuItems(labels: List<String>, maxLabelsToDisplay: Int) {
removeGroup(1) removeGroup(1)
addStaticLabelsMenuItems() add(1, R.id.Labels, CATEGORY_CONTAINER + 1, R.string.labels)
.setCheckable(true)
.setIcon(R.drawable.label_more)
labelsMenuItems = labelsMenuItems =
labels labels
.mapIndexed { index, label -> .mapIndexed { index, label ->
add(1, R.id.DisplayLabel, CATEGORY_CONTAINER + index + 3, label) add(1, R.id.DisplayLabel, CATEGORY_CONTAINER + index + 2, label)
.setCheckable(true) .setCheckable(true)
.setChecked(baseModel.currentLabel == label)
.setVisible(index < maxLabelsToDisplay) .setVisible(index < maxLabelsToDisplay)
.setIcon(R.drawable.label) .setIcon(R.drawable.label)
.setOnMenuItemClickListener { .setOnMenuItemClickListener {
navigateToLabel(label) val bundle = Bundle().apply { putString(EXTRA_DISPLAYED_LABEL, label) }
navController.navigate(R.id.DisplayLabel, bundle)
false false
} }
} }
@ -242,12 +209,10 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
} else null } else null
configuration = AppBarConfiguration(binding.NavigationView.menu, binding.DrawerLayout) configuration = AppBarConfiguration(binding.NavigationView.menu, binding.DrawerLayout)
setupActionBarWithNavController(navController, configuration) setupActionBarWithNavController(navController, configuration)
hideLabelsInNavigation(baseModel.preferences.labelsHidden.value, maxLabelsToDisplay) hideLabelsInNavigation(
} baseModel.preferences.labelsHiddenInNavigation.value,
maxLabelsToDisplay,
private fun navigateToLabel(label: String) { )
val bundle = Bundle().apply { putString(EXTRA_DISPLAYED_LABEL, label) }
navController.navigate(R.id.DisplayLabel, bundle)
} }
private fun hideLabelsInNavigation(hiddenLabels: Set<String>, maxLabelsToDisplay: Int) { private fun hideLabelsInNavigation(hiddenLabels: Set<String>, maxLabelsToDisplay: Int) {
@ -292,34 +257,28 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
val menu = binding.ActionMode.menu val menu = binding.ActionMode.menu
baseModel.folder.observe(this@MainActivity, ModelFolderObserver(menu, baseModel)) baseModel.folder.observe(this@MainActivity, ModelFolderObserver(menu, baseModel))
baseModel.actionMode.loading.observe(this@MainActivity) { loading ->
menu.setGroupEnabled(Menu.NONE, !loading)
}
} }
private fun moveNotes(folderTo: Folder) { private fun moveNotes(folderTo: Folder) {
if (baseModel.actionMode.loading.value || baseModel.actionMode.isEmpty()) { val folderFrom = baseModel.actionMode.getFirstNote().folder
return val ids = baseModel.moveBaseNotes(folderTo)
} Snackbar.make(
try { findViewById(R.id.DrawerLayout),
baseModel.actionMode.loading.value = true getQuantityString(folderTo.movedToResId(), ids.size),
val folderFrom = baseModel.actionMode.getFirstNote().folder Snackbar.LENGTH_SHORT,
val ids = baseModel.moveBaseNotes(folderTo) )
Snackbar.make( .apply { setAction(R.string.undo) { baseModel.moveBaseNotes(ids, folderFrom) } }
findViewById(R.id.DrawerLayout), .show()
getQuantityString(folderTo.movedToResId(), ids.size),
Snackbar.LENGTH_SHORT,
)
.apply { setAction(R.string.undo) { baseModel.moveBaseNotes(ids, folderFrom) } }
.show()
} finally {
baseModel.actionMode.loading.value = false
}
} }
private fun share() { private fun share() {
val baseNote = baseModel.actionMode.getFirstNote() val baseNote = baseModel.actionMode.getFirstNote()
this.shareNote(baseNote) val body =
when (baseNote.type) {
Type.NOTE -> baseNote.body.applySpans(baseNote.spans)
Type.LIST -> baseNote.items.toText()
}
this.shareNote(baseNote.title, body)
} }
private fun deleteForever() { private fun deleteForever() {
@ -399,12 +358,86 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
} }
private fun exportSelectedNotes(mimeType: ExportMimeType) { private fun exportSelectedNotes(mimeType: ExportMimeType) {
exportNotes( if (baseModel.actionMode.count.value == 1) {
mimeType, val baseNote = baseModel.actionMode.getFirstNote()
baseModel.actionMode.selectedNotes.values, when (mimeType) {
exportFileActivityResultLauncher, ExportMimeType.PDF -> {
exportNotesActivityResultLauncher, exportPdfFile(
) application,
baseNote,
DocumentFile.fromFile(application.getExportedPath()),
pdfPrintListener =
object : PdfPrintListener {
override fun onSuccess(file: DocumentFile) {
showFileOptionsDialog(file, ExportMimeType.PDF.mimeType)
}
override fun onFailure(message: CharSequence?) {
Toast.makeText(
this@MainActivity,
R.string.something_went_wrong,
Toast.LENGTH_SHORT,
)
.show()
}
},
)
}
ExportMimeType.TXT,
ExportMimeType.JSON,
ExportMimeType.HTML ->
lifecycleScope.launch {
exportPlainTextFile(
application,
baseNote,
mimeType,
DocumentFile.fromFile(application.getExportedPath()),
)
?.let { showFileOptionsDialog(it, mimeType.mimeType) }
}
}
} else {
lifecycleScope.launch {
val intent =
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
.apply { addCategory(Intent.CATEGORY_DEFAULT) }
.wrapWithChooser(this@MainActivity)
baseModel.selectedExportMimeType = mimeType
exportNotesActivityResultLauncher.launch(intent)
}
}
}
private fun showFileOptionsDialog(file: DocumentFile, mimeType: String) {
MenuDialog(this)
.add(R.string.view_file) { viewFile(getUriForFile(File(file.uri.path!!)), mimeType) }
.add(R.string.save_to_device) { saveFileToDevice(file, mimeType) }
.show()
}
private fun viewFile(uri: Uri, mimeType: String) {
val intent =
Intent(Intent.ACTION_VIEW)
.apply {
setDataAndType(uri, mimeType)
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
}
.wrapWithChooser(this@MainActivity)
startActivity(intent)
}
private fun saveFileToDevice(file: DocumentFile, mimeType: String) {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT)
.apply {
type = mimeType
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_TITLE, file.nameWithoutExtension!!)
}
.wrapWithChooser(this@MainActivity)
baseModel.selectedExportFile = file
exportFileActivityResultLauncher.launch(intent)
} }
private fun setupNavigation() { private fun setupNavigation() {
@ -435,47 +468,52 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
} }
) )
navController.addOnDestinationChangedListener { _, destination, bundle -> navController.addOnDestinationChangedListener { _, destination, _ ->
fragmentIdToLoad = destination.id fragmentIdToLoad = destination.id
when (fragmentIdToLoad) { binding.NavigationView.setCheckedItem(destination.id)
R.id.DisplayLabel -> if (destination.id != R.id.Search) {
bundle?.getString(EXTRA_DISPLAYED_LABEL)?.let { binding.EnterSearchKeyword.apply {
baseModel.currentLabel = it setText("")
binding.NavigationView.menu.children clearFocus()
.find { menuItem -> menuItem.title == it }
?.let { menuItem -> menuItem.isChecked = true }
}
R.id.Unlabeled -> {
baseModel.currentLabel = CURRENT_LABEL_NONE
binding.NavigationView.setCheckedItem(destination.id)
} }
else -> { when (destination.id) {
baseModel.currentLabel = CURRENT_LABEL_EMPTY R.id.Notes,
binding.NavigationView.setCheckedItem(destination.id) R.id.Deleted,
R.id.Archived -> binding.EnterSearchKeywordLayout.visibility = VISIBLE
else -> binding.EnterSearchKeywordLayout.visibility = GONE
} }
} }
when (destination.id) { handleDestinationChange(destination)
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 isStartViewFragment(id: Int, bundle: Bundle?): Boolean { private fun handleDestinationChange(destination: NavDestination) {
val (startViewId, startViewBundle) = getStartViewNavigation() when (destination.id) {
return startViewId == id && R.id.Notes,
startViewBundle.getString(EXTRA_DISPLAYED_LABEL) == R.id.DisplayLabel -> {
bundle?.getString(EXTRA_DISPLAYED_LABEL) binding.TakeNote.show()
binding.MakeList.show()
}
else -> {
binding.TakeNote.hide()
binding.MakeList.hide()
}
}
val inputManager = ContextCompat.getSystemService(this, InputMethodManager::class.java)
if (destination.id == R.id.Search) {
binding.EnterSearchKeyword.apply {
// setText("")
visibility = View.VISIBLE
requestFocus()
inputManager?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
}
} else {
binding.EnterSearchKeyword.apply {
// visibility = View.GONE
inputManager?.hideSoftInputFromWindow(this.windowToken, 0)
}
}
} }
private fun navigateWithAnimation(id: Int) { private fun navigateWithAnimation(id: Int) {
@ -492,6 +530,34 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
navController.navigate(id, null, options) navController.navigate(id, null, options)
} }
private fun setupSearch() {
binding.EnterSearchKeyword.apply {
setText(baseModel.keyword)
doAfterTextChanged { text ->
baseModel.keyword = requireNotNull(text).trim().toString()
if (
baseModel.keyword.isNotEmpty() &&
navController.currentDestination?.id != R.id.Search
) {
val bundle =
Bundle().apply {
putSerializable(EXTRA_INITIAL_FOLDER, baseModel.folder.value)
}
navController.navigate(R.id.Search, bundle)
}
}
setOnFocusChangeListener { v, hasFocus ->
if (hasFocus && navController.currentDestination?.id != R.id.Search) {
val bundle =
Bundle().apply {
putSerializable(EXTRA_INITIAL_FOLDER, baseModel.folder.value)
}
navController.navigate(R.id.Search, bundle)
}
}
}
}
private fun setupActivityResultLaunchers() { private fun setupActivityResultLaunchers() {
exportFileActivityResultLauncher = exportFileActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
@ -582,38 +648,7 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM
): MenuItem { ): MenuItem {
return add(R.string.change_color, R.drawable.change_color, showAsAction) { return add(R.string.change_color, R.drawable.change_color, showAsAction) {
lifecycleScope.launch { showColorSelectDialog(null) { selectedColor -> model.colorBaseNote(selectedColor) }
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)
}
}
} }
} }
@ -682,6 +717,5 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
companion object { companion object {
const val EXTRA_FRAGMENT_TO_OPEN = "notallyx.intent.extra.FRAGMENT_TO_OPEN" const val EXTRA_FRAGMENT_TO_OPEN = "notallyx.intent.extra.FRAGMENT_TO_OPEN"
const val EXTRA_SKIP_START_VIEW_ON_BACK = "notallyx.intent.extra.SKIP_START_VIEW_ON_BACK"
} }
} }

View file

@ -6,8 +6,8 @@ import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
@ -18,7 +18,6 @@ import com.philkes.notallyx.databinding.DialogInputBinding
import com.philkes.notallyx.databinding.FragmentNotesBinding import com.philkes.notallyx.databinding.FragmentNotesBinding
import com.philkes.notallyx.presentation.activity.main.fragment.DisplayLabelFragment.Companion.EXTRA_DISPLAYED_LABEL import com.philkes.notallyx.presentation.activity.main.fragment.DisplayLabelFragment.Companion.EXTRA_DISPLAYED_LABEL
import com.philkes.notallyx.presentation.add import com.philkes.notallyx.presentation.add
import com.philkes.notallyx.presentation.displayEditLabelDialog
import com.philkes.notallyx.presentation.initListView import com.philkes.notallyx.presentation.initListView
import com.philkes.notallyx.presentation.setCancelButton import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.showAndFocus import com.philkes.notallyx.presentation.showAndFocus
@ -44,7 +43,7 @@ class LabelsFragment : Fragment(), LabelListener {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
labelAdapter = LabelAdapter(this) labelAdapter = LabelAdapter(this)
binding?.MainListView?.apply { binding?.RecyclerView?.apply {
initListView(requireContext()) initListView(requireContext())
adapter = labelAdapter adapter = labelAdapter
binding?.ImageView?.setImageResource(R.drawable.label) binding?.ImageView?.setImageResource(R.drawable.label)
@ -77,7 +76,7 @@ class LabelsFragment : Fragment(), LabelListener {
override fun onEdit(position: Int) { override fun onEdit(position: Int) {
labelAdapter?.currentList?.get(position)?.let { (label, _) -> labelAdapter?.currentList?.get(position)?.let { (label, _) ->
displayEditLabelDialog(label, model) displayEditLabelDialog(label)
} }
} }
@ -87,13 +86,13 @@ class LabelsFragment : Fragment(), LabelListener {
override fun onToggleVisibility(position: Int) { override fun onToggleVisibility(position: Int) {
labelAdapter?.currentList?.get(position)?.let { value -> labelAdapter?.currentList?.get(position)?.let { value ->
val hiddenLabels = model.preferences.labelsHidden.value.toMutableSet() val hiddenLabels = model.preferences.labelsHiddenInNavigation.value.toMutableSet()
if (value.visibleInNavigation) { if (value.visibleInNavigation) {
hiddenLabels.add(value.label) hiddenLabels.add(value.label)
} else { } else {
hiddenLabels.remove(value.label) hiddenLabels.remove(value.label)
} }
model.savePreference(model.preferences.labelsHidden, hiddenLabels) model.savePreference(model.preferences.labelsHiddenInNavigation, hiddenLabels)
val currentList = labelAdapter!!.currentList.toMutableList() val currentList = labelAdapter!!.currentList.toMutableList()
currentList[position] = currentList[position] =
@ -104,7 +103,7 @@ class LabelsFragment : Fragment(), LabelListener {
private fun setupObserver() { private fun setupObserver() {
model.labels.observe(viewLifecycleOwner) { labels -> model.labels.observe(viewLifecycleOwner) { labels ->
val hiddenLabels = model.preferences.labelsHidden.value val hiddenLabels = model.preferences.labelsHiddenInNavigation.value
val labelsData = labels.map { label -> LabelData(label, !hiddenLabels.contains(label)) } val labelsData = labels.map { label -> LabelData(label, !hiddenLabels.contains(label)) }
labelAdapter?.submitList(labelsData) labelAdapter?.submitList(labelsData)
binding?.ImageView?.isVisible = labels.isEmpty() binding?.ImageView?.isVisible = labels.isEmpty()
@ -132,12 +131,7 @@ class LabelsFragment : Fragment(), LabelListener {
} }
} }
} }
.showAndFocus(dialogBinding.EditText, allowFullSize = true) { positiveButton -> .showAndFocus(dialogBinding.EditText, allowFullSize = true)
dialogBinding.EditText.doAfterTextChanged { text ->
positiveButton.isEnabled = !text.isNullOrEmpty()
}
positiveButton.isEnabled = false
}
} }
private fun confirmDeletion(value: String) { private fun confirmDeletion(value: String) {
@ -148,4 +142,32 @@ class LabelsFragment : Fragment(), LabelListener {
.setCancelButton() .setCancelButton()
.show() .show()
} }
private fun displayEditLabelDialog(oldValue: String) {
val dialogBinding = DialogInputBinding.inflate(layoutInflater)
dialogBinding.EditText.setText(oldValue)
MaterialAlertDialogBuilder(requireContext())
.setView(dialogBinding.root)
.setTitle(R.string.edit_label)
.setCancelButton()
.setPositiveButton(R.string.save) { dialog, _ ->
val value = dialogBinding.EditText.text.toString().trim()
if (value.isNotEmpty()) {
model.updateLabel(oldValue, value) { success ->
if (success) {
dialog.dismiss()
} else
Toast.makeText(
requireContext(),
R.string.label_exists,
Toast.LENGTH_LONG,
)
.show()
}
}
}
.showAndFocus(dialogBinding.EditText, allowFullSize = true)
}
} }

View file

@ -9,11 +9,9 @@ import android.view.ViewGroup
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.navigation.findNavController
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager import androidx.recyclerview.widget.StaggeredGridLayoutManager
@ -25,8 +23,6 @@ import com.philkes.notallyx.data.model.Item
import com.philkes.notallyx.data.model.Type import com.philkes.notallyx.data.model.Type
import com.philkes.notallyx.databinding.FragmentNotesBinding import com.philkes.notallyx.databinding.FragmentNotesBinding
import com.philkes.notallyx.presentation.activity.main.MainActivity import com.philkes.notallyx.presentation.activity.main.MainActivity
import com.philkes.notallyx.presentation.activity.main.fragment.SearchFragment.Companion.EXTRA_INITIAL_FOLDER
import com.philkes.notallyx.presentation.activity.main.fragment.SearchFragment.Companion.EXTRA_INITIAL_LABEL
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_FOLDER_FROM import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_FOLDER_FROM
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_FOLDER_TO import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_FOLDER_TO
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_NOTE_ID import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_NOTE_ID
@ -34,9 +30,7 @@ import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EX
import com.philkes.notallyx.presentation.activity.note.EditListActivity import com.philkes.notallyx.presentation.activity.note.EditListActivity
import com.philkes.notallyx.presentation.activity.note.EditNoteActivity import com.philkes.notallyx.presentation.activity.note.EditNoteActivity
import com.philkes.notallyx.presentation.getQuantityString import com.philkes.notallyx.presentation.getQuantityString
import com.philkes.notallyx.presentation.hideKeyboard
import com.philkes.notallyx.presentation.movedToResId import com.philkes.notallyx.presentation.movedToResId
import com.philkes.notallyx.presentation.showKeyboard
import com.philkes.notallyx.presentation.view.main.BaseNoteAdapter import com.philkes.notallyx.presentation.view.main.BaseNoteAdapter
import com.philkes.notallyx.presentation.view.main.BaseNoteVHPreferences import com.philkes.notallyx.presentation.view.main.BaseNoteVHPreferences
import com.philkes.notallyx.presentation.view.misc.ItemListener import com.philkes.notallyx.presentation.view.misc.ItemListener
@ -61,7 +55,7 @@ abstract class NotallyFragment : Fragment(), ItemListener {
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
val layoutManager = binding?.MainListView?.layoutManager as? LinearLayoutManager val layoutManager = binding?.RecyclerView?.layoutManager as? LinearLayoutManager
if (layoutManager != null) { if (layoutManager != null) {
val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition() val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()
if (firstVisiblePosition != RecyclerView.NO_POSITION) { if (firstVisiblePosition != RecyclerView.NO_POSITION) {
@ -79,7 +73,6 @@ abstract class NotallyFragment : Fragment(), ItemListener {
setupAdapter() setupAdapter()
setupRecyclerView() setupRecyclerView()
setupObserver() setupObserver()
setupSearch()
setupActivityResultLaunchers() setupActivityResultLaunchers()
@ -87,8 +80,8 @@ abstract class NotallyFragment : Fragment(), ItemListener {
val scrollPosition = bundle.getInt(EXTRA_SCROLL_POS, -1) val scrollPosition = bundle.getInt(EXTRA_SCROLL_POS, -1)
val scrollOffset = bundle.getInt(EXTRA_SCROLL_OFFSET, 0) val scrollOffset = bundle.getInt(EXTRA_SCROLL_OFFSET, 0)
if (scrollPosition > -1) { if (scrollPosition > -1) {
binding?.MainListView?.post { binding?.RecyclerView?.post {
val layoutManager = binding?.MainListView?.layoutManager as? LinearLayoutManager val layoutManager = binding?.RecyclerView?.layoutManager as? LinearLayoutManager
layoutManager?.scrollToPositionWithOffset(scrollPosition, scrollOffset) layoutManager?.scrollToPositionWithOffset(scrollPosition, scrollOffset)
} }
} }
@ -177,43 +170,6 @@ abstract class NotallyFragment : Fragment(), ItemListener {
} }
} }
private fun setupSearch() {
binding?.EnterSearchKeyword?.apply {
setText(model.keyword)
val navController = findNavController()
navController.addOnDestinationChangedListener { controller, destination, arguments ->
if (destination.id == R.id.Search) {
// setText("")
visibility = View.VISIBLE
requestFocus()
activity?.showKeyboard(this)
} else {
// visibility = View.GONE
setText("")
clearFocus()
activity?.hideKeyboard(this)
}
}
doAfterTextChanged { text ->
val isSearchFragment = navController.currentDestination?.id == R.id.Search
if (isSearchFragment) {
model.keyword = requireNotNull(text).trim().toString()
}
if (text?.isNotEmpty() == true && !isSearchFragment) {
setText("")
model.keyword = text.trim().toString()
navController.navigate(
R.id.Search,
Bundle().apply {
putSerializable(EXTRA_INITIAL_FOLDER, model.folder.value)
putSerializable(EXTRA_INITIAL_LABEL, model.currentLabel)
},
)
}
}
}
}
private fun handleNoteSelection(id: Long, position: Int, baseNote: BaseNote) { private fun handleNoteSelection(id: Long, position: Int, baseNote: BaseNote) {
if (model.actionMode.selectedNotes.contains(id)) { if (model.actionMode.selectedNotes.contains(id)) {
model.actionMode.remove(id) model.actionMode.remove(id)
@ -236,8 +192,7 @@ abstract class NotallyFragment : Fragment(), ItemListener {
maxItems.value, maxItems.value,
maxLines.value, maxLines.value,
maxTitle.value, maxTitle.value,
labelTagsHiddenInOverview.value, labelsHiddenInOverview.value,
imagesHiddenInOverview.value,
), ),
model.imageRoot, model.imageRoot,
this@NotallyFragment, this@NotallyFragment,
@ -248,12 +203,12 @@ abstract class NotallyFragment : Fragment(), ItemListener {
object : RecyclerView.AdapterDataObserver() { object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (itemCount > 0) { if (itemCount > 0) {
binding?.MainListView?.scrollToPosition(positionStart) binding?.RecyclerView?.scrollToPosition(positionStart)
} }
} }
} }
) )
binding?.MainListView?.apply { binding?.RecyclerView?.apply {
adapter = notesAdapter adapter = notesAdapter
setHasFixedSize(false) setHasFixedSize(false)
} }
@ -287,7 +242,7 @@ abstract class NotallyFragment : Fragment(), ItemListener {
} }
private fun setupRecyclerView() { private fun setupRecyclerView() {
binding?.MainListView?.layoutManager = binding?.RecyclerView?.layoutManager =
if (model.preferences.notesView.value == NotesView.GRID) { if (model.preferences.notesView.value == NotesView.GRID) {
StaggeredGridLayoutManager(2, RecyclerView.VERTICAL) StaggeredGridLayoutManager(2, RecyclerView.VERTICAL)
} else LinearLayoutManager(requireContext()) } else LinearLayoutManager(requireContext())

View file

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

View file

@ -4,49 +4,38 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.core.os.BundleCompat import androidx.core.os.BundleCompat
import androidx.core.view.isVisible
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Folder import com.philkes.notallyx.data.model.Folder
class SearchFragment : NotallyFragment() { class SearchFragment : NotallyFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// TODO: autofocus and show keyboard
val initialFolder = val initialFolder =
arguments?.let { arguments?.let {
BundleCompat.getSerializable(it, EXTRA_INITIAL_FOLDER, Folder::class.java) BundleCompat.getSerializable(it, EXTRA_INITIAL_FOLDER, Folder::class.java)
} }
binding?.ChipGroup?.visibility = View.VISIBLE binding?.ChipGroup?.visibility = View.VISIBLE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding?.MainListView?.scrollIndicators = View.SCROLL_INDICATOR_TOP binding?.RecyclerView?.scrollIndicators = View.SCROLL_INDICATOR_TOP
} }
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val initialLabel = arguments?.getString(EXTRA_INITIAL_LABEL) val checked =
model.currentLabel = initialLabel when (initialFolder ?: model.folder.value) {
if (initialLabel?.isEmpty() == true) { Folder.NOTES -> R.id.Notes
val checked = Folder.DELETED -> R.id.Deleted
when (initialFolder ?: model.folder.value) { Folder.ARCHIVED -> R.id.Archived
Folder.NOTES -> R.id.Notes
Folder.DELETED -> R.id.Deleted
Folder.ARCHIVED -> R.id.Archived
}
binding?.ChipGroup?.apply {
setOnCheckedStateChangeListener { _, checkedId ->
when (checkedId.first()) {
R.id.Notes -> model.folder.value = Folder.NOTES
R.id.Deleted -> model.folder.value = Folder.DELETED
R.id.Archived -> model.folder.value = Folder.ARCHIVED
}
}
check(checked)
isVisible = true
} }
} else binding?.ChipGroup?.isVisible = false
getObservable().observe(viewLifecycleOwner) { items -> binding?.ChipGroup?.apply {
model.actionMode.updateSelected(items?.filterIsInstance<BaseNote>()?.map { it.id }) 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)
} }
} }
@ -56,6 +45,5 @@ class SearchFragment : NotallyFragment() {
companion object { companion object {
const val EXTRA_INITIAL_FOLDER = "notallyx.intent.extra.INITIAL_FOLDER" const val EXTRA_INITIAL_FOLDER = "notallyx.intent.extra.INITIAL_FOLDER"
const val EXTRA_INITIAL_LABEL = "notallyx.intent.extra.INITIAL_LABEL"
} }
} }

View file

@ -1,22 +0,0 @@
package com.philkes.notallyx.presentation.activity.main.fragment
import android.os.Bundle
import android.view.View
import androidx.lifecycle.LiveData
import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.data.model.Item
class UnlabeledFragment : NotallyFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
model.folder.value = Folder.NOTES
}
override fun getBackground() = R.drawable.label_off
override fun getObservable(): LiveData<List<Item>> {
return model.getNotesWithoutLabel()
}
}

View file

@ -6,23 +6,19 @@ import android.net.Uri
import android.text.method.PasswordTransformationMethod import android.text.method.PasswordTransformationMethod
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import androidx.core.view.isVisible
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.google.android.material.color.DynamicColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider
import com.google.android.material.textfield.TextInputLayout.END_ICON_PASSWORD_TOGGLE import com.google.android.material.textfield.TextInputLayout.END_ICON_PASSWORD_TOGGLE
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.databinding.ChoiceItemBinding import com.philkes.notallyx.databinding.ChoiceItemBinding
import com.philkes.notallyx.databinding.DialogDateFormatBinding
import com.philkes.notallyx.databinding.DialogNotesSortBinding import com.philkes.notallyx.databinding.DialogNotesSortBinding
import com.philkes.notallyx.databinding.DialogPreferenceBooleanBinding import com.philkes.notallyx.databinding.DialogPreferenceBooleanBinding
import com.philkes.notallyx.databinding.DialogPreferenceEnumWithToggleBinding
import com.philkes.notallyx.databinding.DialogSelectionBoxBinding
import com.philkes.notallyx.databinding.DialogTextInputBinding import com.philkes.notallyx.databinding.DialogTextInputBinding
import com.philkes.notallyx.databinding.PreferenceBinding import com.philkes.notallyx.databinding.PreferenceBinding
import com.philkes.notallyx.databinding.PreferenceSeekbarBinding import com.philkes.notallyx.databinding.PreferenceSeekbarBinding
import com.philkes.notallyx.presentation.checkedTag import com.philkes.notallyx.presentation.checkedTag
import com.philkes.notallyx.presentation.select
import com.philkes.notallyx.presentation.setCancelButton import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.showAndFocus import com.philkes.notallyx.presentation.showAndFocus
import com.philkes.notallyx.presentation.showToast import com.philkes.notallyx.presentation.showToast
@ -35,15 +31,12 @@ import com.philkes.notallyx.presentation.viewmodel.preference.DateFormat
import com.philkes.notallyx.presentation.viewmodel.preference.EnumPreference import com.philkes.notallyx.presentation.viewmodel.preference.EnumPreference
import com.philkes.notallyx.presentation.viewmodel.preference.IntPreference import com.philkes.notallyx.presentation.viewmodel.preference.IntPreference
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.EMPTY_PATH import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.EMPTY_PATH
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.START_VIEW_DEFAULT
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.START_VIEW_UNLABELED
import com.philkes.notallyx.presentation.viewmodel.preference.NotesSort import com.philkes.notallyx.presentation.viewmodel.preference.NotesSort
import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortBy import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortBy
import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortPreference import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortPreference
import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection
import com.philkes.notallyx.presentation.viewmodel.preference.StringPreference import com.philkes.notallyx.presentation.viewmodel.preference.StringPreference
import com.philkes.notallyx.presentation.viewmodel.preference.TextProvider import com.philkes.notallyx.presentation.viewmodel.preference.TextProvider
import com.philkes.notallyx.presentation.viewmodel.preference.Theme
import com.philkes.notallyx.utils.canAuthenticateWithBiometrics import com.philkes.notallyx.utils.canAuthenticateWithBiometrics
import com.philkes.notallyx.utils.toReadablePath import com.philkes.notallyx.utils.toReadablePath
@ -201,35 +194,28 @@ fun PreferenceBinding.setup(
Value.text = dateFormatValue.getText(context) Value.text = dateFormatValue.getText(context)
root.setOnClickListener { root.setOnClickListener {
val layout = DialogPreferenceEnumWithToggleBinding.inflate(layoutInflater, null, false) val layout = DialogDateFormatBinding.inflate(layoutInflater, null, false)
layout.EnumHint.apply {
setText(R.string.date_format_hint)
isVisible = true
}
DateFormat.entries.forEachIndexed { idx, dateFormat -> DateFormat.entries.forEachIndexed { idx, dateFormat ->
ChoiceItemBinding.inflate(layoutInflater).root.apply { ChoiceItemBinding.inflate(layoutInflater).root.apply {
id = idx id = idx
text = dateFormat.getText(context) text = dateFormat.getText(context)
tag = dateFormat tag = dateFormat
layout.EnumRadioGroup.addView(this) layout.DateFormatRadioGroup.addView(this)
if (dateFormat == dateFormatValue) { if (dateFormat == dateFormatValue) {
layout.EnumRadioGroup.check(this.id) layout.DateFormatRadioGroup.check(this.id)
} }
} }
} }
layout.Toggle.apply { layout.ApplyToNoteView.isChecked = applyToNoteViewValue
setText(R.string.date_format_apply_in_note_view)
isChecked = applyToNoteViewValue
}
MaterialAlertDialogBuilder(context) MaterialAlertDialogBuilder(context)
.setTitle(dateFormatPreference.titleResId) .setTitle(dateFormatPreference.titleResId)
.setView(layout.root) .setView(layout.root)
.setPositiveButton(R.string.save) { dialog, _ -> .setPositiveButton(R.string.save) { dialog, _ ->
dialog.cancel() dialog.cancel()
val dateFormat = layout.EnumRadioGroup.checkedTag() as DateFormat val dateFormat = layout.DateFormatRadioGroup.checkedTag() as DateFormat
val applyToNoteView = layout.Toggle.isChecked val applyToNoteView = layout.ApplyToNoteView.isChecked
onSave(dateFormat, applyToNoteView) onSave(dateFormat, applyToNoteView)
} }
.setCancelButton() .setCancelButton()
@ -237,52 +223,6 @@ fun PreferenceBinding.setup(
} }
} }
fun PreferenceBinding.setup(
themePreference: EnumPreference<Theme>,
themeValue: Theme,
useDynamicColorsValue: Boolean,
context: Context,
layoutInflater: LayoutInflater,
onSave: (theme: Theme, useDynamicColors: Boolean) -> Unit,
) {
Title.setText(themePreference.titleResId!!)
Value.text = themeValue.getText(context)
root.setOnClickListener {
val layout = DialogPreferenceEnumWithToggleBinding.inflate(layoutInflater, null, false)
Theme.entries.forEachIndexed { idx, theme ->
ChoiceItemBinding.inflate(layoutInflater).root.apply {
id = idx
text = theme.getText(context)
tag = theme
layout.EnumRadioGroup.addView(this)
if (theme == themeValue) {
layout.EnumRadioGroup.check(this.id)
}
}
}
layout.Toggle.apply {
isVisible = DynamicColors.isDynamicColorAvailable()
setText(R.string.theme_use_dynamic_colors)
isChecked = useDynamicColorsValue
}
MaterialAlertDialogBuilder(context)
.setTitle(themePreference.titleResId)
.setView(layout.root)
.setPositiveButton(R.string.save) { dialog, _ ->
dialog.cancel()
val theme = layout.EnumRadioGroup.checkedTag() as Theme
val useDynamicColors = layout.Toggle.isChecked
onSave(theme, useDynamicColors)
}
.setCancelButton()
.show()
}
}
fun PreferenceBinding.setup( fun PreferenceBinding.setup(
preference: BooleanPreference, preference: BooleanPreference,
value: Boolean, value: Boolean,
@ -489,71 +429,3 @@ fun PreferenceSeekbarBinding.setup(
onChange(newValue) onChange(newValue)
} }
} }
fun PreferenceSeekbarBinding.setupAutoSaveIdleTime(
preference: IntPreference,
context: Context,
value: Int = preference.value,
onChange: (newValue: Int) -> Unit,
) {
Slider.apply {
setLabelFormatter { sliderValue ->
if (sliderValue == -1f) {
context.getString(R.string.disabled)
} else "${sliderValue.toInt()}s"
}
addOnChangeListener { _, value, _ ->
if (value == -1f) {
setAlpha(0.6f) // Reduce opacity to make it look disabled
} else {
setAlpha(1f) // Restore normal appearance
}
}
}
setup(preference, context, value, onChange)
}
fun PreferenceBinding.setupStartView(
preference: StringPreference,
value: String,
labels: List<String>?,
context: Context,
layoutInflater: LayoutInflater,
onSave: (value: String) -> Unit,
) {
Title.setText(preference.titleResId!!)
val notesText = "${context.getText(R.string.notes)} (${context.getText(R.string.text_default)})"
val unlabeledText = context.getText(R.string.unlabeled).toString()
val textValue =
when (value) {
START_VIEW_DEFAULT -> notesText
START_VIEW_UNLABELED -> unlabeledText
else -> value
}
Value.text = textValue
root.setOnClickListener {
val layout = DialogSelectionBoxBinding.inflate(layoutInflater, null, false)
layout.Message.setText(R.string.start_view_hint)
val values =
mutableListOf(notesText to START_VIEW_DEFAULT, unlabeledText to START_VIEW_UNLABELED)
.apply { labels?.forEach { add(it to it) } }
var selected = -1
layout.SelectionBox.apply {
setSimpleItems(values.map { it.first }.toTypedArray())
select(textValue)
setOnItemClickListener { _, _, position, _ -> selected = position }
}
MaterialAlertDialogBuilder(context)
.setTitle(preference.titleResId)
.setView(layout.root)
.setPositiveButton(R.string.save) { dialog, _ ->
dialog.cancel()
val newValue = values[selected].second
onSave(newValue)
}
.setCancelButton()
.showAndFocus(allowFullSize = true)
}
}

View file

@ -31,9 +31,7 @@ import com.philkes.notallyx.data.imports.txt.APPLICATION_TEXT_MIME_TYPES
import com.philkes.notallyx.data.model.toText import com.philkes.notallyx.data.model.toText
import com.philkes.notallyx.databinding.DialogTextInputBinding import com.philkes.notallyx.databinding.DialogTextInputBinding
import com.philkes.notallyx.databinding.FragmentSettingsBinding import com.philkes.notallyx.databinding.FragmentSettingsBinding
import com.philkes.notallyx.presentation.activity.main.MainActivity
import com.philkes.notallyx.presentation.setCancelButton import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.setEnabledSecureFlag
import com.philkes.notallyx.presentation.setupImportProgressDialog import com.philkes.notallyx.presentation.setupImportProgressDialog
import com.philkes.notallyx.presentation.setupProgressDialog import com.philkes.notallyx.presentation.setupProgressDialog
import com.philkes.notallyx.presentation.showAndFocus import com.philkes.notallyx.presentation.showAndFocus
@ -41,23 +39,24 @@ import com.philkes.notallyx.presentation.showDialog
import com.philkes.notallyx.presentation.showToast import com.philkes.notallyx.presentation.showToast
import com.philkes.notallyx.presentation.view.misc.TextWithIconAdapter import com.philkes.notallyx.presentation.view.misc.TextWithIconAdapter
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock
import com.philkes.notallyx.presentation.viewmodel.preference.Constants.PASSWORD_EMPTY import com.philkes.notallyx.presentation.viewmodel.preference.Constants.PASSWORD_EMPTY
import com.philkes.notallyx.presentation.viewmodel.preference.LongPreference import com.philkes.notallyx.presentation.viewmodel.preference.LongPreference
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.EMPTY_PATH import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.EMPTY_PATH
import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackup import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackup
import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackup.Companion.BACKUP_MAX_MIN
import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackup.Companion.BACKUP_PERIOD_DAYS_MIN import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackup.Companion.BACKUP_PERIOD_DAYS_MIN
import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackupsPreference import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackupsPreference
import com.philkes.notallyx.utils.MIME_TYPE_JSON import com.philkes.notallyx.utils.MIME_TYPE_JSON
import com.philkes.notallyx.utils.MIME_TYPE_ZIP import com.philkes.notallyx.utils.MIME_TYPE_ZIP
import com.philkes.notallyx.utils.backup.exportPreferences import com.philkes.notallyx.utils.backup.exportPreferences
import com.philkes.notallyx.utils.catchNoBrowserInstalled import com.philkes.notallyx.utils.catchNoBrowserInstalled
import com.philkes.notallyx.utils.getExtraBooleanFromBundleOrIntent
import com.philkes.notallyx.utils.getLastExceptionLog import com.philkes.notallyx.utils.getLastExceptionLog
import com.philkes.notallyx.utils.getLogFile import com.philkes.notallyx.utils.getLogFile
import com.philkes.notallyx.utils.getUriForFile import com.philkes.notallyx.utils.getUriForFile
import com.philkes.notallyx.utils.reportBug import com.philkes.notallyx.utils.reportBug
import com.philkes.notallyx.utils.security.disableBiometricLock
import com.philkes.notallyx.utils.security.encryptDatabase
import com.philkes.notallyx.utils.security.showBiometricOrPinPrompt import com.philkes.notallyx.utils.security.showBiometricOrPinPrompt
import com.philkes.notallyx.utils.wrapWithChooser import com.philkes.notallyx.utils.wrapWithChooser
import java.util.Date import java.util.Date
@ -98,27 +97,6 @@ class SettingsFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
setupActivityResultLaunchers() setupActivityResultLaunchers()
val showImportBackupsFolder =
getExtraBooleanFromBundleOrIntent(
savedInstanceState,
EXTRA_SHOW_IMPORT_BACKUPS_FOLDER,
false,
)
showImportBackupsFolder.let {
if (it) {
model.refreshBackupsFolder(
requireContext(),
askForUriPermissions = ::askForUriPermissions,
)
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
if (model.showRefreshBackupsFolderAfterThemeChange) {
outState.putBoolean(EXTRA_SHOW_IMPORT_BACKUPS_FOLDER, true)
}
} }
private fun setupActivityResultLaunchers() { private fun setupActivityResultLaunchers() {
@ -193,12 +171,11 @@ class SettingsFragment : Fragment() {
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) { if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri -> result.data?.data?.let { uri ->
model.importPreferences( if (
requireContext(), model.importPreferences(requireContext(), uri, ::askForUriPermissions)
uri,
::askForUriPermissions,
{ showToast(R.string.import_settings_success) },
) { ) {
showToast(R.string.import_settings_success)
} else {
showToast(R.string.import_settings_failure) showToast(R.string.import_settings_failure)
} }
} }
@ -247,27 +224,9 @@ class SettingsFragment : Fragment() {
} }
} }
theme.merge(useDynamicColors).observe(viewLifecycleOwner) { theme.observe(viewLifecycleOwner) { value ->
(themeValue, useDynamicColorsValue) -> binding.Theme.setup(theme, value, requireContext()) { newValue ->
binding.Theme.setup( model.savePreference(theme, newValue)
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)
} }
} }
@ -300,29 +259,14 @@ class SettingsFragment : Fragment() {
model, model,
) )
} }
// TODO: Hide for now until checked auto-sort is working reliably
listItemSorting.observe(viewLifecycleOwner) { value -> // listItemSorting.observe(viewLifecycleOwner) { value ->
binding.CheckedListItemSorting.setup(listItemSorting, value, requireContext()) { // binding.CheckedListItemSorting.setup(ListItemSorting, value)
newValue -> // }
model.savePreference(listItemSorting, newValue)
}
}
binding.MaxLabels.setup(maxLabels, requireContext()) { newValue -> binding.MaxLabels.setup(maxLabels, requireContext()) { newValue ->
model.savePreference(maxLabels, newValue) model.savePreference(maxLabels, newValue)
} }
startView.merge(model.labels).observe(viewLifecycleOwner) { (startViewValue, labelsValue) ->
binding.StartView.setupStartView(
startView,
startViewValue,
labelsValue,
requireContext(),
layoutInflater,
) { newValue ->
model.savePreference(startView, newValue)
}
}
} }
private fun NotallyXPreferences.setupContentDensity(binding: FragmentSettingsBinding) { private fun NotallyXPreferences.setupContentDensity(binding: FragmentSettingsBinding) {
@ -337,29 +281,15 @@ class SettingsFragment : Fragment() {
MaxLines.setup(maxLines, requireContext()) { newValue -> MaxLines.setup(maxLines, requireContext()) { newValue ->
model.savePreference(maxLines, newValue) model.savePreference(maxLines, newValue)
} }
MaxLabels.setup(maxLabels, requireContext()) { newValue -> labelsHiddenInOverview.observe(viewLifecycleOwner) { value ->
model.savePreference(maxLabels, newValue)
}
labelTagsHiddenInOverview.observe(viewLifecycleOwner) { value ->
binding.LabelsHiddenInOverview.setup( binding.LabelsHiddenInOverview.setup(
labelTagsHiddenInOverview, labelsHiddenInOverview,
value, value,
requireContext(), requireContext(),
layoutInflater, layoutInflater,
R.string.labels_hidden_in_overview, R.string.labels_hidden_in_overview,
) { enabled -> ) { enabled ->
model.savePreference(labelTagsHiddenInOverview, enabled) model.savePreference(labelsHiddenInOverview, enabled)
}
}
imagesHiddenInOverview.observe(viewLifecycleOwner) { value ->
binding.ImagesHiddenInOverview.setup(
imagesHiddenInOverview,
value,
requireContext(),
layoutInflater,
R.string.images_hidden_in_overview,
) { enabled ->
model.savePreference(imagesHiddenInOverview, enabled)
} }
} }
} }
@ -452,7 +382,7 @@ class SettingsFragment : Fragment() {
when (selectedImportSource.mimeType) { when (selectedImportSource.mimeType) {
FOLDER_OR_FILE_MIMETYPE -> FOLDER_OR_FILE_MIMETYPE ->
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle(selectedImportSource.displayNameResId) .setTitle(R.string.plain_text_files)
.setItems( .setItems(
arrayOf( arrayOf(
getString(R.string.folder), getString(R.string.folder),
@ -534,17 +464,9 @@ class SettingsFragment : Fragment() {
enabled = backupFolder != EMPTY_PATH, enabled = backupFolder != EMPTY_PATH,
) { enabled -> ) { enabled ->
if (enabled) { if (enabled) {
val periodInDays =
preference.value.periodInDays.let {
if (it >= BACKUP_PERIOD_DAYS_MIN) it else BACKUP_PERIOD_DAYS_MIN
}
val maxBackups =
preference.value.maxBackups.let {
if (it >= BACKUP_MAX_MIN) it else BACKUP_MAX_MIN
}
model.savePreference( model.savePreference(
preference, preference,
preference.value.copy(periodInDays = periodInDays, maxBackups = maxBackups), preference.value.copy(periodInDays = BACKUP_PERIOD_DAYS_MIN),
) )
} else { } else {
model.savePreference(preference, preference.value.copy(periodInDays = 0)) model.savePreference(preference, preference.value.copy(periodInDays = 0))
@ -604,14 +526,6 @@ class SettingsFragment : Fragment() {
model.savePreference(backupPassword, newValue) model.savePreference(backupPassword, newValue)
} }
} }
secureFlag.observe(viewLifecycleOwner) { value ->
binding.SecureFlag.setup(secureFlag, value, requireContext(), layoutInflater) { newValue
->
model.savePreference(secureFlag, newValue)
activity?.setEnabledSecureFlag(newValue)
}
}
} }
private fun NotallyXPreferences.setupSettings(binding: FragmentSettingsBinding) { private fun NotallyXPreferences.setupSettings(binding: FragmentSettingsBinding) {
@ -644,7 +558,8 @@ class SettingsFragment : Fragment() {
} }
ResetSettings.setOnClickListener { ResetSettings.setOnClickListener {
showDialog(R.string.reset_settings_message, R.string.reset_settings) { _, _ -> showDialog(R.string.reset_settings_message, R.string.reset_settings) { _, _ ->
model.resetPreferences { _ -> showToast(R.string.reset_settings_success) } model.resetPreferences()
showToast(R.string.reset_settings_success)
} }
} }
dataInPublicFolder.observe(viewLifecycleOwner) { value -> dataInPublicFolder.observe(viewLifecycleOwner) { value ->
@ -662,11 +577,6 @@ class SettingsFragment : Fragment() {
} }
} }
} }
AutoSaveAfterIdle.setupAutoSaveIdleTime(autoSaveAfterIdleTime, requireContext()) {
newValue ->
model.savePreference(autoSaveAfterIdleTime, newValue)
}
ClearData.setOnClickListener { ClearData.setOnClickListener {
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.clear_data_message) .setMessage(R.string.clear_data_message)
@ -681,14 +591,34 @@ class SettingsFragment : Fragment() {
private fun setupAbout(binding: FragmentSettingsBinding) { private fun setupAbout(binding: FragmentSettingsBinding) {
binding.apply { binding.apply {
SendFeedback.setOnClickListener { SendFeedback.setOnClickListener {
val intent =
Intent(Intent.ACTION_SEND)
.apply {
selector = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:"))
putExtra(Intent.EXTRA_EMAIL, arrayOf("notallyx@yahoo.com"))
putExtra(Intent.EXTRA_SUBJECT, "NotallyX [Feedback]")
val app = requireContext().applicationContext as Application
val log = app.getLogFile()
if (log.exists()) {
val uri = app.getUriForFile(log)
putExtra(Intent.EXTRA_STREAM, uri)
}
}
.wrapWithChooser(requireContext())
try {
startActivity(intent)
} catch (exception: ActivityNotFoundException) {
showToast(R.string.install_an_email)
}
}
CreateIssue.setOnClickListener {
val options = val options =
arrayOf( arrayOf(
getString(R.string.report_bug), getString(R.string.report_bug),
getString(R.string.make_feature_request), getString(R.string.make_feature_request),
getString(R.string.send_feedback),
) )
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.send_feedback) .setTitle(R.string.create_github_issue)
.setItems(options) { _, which -> .setItems(options) { _, which ->
when (which) { when (which) {
0 -> { 0 -> {
@ -697,7 +627,7 @@ class SettingsFragment : Fragment() {
reportBug(logs) reportBug(logs)
} }
1 -> else ->
requireContext().catchNoBrowserInstalled { requireContext().catchNoBrowserInstalled {
startActivity( startActivity(
Intent( Intent(
@ -709,40 +639,11 @@ class SettingsFragment : Fragment() {
.wrapWithChooser(requireContext()) .wrapWithChooser(requireContext())
) )
} }
2 -> {
val intent =
Intent(Intent.ACTION_SEND)
.apply {
selector =
Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:"))
putExtra(
Intent.EXTRA_EMAIL,
arrayOf("notallyx@yahoo.com"),
)
putExtra(Intent.EXTRA_SUBJECT, "NotallyX [Feedback]")
val app =
requireContext().applicationContext as Application
val log = app.getLogFile()
if (log.exists()) {
val uri = app.getUriForFile(log)
putExtra(Intent.EXTRA_STREAM, uri)
}
}
.wrapWithChooser(requireContext())
try {
startActivity(intent)
} catch (exception: ActivityNotFoundException) {
showToast(R.string.install_an_email)
}
}
} }
} }
.setCancelButton() .setCancelButton()
.show() .show()
} }
Rate.setOnClickListener {
openLink("https://play.google.com/store/apps/details?id=com.philkes.notallyx")
}
SourceCode.setOnClickListener { openLink("https://github.com/PhilKes/NotallyX") } SourceCode.setOnClickListener { openLink("https://github.com/PhilKes/NotallyX") }
Libraries.setOnClickListener { Libraries.setOnClickListener {
val libraries = val libraries =
@ -756,7 +657,6 @@ class SettingsFragment : Fragment() {
"SQLCipher", "SQLCipher",
"Zip4J", "Zip4J",
"AndroidFastScroll", "AndroidFastScroll",
"ColorPickerView",
) )
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.libraries) .setTitle(R.string.libraries)
@ -780,13 +680,11 @@ class SettingsFragment : Fragment() {
6 -> openLink("https://github.com/sqlcipher/sqlcipher") 6 -> openLink("https://github.com/sqlcipher/sqlcipher")
7 -> openLink("https://github.com/srikanth-lingala/zip4j") 7 -> openLink("https://github.com/srikanth-lingala/zip4j")
8 -> openLink("https://github.com/zhanghai/AndroidFastScroll") 8 -> openLink("https://github.com/zhanghai/AndroidFastScroll")
9 -> openLink("https://github.com/skydoves/ColorPickerView")
} }
} }
.setCancelButton() .setCancelButton()
.show() .show()
} }
Donate.setOnClickListener { openLink("https://ko-fi.com/philkes") }
try { try {
val pInfo = val pInfo =
@ -812,7 +710,14 @@ class SettingsFragment : Fragment() {
R.string.enable_lock_description, R.string.enable_lock_description,
onSuccess = { cipher -> onSuccess = { cipher ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
model.enableBiometricLock(cipher) model.savePreference(model.preferences.iv, cipher.iv)
val passphrase = model.preferences.databaseEncryptionKey.init(cipher)
encryptDatabase(requireContext(), passphrase)
model.savePreference(
model.preferences.fallbackDatabaseEncryptionKey,
passphrase,
)
model.savePreference(model.preferences.biometricLock, BiometricLock.ENABLED)
} }
val app = (activity?.application as NotallyXApplication) val app = (activity?.application as NotallyXApplication)
app.locked.value = false app.locked.value = false
@ -832,7 +737,7 @@ class SettingsFragment : Fragment() {
model.preferences.iv.value!!, model.preferences.iv.value!!,
onSuccess = { cipher -> onSuccess = { cipher ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
model.disableBiometricLock(cipher) requireContext().disableBiometricLock(model, cipher)
} }
showToast(R.string.biometrics_disable_success) showToast(R.string.biometrics_disable_success)
}, },
@ -868,9 +773,4 @@ class SettingsFragment : Fragment() {
} }
) )
} }
companion object {
const val EXTRA_SHOW_IMPORT_BACKUPS_FOLDER =
"notallyx.intent.extra.SHOW_IMPORT_BACKUPS_FOLDER"
}
} }

View file

@ -8,20 +8,15 @@ import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.Settings import android.provider.Settings
import android.text.Editable import android.text.Editable
import android.util.Log
import android.util.TypedValue import android.util.TypedValue
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.View.GONE import android.view.View.GONE
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams import android.view.ViewGroup.LayoutParams
import android.view.ViewGroup.VISIBLE import android.view.ViewGroup.VISIBLE
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.ImageButton
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
@ -37,18 +32,13 @@ import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.data.NotallyDatabase
import com.philkes.notallyx.data.model.Audio import com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.data.model.FileAttachment import com.philkes.notallyx.data.model.FileAttachment
import com.philkes.notallyx.data.model.Folder import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.data.model.Type import com.philkes.notallyx.data.model.Type
import com.philkes.notallyx.data.model.isImageMimeType import com.philkes.notallyx.data.model.toText
import com.philkes.notallyx.databinding.ActivityEditBinding import com.philkes.notallyx.databinding.ActivityEditBinding
import com.philkes.notallyx.presentation.activity.LockedActivity import com.philkes.notallyx.presentation.activity.LockedActivity
import com.philkes.notallyx.presentation.activity.main.MainActivity
import com.philkes.notallyx.presentation.activity.main.MainActivity.Companion.EXTRA_FRAGMENT_TO_OPEN
import com.philkes.notallyx.presentation.activity.main.MainActivity.Companion.EXTRA_SKIP_START_VIEW_ON_BACK
import com.philkes.notallyx.presentation.activity.main.fragment.DisplayLabelFragment.Companion.EXTRA_DISPLAYED_LABEL import com.philkes.notallyx.presentation.activity.main.fragment.DisplayLabelFragment.Companion.EXTRA_DISPLAYED_LABEL
import com.philkes.notallyx.presentation.activity.note.SelectLabelsActivity.Companion.EXTRA_SELECTED_LABELS import com.philkes.notallyx.presentation.activity.note.SelectLabelsActivity.Companion.EXTRA_SELECTED_LABELS
import com.philkes.notallyx.presentation.activity.note.reminders.RemindersActivity import com.philkes.notallyx.presentation.activity.note.reminders.RemindersActivity
@ -56,16 +46,15 @@ import com.philkes.notallyx.presentation.add
import com.philkes.notallyx.presentation.addFastScroll import com.philkes.notallyx.presentation.addFastScroll
import com.philkes.notallyx.presentation.addIconButton import com.philkes.notallyx.presentation.addIconButton
import com.philkes.notallyx.presentation.bindLabels import com.philkes.notallyx.presentation.bindLabels
import com.philkes.notallyx.presentation.displayEditLabelDialog
import com.philkes.notallyx.presentation.displayFormattedTimestamp import com.philkes.notallyx.presentation.displayFormattedTimestamp
import com.philkes.notallyx.presentation.extractColor import com.philkes.notallyx.presentation.extractColor
import com.philkes.notallyx.presentation.getQuantityString import com.philkes.notallyx.presentation.getQuantityString
import com.philkes.notallyx.presentation.hideKeyboard
import com.philkes.notallyx.presentation.isLightColor import com.philkes.notallyx.presentation.isLightColor
import com.philkes.notallyx.presentation.setCancelButton import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews
import com.philkes.notallyx.presentation.setLightStatusAndNavBar import com.philkes.notallyx.presentation.setLightStatusAndNavBar
import com.philkes.notallyx.presentation.setupProgressDialog import com.philkes.notallyx.presentation.setupProgressDialog
import com.philkes.notallyx.presentation.showColorSelectDialog
import com.philkes.notallyx.presentation.showKeyboard import com.philkes.notallyx.presentation.showKeyboard
import com.philkes.notallyx.presentation.showToast import com.philkes.notallyx.presentation.showToast
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
@ -78,28 +67,21 @@ import com.philkes.notallyx.presentation.view.note.action.MoreNoteBottomSheet
import com.philkes.notallyx.presentation.view.note.audio.AudioAdapter import com.philkes.notallyx.presentation.view.note.audio.AudioAdapter
import com.philkes.notallyx.presentation.view.note.preview.PreviewFileAdapter import com.philkes.notallyx.presentation.view.note.preview.PreviewFileAdapter
import com.philkes.notallyx.presentation.view.note.preview.PreviewImageAdapter import com.philkes.notallyx.presentation.view.note.preview.PreviewImageAdapter
import com.philkes.notallyx.presentation.viewmodel.ExportMimeType
import com.philkes.notallyx.presentation.viewmodel.NotallyModel import com.philkes.notallyx.presentation.viewmodel.NotallyModel
import com.philkes.notallyx.presentation.viewmodel.preference.DateFormat import com.philkes.notallyx.presentation.viewmodel.preference.DateFormat
import com.philkes.notallyx.presentation.viewmodel.preference.ListItemSort
import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortBy import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortBy
import com.philkes.notallyx.presentation.widget.WidgetProvider import com.philkes.notallyx.presentation.widget.WidgetProvider
import com.philkes.notallyx.utils.FileError import com.philkes.notallyx.utils.FileError
import com.philkes.notallyx.utils.backup.exportNotes
import com.philkes.notallyx.utils.changehistory.ChangeHistory import com.philkes.notallyx.utils.changehistory.ChangeHistory
import com.philkes.notallyx.utils.getMimeType
import com.philkes.notallyx.utils.getUriForFile import com.philkes.notallyx.utils.getUriForFile
import com.philkes.notallyx.utils.log import com.philkes.notallyx.utils.log
import com.philkes.notallyx.utils.mergeSkipFirst import com.philkes.notallyx.utils.mergeSkipFirst
import com.philkes.notallyx.utils.observeSkipFirst import com.philkes.notallyx.utils.observeSkipFirst
import com.philkes.notallyx.utils.shareNote import com.philkes.notallyx.utils.shareNote
import com.philkes.notallyx.utils.showColorSelectDialog
import com.philkes.notallyx.utils.wrapWithChooser import com.philkes.notallyx.utils.wrapWithChooser
import java.io.File import java.io.File
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
abstract class EditActivity(private val type: Type) : abstract class EditActivity(private val type: Type) :
LockedActivity<ActivityEditBinding>(), AddActions, MoreActions { LockedActivity<ActivityEditBinding>(), AddActions, MoreActions {
@ -111,8 +93,6 @@ abstract class EditActivity(private val type: Type) :
private lateinit var selectLabelsActivityResultLauncher: ActivityResultLauncher<Intent> private lateinit var selectLabelsActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var playAudioActivityResultLauncher: ActivityResultLauncher<Intent> private lateinit var playAudioActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var attachFilesActivityResultLauncher: ActivityResultLauncher<Intent> private lateinit var attachFilesActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var exportNotesActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var exportFileActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var pinMenuItem: MenuItem private lateinit var pinMenuItem: MenuItem
protected var search = Search() protected var search = Search()
@ -126,38 +106,17 @@ abstract class EditActivity(private val type: Type) :
protected var colorInt: Int = -1 protected var colorInt: Int = -1
protected var inputMethodManager: InputMethodManager? = null protected var inputMethodManager: InputMethodManager? = null
protected lateinit var toggleViewMode: ImageButton
protected val canEdit
get() = notallyModel.viewMode.value == NoteViewMode.EDIT
private val autoSaveHandler = Handler(Looper.getMainLooper())
private val autoSaveRunnable = Runnable {
lifecycleScope.launch(Dispatchers.Main) {
updateModel()
if (notallyModel.isModified()) {
Log.d(TAG, "Auto-saving note...")
saveNote(checkAutoSave = false)
}
}
}
override fun finish() { override fun finish() {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
if (notallyModel.isEmpty()) { if (notallyModel.isEmpty()) {
notallyModel.deleteBaseNote(checkAutoSave = false) notallyModel.deleteBaseNote(checkAutoSave = false)
} else if (notallyModel.isModified()) { } else if (notallyModel.isModified()) {
saveNote() saveNote()
} else {
notallyModel.checkBackupOnSave()
} }
super.finish() super.finish()
} }
} }
protected open fun updateModel() {
notallyModel.modifiedTimestamp = System.currentTimeMillis()
}
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
outState.putLong("id", notallyModel.id) outState.putLong("id", notallyModel.id)
@ -166,9 +125,9 @@ abstract class EditActivity(private val type: Type) :
} }
} }
open suspend fun saveNote(checkAutoSave: Boolean = true) { open suspend fun saveNote() {
updateModel() notallyModel.modifiedTimestamp = System.currentTimeMillis()
notallyModel.saveNote(checkAutoSave) notallyModel.saveNote()
WidgetProvider.sendBroadcast(application, longArrayOf(notallyModel.id)) WidgetProvider.sendBroadcast(application, longArrayOf(notallyModel.id))
} }
@ -185,18 +144,14 @@ abstract class EditActivity(private val type: Type) :
val persistedId = savedInstanceState?.getLong("id") val persistedId = savedInstanceState?.getLong("id")
val selectedId = intent.getLongExtra(EXTRA_SELECTED_BASE_NOTE, 0L) val selectedId = intent.getLongExtra(EXTRA_SELECTED_BASE_NOTE, 0L)
val id = persistedId ?: selectedId val id = persistedId ?: selectedId
if (persistedId == null || notallyModel.originalNote == null) { if (persistedId == null) {
notallyModel.setState(id) notallyModel.setState(id)
} }
if (notallyModel.isNewNote) { if (notallyModel.isNewNote && intent.action == Intent.ACTION_SEND) {
when (intent.action) { handleSharedNote()
Intent.ACTION_SEND, } else if (notallyModel.isNewNote) {
Intent.ACTION_SEND_MULTIPLE -> handleSharedNote() intent.getStringExtra(EXTRA_DISPLAYED_LABEL)?.let {
Intent.ACTION_VIEW -> handleViewNote() notallyModel.setLabels(listOf(it))
else ->
intent.getStringExtra(EXTRA_DISPLAYED_LABEL)?.let {
notallyModel.setLabels(listOf(it))
}
} }
} }
@ -212,43 +167,6 @@ abstract class EditActivity(private val type: Type) :
} }
setupActivityResultLaunchers() setupActivityResultLaunchers()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
updateModel()
runBlocking(Dispatchers.IO) { saveNote() }
} catch (e: Exception) {
log(TAG, msg = "Saving note on Crash failed", throwable = e)
} finally {
// Let the system handle the crash
DEFAULT_EXCEPTION_HANDLER?.uncaughtException(thread, throwable)
}
}
}
open fun toggleCanEdit(mode: NoteViewMode) {
binding.EnterTitle.apply {
if (isFocused) {
when {
mode == NoteViewMode.EDIT -> showKeyboard(this)
else -> hideKeyboard(this)
}
}
setCanEdit(mode == NoteViewMode.EDIT)
}
}
override fun onDestroy() {
autoSaveHandler.removeCallbacks(autoSaveRunnable)
super.onDestroy()
}
protected fun resetIdleTimer() {
autoSaveHandler.removeCallbacks(autoSaveRunnable)
val idleTime = preferences.autoSaveAfterIdleTime.value
if (idleTime > -1) {
autoSaveHandler.postDelayed(autoSaveRunnable, idleTime.toLong() * 1000)
}
} }
private fun setupActivityResultLaunchers() { private fun setupActivityResultLaunchers() {
@ -292,7 +210,10 @@ abstract class EditActivity(private val type: Type) :
selectLabelsActivityResultLauncher = selectLabelsActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) { if (result.resultCode == RESULT_OK) {
val list = result.data?.getStringArrayListExtra(EXTRA_SELECTED_LABELS) val list =
result.data?.getStringArrayListExtra(
SelectLabelsActivity.EXTRA_SELECTED_LABELS
)
if (list != null && list != notallyModel.labels) { if (list != null && list != notallyModel.labels) {
notallyModel.setLabels(list) notallyModel.setLabels(list)
binding.LabelGroup.bindLabels( binding.LabelGroup.bindLabels(
@ -301,7 +222,6 @@ abstract class EditActivity(private val type: Type) :
paddingTop = true, paddingTop = true,
colorInt, colorInt,
) )
resetIdleTimer()
} }
} }
} }
@ -336,21 +256,6 @@ abstract class EditActivity(private val type: Type) :
} }
} }
} }
exportFileActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri -> baseModel.exportSelectedFileToUri(uri) }
}
}
exportNotesActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri ->
baseModel.exportNotesToFolder(uri, listOf(notallyModel.getBaseNote()))
}
}
}
} }
override fun onRequestPermissionsResult( override fun onRequestPermissionsResult(
@ -376,7 +281,6 @@ abstract class EditActivity(private val type: Type) :
ChangeHistory().apply { ChangeHistory().apply {
canUndo.observe(this@EditActivity) { canUndo -> undo?.isEnabled = canUndo } canUndo.observe(this@EditActivity) { canUndo -> undo?.isEnabled = canUndo }
canRedo.observe(this@EditActivity) { canRedo -> redo?.isEnabled = canRedo } canRedo.observe(this@EditActivity) { canRedo -> redo?.isEnabled = canRedo }
stackPointer.observe(this@EditActivity) { _ -> resetIdleTimer() }
} }
} }
@ -390,6 +294,26 @@ abstract class EditActivity(private val type: Type) :
pinMenuItem = pinMenuItem =
add(R.string.pin, R.drawable.pin, MenuItem.SHOW_AS_ACTION_ALWAYS) { pin() } add(R.string.pin, R.drawable.pin, MenuItem.SHOW_AS_ACTION_ALWAYS) { pin() }
bindPinned() bindPinned()
when (notallyModel.folder) {
Folder.NOTES -> {
add(R.string.delete, R.drawable.delete, MenuItem.SHOW_AS_ACTION_ALWAYS) {
delete()
}
}
Folder.DELETED -> {
add(R.string.restore, R.drawable.restore, MenuItem.SHOW_AS_ACTION_ALWAYS) {
restore()
}
}
Folder.ARCHIVED -> {
add(R.string.unarchive, R.drawable.unarchive, MenuItem.SHOW_AS_ACTION_ALWAYS) {
restore()
}
}
}
} }
search.results.mergeSkipFirst(search.resultPos).observe(this) { (amount, pos) -> search.results.mergeSkipFirst(search.resultPos).observe(this) { (amount, pos) ->
@ -505,19 +429,7 @@ abstract class EditActivity(private val type: Type) :
binding.BottomAppBarCenter.apply { binding.BottomAppBarCenter.apply {
removeAllViews() removeAllViews()
undo = undo =
addIconButton( addIconButton(R.string.undo, R.drawable.undo, marginStart = 2) {
R.string.undo,
R.drawable.undo,
marginStart = 2,
onLongClick = {
try {
changeHistory.undoAll()
} catch (e: ChangeHistory.ChangeHistoryException) {
application.log(TAG, throwable = e)
}
true
},
) {
try { try {
changeHistory.undo() changeHistory.undo()
} catch (e: ChangeHistory.ChangeHistoryException) { } catch (e: ChangeHistory.ChangeHistoryException) {
@ -527,19 +439,7 @@ abstract class EditActivity(private val type: Type) :
.apply { isEnabled = changeHistory.canUndo.value } .apply { isEnabled = changeHistory.canUndo.value }
redo = redo =
addIconButton( addIconButton(R.string.redo, R.drawable.redo, marginStart = 2) {
R.string.redo,
R.drawable.redo,
marginStart = 2,
onLongClick = {
try {
changeHistory.redoAll()
} catch (e: ChangeHistory.ChangeHistoryException) {
application.log(TAG, throwable = e)
}
true
},
) {
try { try {
changeHistory.redo() changeHistory.redo()
} catch (e: ChangeHistory.ChangeHistoryException) { } catch (e: ChangeHistory.ChangeHistoryException) {
@ -550,132 +450,41 @@ abstract class EditActivity(private val type: Type) :
} }
binding.BottomAppBarRight.apply { binding.BottomAppBarRight.apply {
removeAllViews() removeAllViews()
addIconButton(R.string.more, R.drawable.more_vert, marginStart = 0) {
addToggleViewMode() MoreNoteBottomSheet(this@EditActivity, createFolderActions(), colorInt)
addIconButton(R.string.tap_for_more_options, R.drawable.more_vert, marginStart = 0) {
MoreNoteBottomSheet(
this@EditActivity,
createNoteTypeActions() + createFolderActions(),
colorInt,
)
.show(supportFragmentManager, MoreNoteBottomSheet.TAG) .show(supportFragmentManager, MoreNoteBottomSheet.TAG)
} }
} }
setBottomAppBarColor(colorInt) setBottomAppBarColor(colorInt)
} }
protected fun ViewGroup.addToggleViewMode() {
toggleViewMode =
addIconButton(R.string.edit, R.drawable.visibility) {
notallyModel.viewMode.value =
when (notallyModel.viewMode.value) {
NoteViewMode.EDIT -> NoteViewMode.READ_ONLY
NoteViewMode.READ_ONLY -> NoteViewMode.EDIT
}
}
}
protected fun createFolderActions() = protected fun createFolderActions() =
when (notallyModel.folder) { when (notallyModel.folder) {
Folder.NOTES -> Folder.NOTES ->
listOf( listOf(
Action(R.string.archive, R.drawable.archive) { _ -> Action(R.string.archive, R.drawable.archive, callback = ::archive),
archive() Action(R.string.delete, R.drawable.delete, callback = ::delete),
true
},
Action(R.string.delete, R.drawable.delete) { _ ->
delete()
true
},
) )
Folder.DELETED -> Folder.DELETED ->
listOf( listOf(
Action(R.string.delete_forever, R.drawable.delete) { _ -> Action(R.string.delete_forever, R.drawable.delete, callback = ::deleteForever),
deleteForever() Action(R.string.restore, R.drawable.restore, callback = ::restore),
true
},
Action(R.string.restore, R.drawable.restore) { _ ->
restore()
true
},
) )
Folder.ARCHIVED -> Folder.ARCHIVED ->
listOf( listOf(
Action(R.string.delete, R.drawable.delete) { _ -> Action(R.string.delete, R.drawable.delete, callback = ::delete),
delete() Action(R.string.unarchive, R.drawable.unarchive, callback = ::restore),
true
},
Action(R.string.unarchive, R.drawable.unarchive) { _ ->
restore()
true
},
) )
} }
protected fun createNoteTypeActions() =
when (notallyModel.type) {
Type.NOTE ->
listOf(
Action(R.string.convert_to_list_note, R.drawable.convert_to_text) { _ ->
convertTo(Type.LIST)
true
}
)
Type.LIST ->
listOf(
Action(R.string.convert_to_text_note, R.drawable.convert_to_text) { _ ->
convertTo(Type.NOTE)
true
}
)
}
private fun convertTo(type: Type) {
updateModel()
lifecycleScope.launch {
notallyModel.convertTo(type)
val intent =
Intent(
this@EditActivity,
when (type) {
Type.NOTE -> EditNoteActivity::class.java
Type.LIST -> EditListActivity::class.java
},
)
intent.putExtra(EXTRA_SELECTED_BASE_NOTE, notallyModel.id)
startActivity(intent)
finish()
}
}
abstract fun configureUI() abstract fun configureUI()
open fun setupListeners() { open fun setupListeners() {
binding.EnterTitle.initHistory(changeHistory) { text -> binding.EnterTitle.initHistory(changeHistory) { text ->
notallyModel.title = text.trim().toString() notallyModel.title = text.trim().toString()
} }
notallyModel.viewMode.observe(this) { value ->
toggleViewMode.apply {
setImageResource(
when (value) {
NoteViewMode.READ_ONLY -> R.drawable.edit
else -> R.drawable.visibility
}
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
tooltipText =
getString(
when (value) {
NoteViewMode.READ_ONLY -> R.string.edit
else -> R.string.read_only
}
)
}
}
value?.let { toggleCanEdit(it) }
}
} }
open fun setStateFromModel(savedInstanceState: Bundle?) { open fun setStateFromModel(savedInstanceState: Bundle?) {
@ -692,90 +501,26 @@ abstract class EditActivity(private val type: Type) :
} else DateFormat.ABSOLUTE } else DateFormat.ABSOLUTE
binding.Date.displayFormattedTimestamp(date, dateFormat, datePrefixResId) binding.Date.displayFormattedTimestamp(date, dateFormat, datePrefixResId)
binding.EnterTitle.setText(notallyModel.title) binding.EnterTitle.setText(notallyModel.title)
bindLabels()
setColor()
}
private fun bindLabels() {
binding.LabelGroup.bindLabels( binding.LabelGroup.bindLabels(
notallyModel.labels, notallyModel.labels,
notallyModel.textSize, notallyModel.textSize,
paddingTop = true, paddingTop = true,
colorInt, colorInt,
onClick = { label ->
val bundle = Bundle()
bundle.putString(EXTRA_DISPLAYED_LABEL, label)
startActivity(
Intent(this, MainActivity::class.java).apply {
putExtra(EXTRA_FRAGMENT_TO_OPEN, R.id.DisplayLabel)
putExtra(EXTRA_DISPLAYED_LABEL, label)
putExtra(EXTRA_SKIP_START_VIEW_ON_BACK, true)
}
)
},
onLongClick = { label ->
displayEditLabelDialog(label, baseModel) { oldLabel, newLabel ->
notallyModel.labels.apply {
remove(oldLabel)
add(newLabel)
}
bindLabels()
}
},
) )
setColor()
} }
private fun handleSharedNote() { private fun handleSharedNote() {
val title = intent.getStringExtra(Intent.EXTRA_SUBJECT) val title = intent.getStringExtra(Intent.EXTRA_SUBJECT)
val string = intent.getStringExtra(Intent.EXTRA_TEXT) val string = intent.getStringExtra(Intent.EXTRA_TEXT)
val files =
IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
?: IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
?.let { listOf(it) }
if (string != null) { if (string != null) {
notallyModel.body = Editable.Factory.getInstance().newEditable(string) notallyModel.body = Editable.Factory.getInstance().newEditable(string)
} }
if (title != null) { if (title != null) {
notallyModel.title = title notallyModel.title = title
} }
files?.let {
val filesByType =
it.groupBy { uri ->
getMimeType(uri)?.let { mimeType ->
if (mimeType.isImageMimeType) {
NotallyModel.FileType.IMAGE
} else {
NotallyModel.FileType.ANY
}
} ?: NotallyModel.FileType.ANY
}
filesByType[NotallyModel.FileType.IMAGE]?.let { images ->
notallyModel.addImages(images.toTypedArray())
}
filesByType[NotallyModel.FileType.ANY]?.let { otherFiles ->
notallyModel.addFiles(otherFiles.toTypedArray())
}
}
}
private fun handleViewNote() {
val text =
intent.data?.let { uri ->
contentResolver.openInputStream(uri)?.use { inputStream ->
inputStream.bufferedReader().readText()
}
?: run {
showToast(R.string.cant_load_file)
null
}
} ?: intent.getStringExtra(Intent.EXTRA_TEXT)
val title = intent.getStringExtra(Intent.EXTRA_SUBJECT)
if (text != null) {
notallyModel.body = Editable.Factory.getInstance().newEditable(text)
}
if (title != null) {
notallyModel.title = title
}
} }
@RequiresApi(24) @RequiresApi(24)
@ -844,38 +589,9 @@ abstract class EditActivity(private val type: Type) :
} }
override fun changeColor() { override fun changeColor() {
lifecycleScope.launch { showColorSelectDialog(colorInt.isLightColor()) { selectedColor ->
val colors = notallyModel.color = selectedColor
withContext(Dispatchers.IO) { setColor()
NotallyDatabase.getDatabase(this@EditActivity, observePreferences = false)
.value
.getBaseNoteDao()
.getAllColors()
}
.toMutableList()
if (colors.none { it == notallyModel.color }) {
colors.add(notallyModel.color)
}
showColorSelectDialog(
colors,
notallyModel.color,
colorInt.isLightColor(),
{ selectedColor, oldColor ->
if (oldColor != null) {
baseModel.changeColor(oldColor, selectedColor)
}
notallyModel.color = selectedColor
setColor()
resetIdleTimer()
},
) { colorToDelete, newColor ->
baseModel.changeColor(colorToDelete, newColor)
if (colorToDelete == notallyModel.color) {
notallyModel.color = newColor
setColor()
}
resetIdleTimer()
}
} }
} }
@ -892,16 +608,12 @@ abstract class EditActivity(private val type: Type) :
} }
override fun share() { override fun share() {
this.shareNote(notallyModel.getBaseNote()) val body =
} when (type) {
Type.NOTE -> notallyModel.body
override fun export(mimeType: ExportMimeType) { Type.LIST -> notallyModel.items.toMutableList().toText()
exportNotes( }
mimeType, this.shareNote(notallyModel.title, body)
listOf(notallyModel.getBaseNote()),
exportFileActivityResultLauncher,
exportNotesActivityResultLauncher,
)
} }
private fun delete() { private fun delete() {
@ -1095,9 +807,7 @@ abstract class EditActivity(private val type: Type) :
colorInt = extractColor(notallyModel.color) colorInt = extractColor(notallyModel.color)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
window.statusBarColor = colorInt window.statusBarColor = colorInt
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { window.navigationBarColor = colorInt
window.navigationBarColor = colorInt
}
window.setLightStatusAndNavBar(colorInt.isLightColor()) window.setLightStatusAndNavBar(colorInt.isLightColor())
} }
binding.apply { binding.apply {
@ -1106,8 +816,7 @@ abstract class EditActivity(private val type: Type) :
setControlsContrastColorForAllViews(colorInt) setControlsContrastColorForAllViews(colorInt)
} }
root.setBackgroundColor(colorInt) root.setBackgroundColor(colorInt)
MainListView.setBackgroundColor(colorInt) RecyclerView.setBackgroundColor(colorInt)
CheckedListView.setBackgroundColor(colorInt)
Toolbar.backgroundTintList = ColorStateList.valueOf(colorInt) Toolbar.backgroundTintList = ColorStateList.valueOf(colorInt)
Toolbar.setControlsContrastColorForAllViews(colorInt) Toolbar.setControlsContrastColorForAllViews(colorInt)
} }
@ -1129,15 +838,10 @@ abstract class EditActivity(private val type: Type) :
when (type) { when (type) {
Type.NOTE -> { Type.NOTE -> {
binding.AddItem.visibility = GONE binding.AddItem.visibility = GONE
binding.MainListView.visibility = GONE binding.RecyclerView.visibility = GONE
binding.CheckedListView.visibility = GONE
} }
Type.LIST -> { Type.LIST -> {
binding.EnterBody.visibility = GONE binding.EnterBody.visibility = GONE
binding.CheckedListView.visibility =
if (preferences.listItemSorting.value == ListItemSort.AUTO_SORT_BY_CHECKED)
VISIBLE
else GONE
} }
} }
@ -1192,7 +896,5 @@ abstract class EditActivity(private val type: Type) :
const val EXTRA_NOTE_ID = "notallyx.intent.extra.NOTE_ID" const val EXTRA_NOTE_ID = "notallyx.intent.extra.NOTE_ID"
const val EXTRA_FOLDER_FROM = "notallyx.intent.extra.FOLDER_FROM" const val EXTRA_FOLDER_FROM = "notallyx.intent.extra.FOLDER_FROM"
const val EXTRA_FOLDER_TO = "notallyx.intent.extra.FOLDER_TO" const val EXTRA_FOLDER_TO = "notallyx.intent.extra.FOLDER_TO"
val DEFAULT_EXCEPTION_HANDLER = Thread.getDefaultUncaughtExceptionHandler()
} }
} }

View file

@ -1,64 +1,42 @@
package com.philkes.notallyx.presentation.activity.note package com.philkes.notallyx.presentation.activity.note
import android.os.Bundle import android.os.Bundle
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SortedList
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.data.model.Type import com.philkes.notallyx.data.model.Type
import com.philkes.notallyx.presentation.addIconButton import com.philkes.notallyx.presentation.addIconButton
import com.philkes.notallyx.presentation.hideKeyboardOnFocusedItem
import com.philkes.notallyx.presentation.setOnNextAction import com.philkes.notallyx.presentation.setOnNextAction
import com.philkes.notallyx.presentation.showKeyboardOnFocusedItem
import com.philkes.notallyx.presentation.view.note.action.MoreListActions import com.philkes.notallyx.presentation.view.note.action.MoreListActions
import com.philkes.notallyx.presentation.view.note.action.MoreListBottomSheet import com.philkes.notallyx.presentation.view.note.action.MoreListBottomSheet
import com.philkes.notallyx.presentation.view.note.listitem.HighlightText import com.philkes.notallyx.presentation.view.note.listitem.ListItemAdapter
import com.philkes.notallyx.presentation.view.note.listitem.ListItemVH
import com.philkes.notallyx.presentation.view.note.listitem.ListManager import com.philkes.notallyx.presentation.view.note.listitem.ListManager
import com.philkes.notallyx.presentation.view.note.listitem.adapter.CheckedListItemAdapter import com.philkes.notallyx.presentation.view.note.listitem.sorting.ListItemNoSortCallback
import com.philkes.notallyx.presentation.view.note.listitem.adapter.ListItemAdapter import com.philkes.notallyx.presentation.view.note.listitem.sorting.ListItemSortedByCheckedCallback
import com.philkes.notallyx.presentation.view.note.listitem.adapter.ListItemHighlight import com.philkes.notallyx.presentation.view.note.listitem.sorting.ListItemSortedList
import com.philkes.notallyx.presentation.view.note.listitem.adapter.ListItemVH import com.philkes.notallyx.presentation.view.note.listitem.sorting.indices
import com.philkes.notallyx.presentation.view.note.listitem.init import com.philkes.notallyx.presentation.view.note.listitem.sorting.mapIndexed
import com.philkes.notallyx.presentation.view.note.listitem.setItems import com.philkes.notallyx.presentation.view.note.listitem.sorting.toMutableList
import com.philkes.notallyx.presentation.view.note.listitem.sorting.ListItemParentSortCallback import com.philkes.notallyx.presentation.viewmodel.preference.ListItemSort
import com.philkes.notallyx.presentation.view.note.listitem.sorting.SortedItemsList
import com.philkes.notallyx.presentation.view.note.listitem.splitByChecked
import com.philkes.notallyx.presentation.view.note.listitem.toMutableList
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.autoSortByCheckedEnabled
import com.philkes.notallyx.utils.findAllOccurrences import com.philkes.notallyx.utils.findAllOccurrences
import com.philkes.notallyx.utils.indices
import com.philkes.notallyx.utils.mapIndexed
import java.util.concurrent.atomic.AtomicInteger
class EditListActivity : EditActivity(Type.LIST), MoreListActions { class EditListActivity : EditActivity(Type.LIST), MoreListActions {
private var adapter: ListItemAdapter? = null private var adapter: ListItemAdapter? = null
private var adapterChecked: CheckedListItemAdapter? = null private lateinit var items: ListItemSortedList
private val items: MutableList<ListItem>
get() = adapter!!.items
private var itemsChecked: SortedItemsList? = null
private lateinit var listManager: ListManager private lateinit var listManager: ListManager
override fun finish() { override fun finish() {
notallyModel.setItems(items.toMutableList() + (itemsChecked?.toMutableList() ?: listOf())) notallyModel.setItems(items.toMutableList())
super.finish() super.finish()
} }
override fun updateModel() {
super.updateModel()
notallyModel.setItems(items.toMutableList() + (itemsChecked?.toMutableList() ?: listOf()))
}
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
updateModel() notallyModel.setItems(items.toMutableList())
binding.MainListView.focusedChild?.let { focusedChild -> binding.RecyclerView.focusedChild?.let { focusedChild ->
val viewHolder = binding.MainListView.findContainingViewHolder(focusedChild) val viewHolder = binding.RecyclerView.findContainingViewHolder(focusedChild)
if (viewHolder is ListItemVH) { if (viewHolder is ListItemVH) {
val itemPos = binding.MainListView.getChildAdapterPosition(focusedChild) val itemPos = binding.RecyclerView.getChildAdapterPosition(focusedChild)
if (itemPos > -1) { if (itemPos > -1) {
val (selectionStart, selectionEnd) = viewHolder.getSelection() val (selectionStart, selectionEnd) = viewHolder.getSelection()
outState.apply { outState.apply {
@ -73,21 +51,6 @@ class EditListActivity : EditActivity(Type.LIST), MoreListActions {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
} }
override fun toggleCanEdit(mode: NoteViewMode) {
super.toggleCanEdit(mode)
when (mode) {
NoteViewMode.EDIT -> binding.MainListView.showKeyboardOnFocusedItem()
NoteViewMode.READ_ONLY -> binding.MainListView.hideKeyboardOnFocusedItem()
}
adapter?.viewMode = mode
adapterChecked?.viewMode = mode
binding.AddItem.visibility =
when (mode) {
NoteViewMode.EDIT -> View.VISIBLE
else -> View.GONE
}
}
override fun deleteChecked() { override fun deleteChecked() {
listManager.deleteCheckedItems() listManager.deleteCheckedItems()
} }
@ -104,112 +67,52 @@ class EditListActivity : EditActivity(Type.LIST), MoreListActions {
super.initBottomMenu() super.initBottomMenu()
binding.BottomAppBarRight.apply { binding.BottomAppBarRight.apply {
removeAllViews() removeAllViews()
addToggleViewMode() addIconButton(R.string.more, R.drawable.more_vert, marginStart = 0) {
addIconButton(R.string.tap_for_more_options, R.drawable.more_vert, marginStart = 0) { MoreListBottomSheet(this@EditListActivity, createFolderActions(), colorInt)
MoreListBottomSheet(
this@EditListActivity,
createNoteTypeActions() + createFolderActions(),
colorInt,
)
.show(supportFragmentManager, MoreListBottomSheet.TAG) .show(supportFragmentManager, MoreListBottomSheet.TAG)
} }
} }
setBottomAppBarColor(colorInt) setBottomAppBarColor(colorInt)
} }
private fun SortedList<ListItem>.highlightSearch(
search: String,
adapter: HighlightText?,
resultPosCounter: AtomicInteger,
alreadyNotifiedItemPos: MutableSet<Int>,
): Int {
return mapIndexed { idx, item ->
val occurrences = item.body.findAllOccurrences(search)
occurrences.onEach { (startIdx, endIdx) ->
adapter?.highlightText(
ListItemHighlight(
idx,
resultPosCounter.getAndIncrement(),
startIdx,
endIdx,
false,
)
)
}
if (occurrences.isNotEmpty()) {
alreadyNotifiedItemPos.add(idx)
}
occurrences.size
}
.sum()
}
private fun List<ListItem>.highlightSearch(
search: String,
adapter: ListItemAdapter?,
resultPosCounter: AtomicInteger,
alreadyNotifiedItemPos: MutableSet<Int>,
): Int {
return mapIndexed { idx, item ->
val occurrences = item.body.findAllOccurrences(search)
occurrences.onEach { (startIdx, endIdx) ->
adapter?.highlightText(
ListItemHighlight(
idx,
resultPosCounter.getAndIncrement(),
startIdx,
endIdx,
false,
)
)
}
if (occurrences.isNotEmpty()) {
alreadyNotifiedItemPos.add(idx)
}
occurrences.size
}
.sum()
}
override fun highlightSearchResults(search: String): Int { override fun highlightSearchResults(search: String): Int {
val resultPosCounter = AtomicInteger(0) var resultPos = 0
val alreadyNotifiedItemPos = mutableSetOf<Int>() val alreadyNotifiedItemPos = mutableSetOf<Int>()
adapter?.clearHighlights() adapter?.clearHighlights()
adapterChecked?.clearHighlights()
val amount = val amount =
items.highlightSearch(search, adapter, resultPosCounter, alreadyNotifiedItemPos) + items
(itemsChecked?.highlightSearch( .mapIndexed { idx, item ->
search, val occurrences = item.body.findAllOccurrences(search)
adapterChecked, occurrences.onEach { (startIdx, endIdx) ->
resultPosCounter, adapter?.highlightText(
alreadyNotifiedItemPos, ListItemAdapter.ListItemHighlight(
) ?: 0) idx,
resultPos++,
startIdx,
endIdx,
false,
)
)
}
if (occurrences.isNotEmpty()) {
alreadyNotifiedItemPos.add(idx)
}
occurrences.size
}
.sum()
items.indices items.indices
.filter { !alreadyNotifiedItemPos.contains(it) } .filter { !alreadyNotifiedItemPos.contains(it) }
.forEach { adapter?.notifyItemChanged(it) } .forEach { adapter?.notifyItemChanged(it) }
itemsChecked
?.indices
?.filter { !alreadyNotifiedItemPos.contains(it) }
?.forEach { adapter?.notifyItemChanged(it) }
return amount return amount
} }
override fun selectSearchResult(resultPos: Int) { override fun selectSearchResult(resultPos: Int) {
var selectedItemPos = adapter!!.selectHighlight(resultPos) val selectedItemPos = adapter!!.selectHighlight(resultPos)
if (selectedItemPos == -1 && adapterChecked != null) { if (selectedItemPos != -1) {
selectedItemPos = adapterChecked!!.selectHighlight(resultPos) binding.RecyclerView.post {
if (selectedItemPos != -1) { binding.RecyclerView.findViewHolderForAdapterPosition(selectedItemPos)
binding.CheckedListView.scrollToItemPosition(selectedItemPos) ?.itemView
} ?.let { binding.ScrollView.scrollTo(0, binding.RecyclerView.top + it.top) }
} else if (selectedItemPos != -1) {
binding.MainListView.scrollToItemPosition(selectedItemPos)
}
}
private fun RecyclerView.scrollToItemPosition(position: Int) {
post {
findViewHolderForAdapterPosition(position)?.itemView?.let {
binding.ScrollView.scrollTo(0, top + it.top)
} }
} }
} }
@ -232,7 +135,7 @@ class EditListActivity : EditActivity(Type.LIST), MoreListActions {
val elevation = resources.displayMetrics.density * 2 val elevation = resources.displayMetrics.density * 2
listManager = listManager =
ListManager( ListManager(
binding.MainListView, binding.RecyclerView,
changeHistory, changeHistory,
preferences, preferences,
inputMethodManager, inputMethodManager,
@ -253,39 +156,25 @@ class EditListActivity : EditActivity(Type.LIST), MoreListActions {
elevation, elevation,
NotallyXPreferences.getInstance(application), NotallyXPreferences.getInstance(application),
listManager, listManager,
false,
binding.ScrollView,
) )
val initializedItems = notallyModel.items.init(true) val sortCallback =
if (preferences.autoSortByCheckedEnabled) { when (preferences.listItemSorting.value) {
val (checkedItems, uncheckedItems) = initializedItems.splitByChecked() ListItemSort.AUTO_SORT_BY_CHECKED -> ListItemSortedByCheckedCallback(adapter)
adapter?.submitList(uncheckedItems.toMutableList()) else -> ListItemNoSortCallback(adapter)
adapterChecked = }
CheckedListItemAdapter( items = ListItemSortedList(sortCallback)
colorInt, if (sortCallback is ListItemSortedByCheckedCallback) {
notallyModel.textSize, sortCallback.setList(items)
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())
} }
listManager.init(adapter!!, itemsChecked, adapterChecked) items.init(notallyModel.items)
binding.MainListView.adapter = adapter adapter?.setList(items)
binding.RecyclerView.adapter = adapter
listManager.adapter = adapter!!
listManager.initList(items)
savedInstanceState?.let { savedInstanceState?.let {
val itemPos = it.getInt(EXTRA_ITEM_POS, -1) val itemPos = it.getInt(EXTRA_ITEM_POS, -1)
if (itemPos > -1) { if (itemPos > -1) {
binding.MainListView.apply { binding.RecyclerView.apply {
post { post {
scrollToPosition(itemPos) scrollToPosition(itemPos)
val viewHolder = findViewHolderForLayoutPosition(itemPos) val viewHolder = findViewHolderForLayoutPosition(itemPos)
@ -307,7 +196,6 @@ class EditListActivity : EditActivity(Type.LIST), MoreListActions {
override fun setColor() { override fun setColor() {
super.setColor() super.setColor()
adapter?.setBackgroundColor(colorInt) adapter?.setBackgroundColor(colorInt)
adapterChecked?.setBackgroundColor(colorInt)
} }
companion object { companion object {

View file

@ -21,12 +21,10 @@ import android.widget.LinearLayout
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.data.model.Type import com.philkes.notallyx.data.model.Type
import com.philkes.notallyx.data.model.createNoteUrl import com.philkes.notallyx.data.model.createNoteUrl
import com.philkes.notallyx.data.model.getNoteIdFromUrl import com.philkes.notallyx.data.model.getNoteIdFromUrl
@ -40,9 +38,7 @@ import com.philkes.notallyx.presentation.activity.note.PickNoteActivity.Companio
import com.philkes.notallyx.presentation.activity.note.PickNoteActivity.Companion.EXTRA_PICKED_NOTE_TYPE import com.philkes.notallyx.presentation.activity.note.PickNoteActivity.Companion.EXTRA_PICKED_NOTE_TYPE
import com.philkes.notallyx.presentation.add import com.philkes.notallyx.presentation.add
import com.philkes.notallyx.presentation.addIconButton import com.philkes.notallyx.presentation.addIconButton
import com.philkes.notallyx.presentation.createBoldSpan
import com.philkes.notallyx.presentation.dp import com.philkes.notallyx.presentation.dp
import com.philkes.notallyx.presentation.hideKeyboard
import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews
import com.philkes.notallyx.presentation.setOnNextAction import com.philkes.notallyx.presentation.setOnNextAction
import com.philkes.notallyx.presentation.showKeyboard import com.philkes.notallyx.presentation.showKeyboard
@ -69,6 +65,8 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
override fun configureUI() { override fun configureUI() {
binding.EnterTitle.setOnNextAction { binding.EnterBody.requestFocus() } binding.EnterTitle.setOnNextAction { binding.EnterBody.requestFocus() }
setupEditor()
if (notallyModel.isNewNote) { if (notallyModel.isNewNote) {
binding.EnterBody.requestFocus() binding.EnterBody.requestFocus()
} }
@ -80,17 +78,6 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
setupActivityResultLaunchers() setupActivityResultLaunchers()
} }
override fun toggleCanEdit(mode: NoteViewMode) {
super.toggleCanEdit(mode)
textFormatMenu.isVisible = mode == NoteViewMode.EDIT
when {
mode == NoteViewMode.EDIT -> showKeyboard(binding.EnterBody)
binding.EnterBody.isFocused -> hideKeyboard(binding.EnterBody)
}
binding.EnterBody.setCanEdit(mode == NoteViewMode.EDIT)
setupEditor()
}
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
outState.apply { outState.apply {
@ -185,8 +172,78 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
private fun setupEditor() { private fun setupEditor() {
setupMovementMethod() setupMovementMethod()
binding.EnterBody.customSelectionActionModeCallback = binding.EnterBody.customSelectionActionModeCallback =
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, 0, showAsAction = MenuItem.SHOW_AS_ACTION_NEVER) {
binding.EnterBody.showAddLinkDialog(
this@EditNoteActivity,
mode = mode,
)
}
add(R.string.bold, 0, showAsAction = MenuItem.SHOW_AS_ACTION_NEVER) {
binding.EnterBody.applySpan(StyleSpan(Typeface.BOLD))
mode?.finish()
}
add(R.string.italic, 0, showAsAction = MenuItem.SHOW_AS_ACTION_NEVER) {
binding.EnterBody.applySpan(StyleSpan(Typeface.ITALIC))
mode?.finish()
}
add(
R.string.monospace,
0,
showAsAction = MenuItem.SHOW_AS_ACTION_NEVER,
) {
binding.EnterBody.applySpan(TypefaceSpan("monospace"))
mode?.finish()
}
add(
R.string.strikethrough,
0,
showAsAction = MenuItem.SHOW_AS_ACTION_NEVER,
) {
binding.EnterBody.applySpan(StrikethroughSpan())
mode?.finish()
}
add(
R.string.clear_formatting,
0,
showAsAction = MenuItem.SHOW_AS_ACTION_NEVER,
) {
binding.EnterBody.clearFormatting()
mode?.finish()
}
}
} catch (exception: Exception) {
exception.printStackTrace()
}
return true
}
override fun onDestroyActionMode(mode: ActionMode?) {
binding.EnterBody.isActionModeOn = false
}
}
binding.ContentLayout.setOnClickListener {
binding.EnterBody.apply {
requestFocus()
setSelection(length())
showKeyboard(this)
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding.EnterBody.customInsertionActionModeCallback =
object : ActionMode.Callback { object : ActionMode.Callback {
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?) = false override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?) = false
@ -198,54 +255,8 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
// ActionMode implementation // ActionMode implementation
try { try {
menu?.apply { menu?.apply {
add( add(R.string.link_note, 0, order = Menu.CATEGORY_CONTAINER + 1) {
R.string.link, linkNote(pickNoteNewActivityResultLauncher)
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()
} }
} }
@ -259,69 +270,26 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
binding.EnterBody.isActionModeOn = false binding.EnterBody.isActionModeOn = false
} }
} }
} else null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding.EnterBody.customInsertionActionModeCallback =
if (canEdit) {
object : ActionMode.Callback {
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?) = false
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?) = false
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
binding.EnterBody.isActionModeOn = true
// Try block is there because this will crash on MiUI as Xiaomi has a
// broken
// ActionMode implementation
try {
menu?.apply {
add(
R.string.link_note,
0,
order = Menu.CATEGORY_CONTAINER + 1,
) {
linkNote(pickNoteNewActivityResultLauncher)
mode?.finish()
}
}
} catch (exception: Exception) {
exception.printStackTrace()
}
return true
}
override fun onDestroyActionMode(mode: ActionMode?) {
binding.EnterBody.isActionModeOn = false
}
}
} else null
} }
if (canEdit) { binding.EnterBody.setOnSelectionChange { selStart, selEnd ->
binding.EnterBody.setOnSelectionChange { selStart, selEnd -> if (selEnd - selStart > 0) {
if (selEnd - selStart > 0) { if (!textFormatMenu.isEnabled) {
if (!textFormatMenu.isEnabled) { initBottomTextFormattingMenu()
initBottomTextFormattingMenu()
}
textFormatMenu.isEnabled = true
textFormattingAdapter?.updateTextFormattingToggles(selStart, selEnd)
} else {
if (textFormatMenu.isEnabled) {
initBottomMenu()
}
textFormatMenu.isEnabled = false
} }
textFormatMenu.isEnabled = true
textFormattingAdapter?.updateTextFormattingToggles(selStart, selEnd)
} else {
if (textFormatMenu.isEnabled) {
initBottomMenu()
}
textFormatMenu.isEnabled = false
} }
} else {
binding.EnterBody.setOnSelectionChange { _, _ -> }
} }
binding.ContentLayout.setOnClickListener { binding.ContentLayout.setOnClickListener {
binding.EnterBody.apply { binding.EnterBody.apply {
requestFocus() requestFocus()
if (canEdit) { setSelection(length())
setSelection(length()) showKeyboard(this)
showKeyboard(this)
}
} }
} }
} }
@ -358,7 +326,7 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
updateLayoutParams<LinearLayout.LayoutParams> { updateLayoutParams<LinearLayout.LayoutParams> {
marginEnd = 0 marginEnd = 0
marginStart = 10.dp marginStart = 10.dp(context)
} }
setControlsContrastColorForAllViews(extractColor) setControlsContrastColorForAllViews(extractColor)
setBackgroundColor(0) setBackgroundColor(0)
@ -372,7 +340,7 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
} }
requestLayout() requestLayout()
val layout = BottomTextFormattingMenuBinding.inflate(layoutInflater, this, false) val layout = BottomTextFormattingMenuBinding.inflate(layoutInflater, this, false)
layout.MainListView.apply { layout.RecyclerView.apply {
textFormattingAdapter = textFormattingAdapter =
TextFormattingAdapter(this@EditNoteActivity, binding.EnterBody, colorInt) TextFormattingAdapter(this@EditNoteActivity, binding.EnterBody, colorInt)
adapter = textFormattingAdapter adapter = textFormattingAdapter
@ -398,23 +366,19 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
val movementMethod = LinkMovementMethod { span -> val movementMethod = LinkMovementMethod { span ->
val items = val items =
if (span.url.isNoteUrl()) { if (span.url.isNoteUrl()) {
if (canEdit) { arrayOf(
arrayOf( getString(R.string.remove_link),
getString(R.string.open_note), getString(R.string.change_note),
getString(R.string.remove_link), getString(R.string.edit),
getString(R.string.change_note), getString(R.string.open_note),
getString(R.string.edit), )
)
} else arrayOf(getString(R.string.open_note))
} else { } else {
if (canEdit) { arrayOf(
arrayOf( getString(R.string.remove_link),
getString(R.string.open_link), getString(R.string.copy),
getString(R.string.copy), getString(R.string.edit),
getString(R.string.remove_link), getString(R.string.open_link),
getString(R.string.edit), )
)
} else arrayOf(getString(R.string.open_link), getString(R.string.copy))
} }
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this)
.setTitle( .setTitle(
@ -426,16 +390,35 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
) )
.setItems(items) { _, which -> .setItems(items) { _, which ->
when (which) { when (which) {
0 -> openLink(span) 0 -> {
binding.EnterBody.removeSpanWithHistory(
span,
span.url.isNoteUrl() ||
span.url == binding.EnterBody.getSpanText(span),
)
}
1 -> 1 ->
if (span.url.isNoteUrl()) { if (span.url.isNoteUrl()) {
removeLink(span) selectedSpan = span
} else copyLink(span) linkNote(pickNoteUpdateActivityResultLauncher)
2 -> } else {
if (span.url.isNoteUrl()) { copyToClipBoard(span.url)
changeNoteLink(span) showToast(R.string.copied_link)
} else removeLink(span) }
3 -> editLink(span)
2 -> {
binding.EnterBody.showEditDialog(span)
}
3 -> {
span.url?.let {
if (it.isNoteUrl()) {
span.navigateToNote()
} else {
openLink(span.url)
}
}
}
} }
} }
.show() .show()
@ -443,37 +426,6 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
binding.EnterBody.movementMethod = movementMethod binding.EnterBody.movementMethod = movementMethod
} }
private fun openLink(span: URLSpan) {
span.url?.let {
if (it.isNoteUrl()) {
span.navigateToNote()
} else {
openLink(span.url)
}
}
}
private fun editLink(span: URLSpan) {
binding.EnterBody.showEditDialog(span)
}
private fun changeNoteLink(span: URLSpan) {
selectedSpan = span
linkNote(pickNoteUpdateActivityResultLauncher)
}
private fun copyLink(span: URLSpan) {
copyToClipBoard(span.url)
showToast(R.string.copied_link)
}
private fun removeLink(span: URLSpan) {
binding.EnterBody.removeSpanWithHistory(
span,
span.url.isNoteUrl() || span.url == binding.EnterBody.getSpanText(span),
)
}
private fun openLink(url: String) { private fun openLink(url: String) {
val uri = Uri.parse(url) val uri = Uri.parse(url)
val intent = Intent(Intent.ACTION_VIEW, uri).wrapWithChooser(this) val intent = Intent(Intent.ACTION_VIEW, uri).wrapWithChooser(this)

View file

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

View file

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

View file

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

View file

@ -102,7 +102,7 @@ class RemindersActivity : LockedActivity<ActivityRemindersBinding>(), ReminderLi
private fun setupRecyclerView() { private fun setupRecyclerView() {
reminderAdapter = ReminderAdapter(this) reminderAdapter = ReminderAdapter(this)
binding.MainListView.apply { binding.RecyclerView.apply {
initListView(this@RemindersActivity) initListView(this@RemindersActivity)
adapter = reminderAdapter adapter = reminderAdapter
} }

View file

@ -10,7 +10,6 @@ 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
@ -100,9 +99,7 @@ class BaseNoteAdapter(
NotesSortBy.TITLE -> BaseNoteTitleSort(this@BaseNoteAdapter, sortDirection) NotesSortBy.TITLE -> BaseNoteTitleSort(this@BaseNoteAdapter, sortDirection)
NotesSortBy.MODIFIED_DATE -> NotesSortBy.MODIFIED_DATE ->
BaseNoteModifiedDateSort(this@BaseNoteAdapter, sortDirection) BaseNoteModifiedDateSort(this@BaseNoteAdapter, sortDirection)
NotesSortBy.CREATION_DATE -> else -> BaseNoteCreationDateSort(this@BaseNoteAdapter, sortDirection)
BaseNoteCreationDateSort(this@BaseNoteAdapter, sortDirection)
NotesSortBy.COLOR -> BaseNoteColorSort(this@BaseNoteAdapter, sortDirection)
} }
private fun replaceSortCallback(sortCallback: SortedListAdapterCallback<Item>) { private fun replaceSortCallback(sortCallback: SortedListAdapterCallback<Item>) {

View file

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

View file

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

View file

@ -1,15 +1,9 @@
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.R import com.philkes.notallyx.data.model.Color
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.databinding.RecyclerColorBinding import com.philkes.notallyx.databinding.RecyclerColorBinding
import com.philkes.notallyx.presentation.dp
import com.philkes.notallyx.presentation.extractColor import com.philkes.notallyx.presentation.extractColor
import com.philkes.notallyx.presentation.getColorFromAttr
import com.philkes.notallyx.presentation.getContrastFontColor
import com.philkes.notallyx.presentation.view.misc.ItemListener import com.philkes.notallyx.presentation.view.misc.ItemListener
class ColorVH(private val binding: RecyclerColorBinding, listener: ItemListener) : class ColorVH(private val binding: RecyclerColorBinding, listener: ItemListener) :
@ -17,40 +11,11 @@ class ColorVH(private val binding: RecyclerColorBinding, listener: ItemListener)
init { init {
binding.CardView.setOnClickListener { listener.onClick(absoluteAdapterPosition) } binding.CardView.setOnClickListener { listener.onClick(absoluteAdapterPosition) }
binding.CardView.setOnLongClickListener {
listener.onLongClick(absoluteAdapterPosition)
true
}
} }
fun bind(color: String, isSelected: Boolean) { fun bind(color: Color) {
val showAddIcon = color == BaseNote.COLOR_NEW val value = binding.root.context.extractColor(color)
val context = binding.root.context binding.CardView.setCardBackgroundColor(value)
val value = binding.CardView.contentDescription = color.name
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,17 +0,0 @@
package com.philkes.notallyx.presentation.view.main.sorting
import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection
class BaseNoteColorSort(adapter: RecyclerView.Adapter<*>?, sortDirection: SortDirection) :
ItemSort(adapter, sortDirection) {
override fun compare(note1: BaseNote, note2: BaseNote, sortDirection: SortDirection): Int {
val sort =
note1.compareColor(note2).takeIf { it != 0 } ?: return -note1.compareModified(note2)
return if (sortDirection == SortDirection.ASC) sort else -1 * sort
}
}
fun BaseNote.compareColor(other: BaseNote) = color.compareTo(other.color)

View file

@ -5,12 +5,10 @@ import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection
class BaseNoteCreationDateSort(adapter: RecyclerView.Adapter<*>?, sortDirection: SortDirection) : class BaseNoteCreationDateSort(adapter: RecyclerView.Adapter<*>?, sortDirection: SortDirection) :
ItemSort(adapter, sortDirection) { BaseNoteSort(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.compareCreated(note2) val sort = note1.timestamp.compareTo(note2.timestamp)
return if (sortDirection == SortDirection.ASC) sort else -1 * sort return if (sortDirection == SortDirection.ASC) sort else -1 * sort
} }
} }
fun BaseNote.compareCreated(other: BaseNote) = timestamp.compareTo(other.timestamp)

View file

@ -5,12 +5,10 @@ import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection
class BaseNoteModifiedDateSort(adapter: RecyclerView.Adapter<*>?, sortDirection: SortDirection) : class BaseNoteModifiedDateSort(adapter: RecyclerView.Adapter<*>?, sortDirection: SortDirection) :
ItemSort(adapter, sortDirection) { BaseNoteSort(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.compareModified(note2) val sort = note1.modifiedTimestamp.compareTo(note2.modifiedTimestamp)
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

@ -7,7 +7,7 @@ import com.philkes.notallyx.data.model.Header
import com.philkes.notallyx.data.model.Item import com.philkes.notallyx.data.model.Item
import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection
abstract class ItemSort( abstract class BaseNoteSort(
adapter: RecyclerView.Adapter<*>?, adapter: RecyclerView.Adapter<*>?,
private val sortDirection: SortDirection, private val sortDirection: SortDirection,
) : SortedListAdapterCallback<Item>(adapter) { ) : SortedListAdapterCallback<Item>(adapter) {

View file

@ -5,12 +5,10 @@ import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection
class BaseNoteTitleSort(adapter: RecyclerView.Adapter<*>?, sortDirection: SortDirection) : class BaseNoteTitleSort(adapter: RecyclerView.Adapter<*>?, sortDirection: SortDirection) :
ItemSort(adapter, sortDirection) { BaseNoteSort(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.compareTitle(note2) val sort = note1.title.compareTo(note2.title)
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

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

View file

@ -24,9 +24,9 @@ class TextWithIconAdapter<T>(
} }
val item = getItem(position)!! val item = getItem(position)!!
setCompoundDrawablesRelativeWithIntrinsicBounds(getIconResId(item), 0, 0, 0) setCompoundDrawablesRelativeWithIntrinsicBounds(getIconResId(item), 0, 0, 0)
setPaddingRelative(30.dp, paddingTop, paddingEnd, paddingBottom) setPaddingRelative(30.dp(context), paddingTop, paddingEnd, paddingBottom)
text = getText(item) text = getText(item)
compoundDrawablePadding = 10.dp compoundDrawablePadding = 10.dp(context)
} }
} }
} }

View file

@ -49,7 +49,7 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
ContextCompat.getDrawable(context, R.drawable.checkbox_partial) ContextCompat.getDrawable(context, R.drawable.checkbox_partial)
init { init {
compoundDrawablePadding = 4.dp compoundDrawablePadding = 4.dp(context)
buttonDrawable = getCurrentDrawable() buttonDrawable = getCurrentDrawable()
setOnClickListener { toggleState() } setOnClickListener { toggleState() }
} }

View file

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

View file

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

View file

@ -8,12 +8,10 @@ import android.view.ViewGroup
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.widget.NestedScrollView
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.philkes.notallyx.R
import com.philkes.notallyx.databinding.BottomSheetActionBinding import com.philkes.notallyx.databinding.BottomSheetActionBinding
import com.philkes.notallyx.presentation.dp import com.philkes.notallyx.presentation.dp
import com.philkes.notallyx.presentation.getColorFromAttr import com.philkes.notallyx.presentation.getColorFromAttr
@ -27,52 +25,41 @@ open class ActionBottomSheet(
@ColorInt private val color: Int? = null, @ColorInt private val color: Int? = null,
) : BottomSheetDialogFragment() { ) : BottomSheetDialogFragment() {
lateinit var layout: LinearLayout
lateinit var inflater: LayoutInflater
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle?, savedInstanceState: Bundle?,
): View { ): View {
this.inflater = inflater val view =
val scrollView =
NestedScrollView(requireContext()).apply {
layoutParams =
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
)
isFillViewport = true
}
layout =
LinearLayout(context).apply { LinearLayout(context).apply {
layoutParams = layoutParams =
LinearLayout.LayoutParams( LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.MATCH_PARENT,
) )
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
setPadding(8.dp, 18.dp, 8.dp, 8.dp) setPadding(8.dp(context), 18.dp(context), 8.dp(context), 8.dp(context))
} }
scrollView.addView(layout)
actions.forEach { action -> actions.forEach { action ->
if (action.showDividerAbove) { if (action.showDividerAbove) {
val divider = val divider =
View(context).apply { View(context).apply {
layoutParams = layoutParams =
LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 1) LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 1)
.apply { setMargins(8.dp, 0, 8.dp, 8.dp) } .apply {
setMargins(8.dp(context), 0, 8.dp(context), 8.dp(context))
}
setBackgroundColor( setBackgroundColor(
context.getColorFromAttr( context.getColorFromAttr(
com.google.android.material.R.attr.colorOnSurfaceVariant com.google.android.material.R.attr.colorOnSurfaceVariant
) )
) )
} }
layout.addView(divider) view.addView(divider)
} }
val textView = val textView =
BottomSheetActionBinding.inflate(inflater, layout, false).root.apply { BottomSheetActionBinding.inflate(inflater, view, false).root.apply {
text = getString(action.labelResId) text = getString(action.labelResId)
setCompoundDrawablesWithIntrinsicBounds( setCompoundDrawablesWithIntrinsicBounds(
ContextCompat.getDrawable(context, action.drawableResId), ContextCompat.getDrawable(context, action.drawableResId),
@ -81,62 +68,34 @@ open class ActionBottomSheet(
null, null,
) )
setOnClickListener { setOnClickListener {
if (action.callback(this@ActionBottomSheet)) { action.callback()
dismiss() hide()
}
} }
} }
layout.addView(textView) view.addView(textView)
} color?.let {
view.apply {
color?.let { setBackgroundColor(it)
layout.apply { setControlsContrastColorForAllViews(it, overwriteBackground = false)
setBackgroundColor(it) }
setControlsContrastColorForAllViews(it, overwriteBackground = false)
} }
} }
return scrollView
return view
} }
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = val dialog = BottomSheetDialog(requireContext(), R.style.ThemeOverlay_App_BottomSheetDialog)
BottomSheetDialog(
requireContext(),
com.philkes.notallyx.R.style.ThemeOverlay_App_BottomSheetDialog,
)
color?.let { color?.let {
dialog.window?.apply { dialog.window?.apply {
navigationBarColor = it navigationBarColor = it
setLightStatusAndNavBar(it.isLightColor()) setLightStatusAndNavBar(it.isLightColor())
} }
} }
dialog.setOnShowListener {
dialog.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)?.let {
bottomSheet ->
BottomSheetBehavior.from(bottomSheet).apply {
state = BottomSheetBehavior.STATE_EXPANDED
isHideable = false
// Disable dragging changes to allow nested scroll
setBottomSheetCallback(
object : BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_HIDDEN) {
dismiss()
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
state = BottomSheetBehavior.STATE_EXPANDED
}
}
)
}
}
}
return dialog return dialog
} }
fun hide() { private fun BottomSheetDialogFragment.hide() {
(dialog as? BottomSheetDialog)?.behavior?.state = STATE_HIDDEN (dialog as? BottomSheetDialog)?.behavior?.state = STATE_HIDDEN
} }
} }
@ -145,10 +104,5 @@ data class Action(
val labelResId: Int, val labelResId: Int,
val drawableResId: Int, val drawableResId: Int,
val showDividerAbove: Boolean = false, val showDividerAbove: Boolean = false,
/** val callback: () -> Unit,
* On click callback.
*
* @returns whether or not the BottomSheet should be hidden
*/
val callback: (actionBottomSheet: ActionBottomSheet) -> Boolean,
) )

View file

@ -13,20 +13,13 @@ class AddBottomSheet(callbacks: AddActions, @ColorInt color: Int?) :
fun createActions(callbacks: AddActions) = fun createActions(callbacks: AddActions) =
listOf( listOf(
Action(R.string.add_images, R.drawable.add_images) { _ -> Action(R.string.add_images, R.drawable.add_images) { callbacks.addImages() },
callbacks.addImages() Action(R.string.attach_file, R.drawable.text_file) { callbacks.attachFiles() },
true
},
Action(R.string.attach_file, R.drawable.text_file) { _ ->
callbacks.attachFiles()
true
},
) + ) +
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
listOf( listOf(
Action(R.string.record_audio, R.drawable.record_audio) { _ -> Action(R.string.record_audio, R.drawable.record_audio) {
callbacks.recordAudio() callbacks.recordAudio()
true
} }
) )
else listOf() else listOf()

View file

@ -12,12 +12,7 @@ class AddNoteBottomSheet(callbacks: AddNoteActions, @ColorInt color: Int?) :
fun createActions(callbacks: AddNoteActions) = fun createActions(callbacks: AddNoteActions) =
AddBottomSheet.createActions(callbacks) + AddBottomSheet.createActions(callbacks) +
listOf( listOf(Action(R.string.link_note, R.drawable.notebook) { callbacks.linkNote() })
Action(R.string.link_note, R.drawable.notebook) { _ ->
callbacks.linkNote()
true
}
)
} }
} }

View file

@ -20,17 +20,14 @@ class MoreListBottomSheet(
R.string.delete_checked_items, R.string.delete_checked_items,
R.drawable.delete_all, R.drawable.delete_all,
showDividerAbove = true, showDividerAbove = true,
) { _ -> ) {
callbacks.deleteChecked() callbacks.deleteChecked()
true
}, },
Action(R.string.check_all_items, R.drawable.checkbox_checked) { _ -> Action(R.string.check_all_items, R.drawable.checkbox_checked) {
callbacks.checkAll() callbacks.checkAll()
true
}, },
Action(R.string.uncheck_all_items, R.drawable.checkbox_unchecked) { _ -> Action(R.string.uncheck_all_items, R.drawable.checkbox_unchecked) {
callbacks.uncheckAll() callbacks.uncheckAll()
true
}, },
) )
} }

View file

@ -2,8 +2,6 @@ package com.philkes.notallyx.presentation.view.note.action
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.databinding.BottomSheetActionBinding
import com.philkes.notallyx.presentation.viewmodel.ExportMimeType
/** BottomSheet inside list-note for all common note actions. */ /** BottomSheet inside list-note for all common note actions. */
class MoreNoteBottomSheet( class MoreNoteBottomSheet(
@ -17,38 +15,12 @@ class MoreNoteBottomSheet(
internal fun createActions(callbacks: MoreActions, additionalActions: Collection<Action>) = internal fun createActions(callbacks: MoreActions, additionalActions: Collection<Action>) =
listOf( listOf(
Action(R.string.share, R.drawable.share) { _ -> Action(R.string.share, R.drawable.share) { callbacks.share() },
callbacks.share() Action(R.string.change_color, R.drawable.change_color) { callbacks.changeColor() },
true Action(R.string.reminders, R.drawable.notifications) {
},
Action(R.string.export, R.drawable.export) { fragment ->
fragment.layout.removeAllViews()
ExportMimeType.entries.forEach { mimeType ->
BottomSheetActionBinding.inflate(fragment.inflater, fragment.layout, true)
.root
.apply {
text = mimeType.name
setCompoundDrawablesWithIntrinsicBounds(null, null, null, null)
setOnClickListener {
callbacks.export(mimeType)
fragment.dismiss()
}
}
}
false
},
Action(R.string.change_color, R.drawable.change_color) { _ ->
callbacks.changeColor()
true
},
Action(R.string.reminders, R.drawable.notifications) { _ ->
callbacks.changeReminders() callbacks.changeReminders()
true
},
Action(R.string.labels, R.drawable.label) { _ ->
callbacks.changeLabels()
true
}, },
Action(R.string.labels, R.drawable.label) { callbacks.changeLabels() },
) + additionalActions ) + additionalActions
} }
} }
@ -56,8 +28,6 @@ class MoreNoteBottomSheet(
interface MoreActions { interface MoreActions {
fun share() fun share()
fun export(mimeType: ExportMimeType)
fun changeColor() fun changeColor()
fun changeReminders() fun changeReminders()

View file

@ -1,7 +0,0 @@
package com.philkes.notallyx.presentation.view.note.listitem
import com.philkes.notallyx.presentation.view.note.listitem.adapter.ListItemHighlight
interface HighlightText {
fun highlightText(highlight: ListItemHighlight)
}

View file

@ -1,87 +1,77 @@
package com.philkes.notallyx.presentation.view.note.listitem.adapter package com.philkes.notallyx.presentation.view.note.listitem
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.core.widget.NestedScrollView import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.NestedScrollViewItemTouchHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.databinding.RecyclerListItemBinding import com.philkes.notallyx.databinding.RecyclerListItemBinding
import com.philkes.notallyx.presentation.view.note.listitem.ListItemDragCallback import com.philkes.notallyx.presentation.view.note.listitem.sorting.ListItemSortedList
import com.philkes.notallyx.presentation.view.note.listitem.ListManager
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.TextSize import com.philkes.notallyx.presentation.viewmodel.preference.TextSize
data class ListItemHighlight( class ListItemAdapter(
val itemPos: Int,
val resultPos: Int,
val startIdx: Int,
val endIdx: Int,
var selected: Boolean,
)
abstract class ListItemAdapterBase(
private val adapter: RecyclerView.Adapter<*>,
@ColorInt var backgroundColor: Int, @ColorInt var backgroundColor: Int,
private val textSize: TextSize, private val textSize: TextSize,
elevation: Float, elevation: Float,
private val preferences: NotallyXPreferences, private val preferences: NotallyXPreferences,
private val listManager: ListManager, private val listManager: ListManager,
private val isCheckedListAdapter: Boolean, ) : RecyclerView.Adapter<ListItemVH>() {
scrollView: NestedScrollView,
) {
private lateinit var list: ListItemSortedList
private val callback = ListItemDragCallback(elevation, listManager) private val callback = ListItemDragCallback(elevation, listManager)
private val touchHelper = NestedScrollViewItemTouchHelper(callback, scrollView) private val touchHelper = ItemTouchHelper(callback)
private val highlights = mutableMapOf<Int, MutableList<ListItemHighlight>>() private val highlights = mutableMapOf<Int, MutableList<ListItemHighlight>>()
fun onAttachedToRecyclerView(recyclerView: RecyclerView) { override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
touchHelper.attachToRecyclerView(recyclerView) touchHelper.attachToRecyclerView(recyclerView)
} }
fun onBindViewHolder(holder: ListItemVH, position: Int, viewMode: NoteViewMode) { override fun getItemCount() = list.size()
val item = getItem(position)
override fun onBindViewHolder(holder: ListItemVH, position: Int) {
val item = list[position]
holder.bind( holder.bind(
backgroundColor, backgroundColor,
item, item,
position, position,
highlights[position], highlights.get(position),
preferences.listItemSorting.value, preferences.listItemSorting.value,
viewMode,
) )
} }
abstract fun getItem(position: Int): ListItem override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListItemVH {
fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListItemVH {
val inflater = LayoutInflater.from(parent.context) val inflater = LayoutInflater.from(parent.context)
val binding = RecyclerListItemBinding.inflate(inflater, parent, false) val binding = RecyclerListItemBinding.inflate(inflater, parent, false)
binding.root.background = parent.background binding.root.background = parent.background
return ListItemVH(binding, listManager, touchHelper, textSize, isCheckedListAdapter) return ListItemVH(binding, listManager, touchHelper, textSize)
} }
internal fun setBackgroundColor(@ColorInt color: Int) { internal fun setBackgroundColor(@ColorInt color: Int) {
backgroundColor = color backgroundColor = color
adapter.notifyDataSetChanged() notifyDataSetChanged()
}
internal fun setList(list: ListItemSortedList) {
this.list = list
} }
internal fun clearHighlights(): Set<Int> { internal fun clearHighlights(): Set<Int> {
val highlightedItemPos = val highlightedItemPos =
highlights.entries.flatMap { (_, value) -> value.map { it.itemPos } }.toSet() highlights.entries.flatMap { (_, value) -> value.map { it.itemPos } }.toSet()
highlights.clear() highlights.clear()
highlightedItemPos.forEach { adapter.notifyItemChanged(it) }
return highlightedItemPos return highlightedItemPos
// itemPos.forEach { notifyItemChanged(it) }
} }
fun highlightText(highlight: ListItemHighlight) { internal fun highlightText(highlight: ListItemHighlight) {
if (highlights.containsKey(highlight.itemPos)) { if (highlights.containsKey(highlight.itemPos)) {
highlights[highlight.itemPos]!!.add(highlight) highlights[highlight.itemPos]!!.add(highlight)
} else { } else {
highlights[highlight.itemPos] = mutableListOf(highlight) highlights[highlight.itemPos] = mutableListOf(highlight)
} }
adapter.notifyItemChanged(highlight.itemPos) notifyItemChanged(highlight.itemPos)
} }
internal fun selectHighlight(pos: Int): Int { internal fun selectHighlight(pos: Int): Int {
@ -91,7 +81,7 @@ abstract class ListItemAdapterBase(
val isSelected = it.selected val isSelected = it.selected
it.selected = it.resultPos == pos it.selected = it.resultPos == pos
if (isSelected != it.selected) { if (isSelected != it.selected) {
adapter.notifyItemChanged(it.itemPos) notifyItemChanged(it.itemPos)
} }
if (it.selected) { if (it.selected) {
selectedItemPos = it.itemPos selectedItemPos = it.itemPos
@ -100,4 +90,12 @@ abstract class ListItemAdapterBase(
} }
return selectedItemPos return selectedItemPos
} }
data class ListItemHighlight(
val itemPos: Int,
val resultPos: Int,
val startIdx: Int,
val endIdx: Int,
var selected: Boolean,
)
} }

View file

@ -1,326 +0,0 @@
package com.philkes.notallyx.presentation.view.note.listitem
import androidx.recyclerview.widget.SortedList
import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.deepCopy
import com.philkes.notallyx.data.model.findChild
import com.philkes.notallyx.data.model.plus
import com.philkes.notallyx.data.model.shouldParentBeChecked
import com.philkes.notallyx.data.model.shouldParentBeUnchecked
import com.philkes.notallyx.utils.filter
import com.philkes.notallyx.utils.forEach
import com.philkes.notallyx.utils.indices
import com.philkes.notallyx.utils.map
import com.philkes.notallyx.utils.mapIndexed
fun List<ListItem>.shiftItemOrders(orderRange: IntRange, valueToAdd: Int) {
this.forEach { it.shiftOrder(orderRange, valueToAdd) }
}
fun SortedList<ListItem>.shiftItemOrders(orderRange: IntRange, valueToAdd: Int) {
forEach { it.shiftOrder(orderRange, valueToAdd) }
}
private fun ListItem.shiftOrder(orderRange: IntRange, valueToAdd: Int) {
if (order!! in orderRange) {
order = order!! + valueToAdd
}
}
fun List<ListItem>.shiftItemOrdersHigher(threshold: Int, valueToAdd: Int) {
this.forEach { it.shiftOrderHigher(threshold, valueToAdd) }
}
fun SortedList<ListItem>.shiftItemOrdersHigher(threshold: Int, valueToAdd: Int) {
this.forEach { it.shiftOrderHigher(threshold, valueToAdd) }
}
private fun ListItem.shiftOrderHigher(threshold: Int, valueToAdd: Int) {
if (order!! > threshold) {
order = order!! + valueToAdd
}
}
fun List<ListItem>.shiftItemOrdersBetween(
thresholdMin: Int,
thresholdMax: Int,
valueToAdd: Int,
excludeParent: ListItem? = null,
) {
this.forEach {
if (
it.order!! in (thresholdMin + 1 until thresholdMax) &&
excludeParent?.let(it::isChildOf) != true
) {
it.order = it.order!! + valueToAdd
}
}
}
fun SortedList<ListItem>.shiftItemOrdersBetween(
thresholdMin: Int,
thresholdMax: Int,
valueToAdd: Int,
excludeParent: ListItem? = null,
) {
this.forEach {
if (
it.order!! in (thresholdMin + 1 until thresholdMax) &&
excludeParent?.let(it::isChildOf) != true
) {
it.order = it.order!! + valueToAdd
}
}
}
fun SortedList<ListItem>.toMutableList(): MutableList<ListItem> {
return indices.map { this[it] }.toMutableList()
}
fun List<ListItem>.cloneList(): MutableList<ListItem> {
val clone = this.indices.map { this[it].clone() as ListItem }.toMutableList()
clone.forEach { itemClone ->
itemClone.children =
itemClone.children.map { child -> clone.first { it.id == child.id } }.toMutableList()
}
return clone
}
fun SortedList<ListItem>.toReadableString(): String {
return map { "$it order: ${it.order} id: ${it.id}" }.joinToString("\n")
}
fun SortedList<ListItem>.findParent(childItem: ListItem): Pair<Int, ListItem>? {
this.indices.forEach {
if (this[it].findChild(childItem.id) != null) {
return Pair(it, this[it])
}
}
return null
}
fun List<ListItem>.findParent(childItem: ListItem): Pair<Int, ListItem>? {
this.indices.forEach {
if (this[it].findChild(childItem.id) != null) {
return Pair(it, this[it])
}
}
return null
}
fun List<ListItem>.findInsertIdx(item: ListItem): Int {
return indexOfFirst { it.order!! > item.order!! }.let { if (it < 0) size else it }
}
fun List<ListItem>.firstBodyOrEmptyString() = firstOrNull()?.body ?: ""
fun MutableList<ListItem>.removeWithChildren(item: ListItem): Pair<Int, Int> {
val index = indexOf(item)
removeAll(item.children + item)
return Pair(index, item.children.size + 1)
}
fun MutableList<ListItem>.addWithChildren(item: ListItem): Pair<Int, Int> {
val insertIdx = findInsertIdx(item)
addAll(insertIdx, item + item.children)
return Pair(insertIdx, item.children.size + 1)
}
fun SortedList<ListItem>.addWithChildren(item: ListItem) {
addAll(item + item.children)
}
fun SortedList<ListItem>.removeWithChildren(item: ListItem) {
(item.children + item).forEach { remove(it) }
}
fun SortedList<ListItem>.setItems(list: MutableList<ListItem>) {
clear()
val (children, parents) = list.partition { it.isChild }
// Need to use replaceAll for auto-sorting checked items
replaceAll(parents.toTypedArray(), false)
addAll(children.toTypedArray(), false)
}
fun List<ListItem>.splitByChecked(): Pair<List<ListItem>, List<ListItem>> = partition {
it.checked && (!it.isChild || findParent(it)?.second?.children?.areAllChecked() == true)
}
fun <R> List<R>.getOrNull(index: Int) = if (lastIndex >= index) this[index] else null
fun Collection<ListItem>.init(resetIds: Boolean = true): List<ListItem> {
val initializedItems = deepCopy()
initList(initializedItems, resetIds)
checkBrokenList(initializedItems)
return initializedItems
}
private fun checkBrokenList(list: List<ListItem>) {
list.forEach { listItem ->
if (listItem.shouldParentBeChecked()) {
listItem.checked = true
} else if (listItem.shouldParentBeUnchecked()) {
listItem.checked = false
}
}
}
private fun initList(list: List<ListItem>, resetIds: Boolean) {
if (resetIds) {
list.forEachIndexed { index, item -> item.id = index }
}
initOrders(list)
initChildren(list)
}
private fun initChildren(list: List<ListItem>) {
list.forEach { it.children.clear() }
var parent: ListItem? = null
list.forEach { item ->
if (item.isChild && parent != null) {
parent!!.children.add(item)
} else {
item.isChild = false
parent = item
}
}
}
/** Makes sure every [ListItem.order] is valid and correct */
private fun initOrders(list: List<ListItem>): Boolean {
var orders = list.map { it.order }.toMutableList()
var invalidOrderFound = false
list.forEachIndexed { idx, item ->
if (item.order == null || orders.count { it == idx } > 1) {
invalidOrderFound = true
if (orders.contains(idx)) {
shiftAllOrdersAfterItem(list, item)
}
item.order = idx
orders = list.map { it.order }.toMutableList()
}
}
return invalidOrderFound
}
private fun shiftAllOrdersAfterItem(list: List<ListItem>, item: ListItem) {
// Move all orders after the item to ensure no duplicate orders
val sortedByOrders = list.sortedBy { it.order }
val position = sortedByOrders.indexOfFirst { it.id == item.id }
for (i in position + 1..sortedByOrders.lastIndex) {
sortedByOrders[i].order = sortedByOrders[i].order!! + 1
}
}
fun List<ListItem>.areAllChecked(except: ListItem? = null): Boolean {
return this.none { !it.checked && (except == null || it.id != except.id) }
}
fun MutableList<ListItem>.containsId(id: Int): Boolean {
return this.any { it.id == id }
}
fun Collection<ListItem>.toReadableString(): String {
return map { "$it order: ${it.order} id: ${it.id}" }.joinToString("\n")
}
fun List<ListItem>.findChildrenPositions(parentPosition: Int): List<Int> {
val childrenPositions = mutableListOf<Int>()
for (position in parentPosition + 1 until this.size) {
if (this[position].isChild) {
childrenPositions.add(position)
} else {
break
}
}
return childrenPositions
}
fun List<ListItem>.findParentPosition(childPosition: Int): Int? {
for (position in childPosition - 1 downTo 0) {
if (!this[position].isChild) {
return position
}
}
return null
}
fun Collection<ListItem>.printList(text: String? = null) {
text?.let { print("--------------\n$it\n") }
println("--------------")
println(toReadableString())
println("--------------")
}
fun Collection<ListItem>.findParentsByChecked(checked: Boolean): List<ListItem> {
return filter { !it.isChild && it.checked == checked }.distinct()
}
fun SortedList<ListItem>.findParentsByChecked(checked: Boolean): List<ListItem> {
return filter { !it.isChild && it.checked == checked }.distinct()
}
fun SortedList<ListItem>.deleteCheckedItems() {
mapIndexed { index, listItem -> Pair(index, listItem) }
.filter { it.second.checked }
.sortedBy { !it.second.isChild }
.forEach { remove(it.second) }
}
fun MutableList<ListItem>.deleteCheckedItems(): Set<Int> {
return mapIndexed { index, listItem -> Pair(index, listItem) }
.filter { it.second.checked }
.sortedBy { it.second.isChild }
.onEach { remove(it.second) }
.map { it.first }
.toSet()
}
/**
* Find correct parent for `childPosition` and update it's `children`.
*
* @return Correct parent
*/
fun List<ListItem>.refreshParent(childPosition: Int): ListItem? {
val item = this[childPosition]
findParent(item)?.let { (pos, parent) -> parent.children.removeWithChildren(item) }
return findParentPosition(childPosition)?.let { parentPos ->
val parent = this[parentPos]
val children = parent.children
val childIndex = children.findInsertIdx(item)
children.addAll(childIndex, item + item.children)
item.children.clear()
parent
}
}
fun SortedList<ListItem>.removeFromParent(child: ListItem): ListItem? {
if (!child.isChild) {
return null
}
return findParent(child)?.second?.also { it.children.remove(child) }
}
fun List<ListItem>.removeFromParent(child: ListItem): ListItem? {
if (!child.isChild) {
return null
}
return findParent(child)?.second?.also { it.children.remove(child) }
}
fun MutableList<ListItem>.addToParent(childPosition: Int) {
findParentPosition(childPosition)?.let { parentPos ->
this[parentPos].children.add(childPosition - parentPos - 1, this[childPosition])
}
}
fun MutableList<ListItem>.removeChildrenBelowPositionFromParent(
parentPosition: Int,
thresholdPosition: Int,
): List<ListItem> {
val children = this[parentPosition].children
val childrenBelow =
children.filterIndexed { idx, _ -> parentPosition + idx + 1 > thresholdPosition - 1 }
children.removeAll(childrenBelow)
return childrenBelow
}

View file

@ -8,19 +8,17 @@ import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.philkes.notallyx.data.model.ListItem import com.philkes.notallyx.data.model.ListItem
/** ItemTouchHelper.Callback that allows dragging ListItem with its children. */ /** ItemTouchHelper.Callback that allows dragging ListItem with its children. */
class ListItemDragCallback(private val elevation: Float, internal val listManager: ListManager) : class ListItemDragCallback(private val elevation: Float, private val listManager: ListManager) :
ItemTouchHelper.Callback() { ItemTouchHelper.Callback() {
private var lastState = ItemTouchHelper.ACTION_STATE_IDLE private var lastState = ItemTouchHelper.ACTION_STATE_IDLE
private var lastIsCurrentlyActive = false private var lastIsCurrentlyActive = false
private var childViewHolders: List<ViewHolder> = mutableListOf() private var childViewHolders: List<ViewHolder> = mutableListOf()
private var stateBefore: ListState? = null private var draggedItem: ListItem? = null
private var itemsBefore: List<ListItem>? = null
private var positionFrom: Int? = null private var positionFrom: Int? = null
private var parentBefore: ListItem? = null
private var itemCount: Int? = null
private var positionTo: Int? = null private var positionTo: Int? = null
private var newPosition: Int? = null private var newPosition: Int? = null
override fun isLongPressDragEnabled() = false override fun isLongPressDragEnabled() = false
@ -43,21 +41,26 @@ class ListItemDragCallback(private val elevation: Float, internal val listManage
internal fun move(from: Int, to: Int): Boolean { internal fun move(from: Int, to: Int): Boolean {
if (positionFrom == null) { if (positionFrom == null) {
positionFrom = from draggedItem = listManager.getItem(from).clone() as ListItem
stateBefore = listManager.getState(selectedPos = from) itemsBefore = listManager.getItems()
val item = listManager.getItem(from)
parentBefore = if (item.isChild) listManager.findParent(item)?.second else null
} }
val (positionTo, itemCount) = listManager.move(from, to) val swapped = listManager.move(from, to, false, false, isDrag = true)
if (positionTo != -1) { if (swapped != null) {
this.itemCount = itemCount if (positionFrom == null) {
this.positionTo = positionTo positionFrom = from
}
positionTo = to
newPosition = swapped
} }
return positionTo != -1 return swapped != null
} }
override fun onSelectedChanged(viewHolder: ViewHolder?, actionState: Int) { override fun onSelectedChanged(viewHolder: ViewHolder?, actionState: Int) {
if (lastState != actionState && actionState == ItemTouchHelper.ACTION_STATE_IDLE) { if (
lastState != actionState &&
actionState == ItemTouchHelper.ACTION_STATE_IDLE &&
positionTo != -1
) {
onDragEnd() onDragEnd()
} }
lastState = actionState lastState = actionState
@ -95,7 +98,8 @@ class ListItemDragCallback(private val elevation: Float, internal val listManage
childViewHolders.forEach { animateFadeIn(it) } childViewHolders.forEach { animateFadeIn(it) }
} }
private fun onDragStart(viewHolder: ViewHolder, recyclerView: RecyclerView) { internal fun onDragStart(viewHolder: ViewHolder, recyclerView: RecyclerView) {
Log.d(TAG, "onDragStart")
reset() reset()
if (viewHolder.absoluteAdapterPosition == -1) { if (viewHolder.absoluteAdapterPosition == -1) {
return return
@ -112,25 +116,31 @@ class ListItemDragCallback(private val elevation: Float, internal val listManage
} }
} }
internal fun onDragEnd() {
Log.d(TAG, "onDragEnd: from: $positionFrom to: $positionTo")
if (positionTo != null && positionTo != -1 && stateBefore != null) {
// The items have already been moved accordingly via move() calls
listManager.finishMove(
positionTo!!,
itemCount!!,
parentBefore,
stateBefore!!,
pushChange = true,
)
}
}
internal fun reset() { internal fun reset() {
positionFrom = null positionFrom = null
positionTo = null positionTo = null
newPosition = null newPosition = null
stateBefore = null draggedItem = null
itemsBefore = null
}
internal fun onDragEnd() {
Log.d(TAG, "onDragEnd: from: $positionFrom to: $positionTo")
if (positionFrom == positionTo) {
return
}
if (newPosition != null && itemsBefore != null) {
// The items have already been moved accordingly via move() calls
listManager.finishMove(
positionFrom!!,
positionTo!!,
newPosition!!,
itemsBefore!!,
updateIsChild = true,
updateChildren = true,
pushChange = true,
)
}
} }
private fun animateFadeOut(viewHolder: ViewHolder) { private fun animateFadeOut(viewHolder: ViewHolder) {
@ -142,6 +152,6 @@ class ListItemDragCallback(private val elevation: Float, internal val listManage
} }
companion object { companion object {
private const val TAG = "ListItemDragCallback" private const val TAG = "DragCallback"
} }
} }

View file

@ -1,46 +1,34 @@
package com.philkes.notallyx.presentation.view.note.listitem.adapter package com.philkes.notallyx.presentation.view.note.listitem
import android.graphics.Paint
import android.util.TypedValue import android.util.TypedValue
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View.GONE
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.CompoundButton.OnCheckedChangeListener import android.widget.CompoundButton.OnCheckedChangeListener
import android.widget.EditText
import android.widget.TextView.INVISIBLE import android.widget.TextView.INVISIBLE
import android.widget.TextView.VISIBLE import android.widget.TextView.VISIBLE
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_POSITION
import cn.leaqi.drawer.SwipeDrawer.DIRECTION_LEFT import cn.leaqi.drawer.SwipeDrawer.DIRECTION_LEFT
import cn.leaqi.drawer.SwipeDrawer.STATE_CLOSE import cn.leaqi.drawer.SwipeDrawer.STATE_CLOSE
import cn.leaqi.drawer.SwipeDrawer.STATE_OPEN import cn.leaqi.drawer.SwipeDrawer.STATE_OPEN
import com.philkes.notallyx.data.imports.txt.extractListItems import com.philkes.notallyx.data.imports.txt.extractListItems
import com.philkes.notallyx.data.imports.txt.findListSyntaxRegex import com.philkes.notallyx.data.imports.txt.findListSyntaxRegex
import com.philkes.notallyx.data.model.ListItem import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.databinding.RecyclerListItemBinding import com.philkes.notallyx.databinding.RecyclerListItemBinding
import com.philkes.notallyx.presentation.clone
import com.philkes.notallyx.presentation.createListTextWatcherWithHistory import com.philkes.notallyx.presentation.createListTextWatcherWithHistory
import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews
import com.philkes.notallyx.presentation.setOnNextAction import com.philkes.notallyx.presentation.setOnNextAction
import com.philkes.notallyx.presentation.view.misc.EditTextAutoClearFocus import com.philkes.notallyx.presentation.view.misc.EditTextAutoClearFocus
import com.philkes.notallyx.presentation.view.note.listitem.ListManager
import com.philkes.notallyx.presentation.view.note.listitem.firstBodyOrEmptyString
import com.philkes.notallyx.presentation.viewmodel.preference.ListItemSort import com.philkes.notallyx.presentation.viewmodel.preference.ListItemSort
import com.philkes.notallyx.presentation.viewmodel.preference.TextSize import com.philkes.notallyx.presentation.viewmodel.preference.TextSize
import com.philkes.notallyx.utils.changehistory.EditTextState
import com.philkes.notallyx.utils.copyToClipBoard
class ListItemVH( class ListItemVH(
val binding: RecyclerListItemBinding, val binding: RecyclerListItemBinding,
val listManager: ListManager, val listManager: ListManager,
touchHelper: ItemTouchHelper, touchHelper: ItemTouchHelper,
textSize: TextSize, textSize: TextSize,
private val inCheckedList: Boolean,
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
private var dragHandleInitialY: Float = 0f private var dragHandleInitialY: Float = 0f
@ -50,6 +38,11 @@ class ListItemVH(
binding.EditText.apply { binding.EditText.apply {
setTextSize(TypedValue.COMPLEX_UNIT_SP, body) setTextSize(TypedValue.COMPLEX_UNIT_SP, body)
setOnNextAction {
val position = absoluteAdapterPosition + 1
listManager.add(position)
}
textWatcher = textWatcher =
createListTextWatcherWithHistory( createListTextWatcherWithHistory(
listManager, listManager,
@ -61,6 +54,10 @@ class ListItemVH(
false false
} }
} }
setOnFocusChangeListener { _, hasFocus ->
binding.Delete.visibility = if (hasFocus) VISIBLE else INVISIBLE
}
} }
binding.DragHandle.setOnTouchListener { _, event -> binding.DragHandle.setOnTouchListener { _, event ->
@ -89,23 +86,22 @@ class ListItemVH(
@ColorInt backgroundColor: Int, @ColorInt backgroundColor: Int,
item: ListItem, item: ListItem,
position: Int, position: Int,
highlights: List<ListItemHighlight>?, highlights: List<ListItemAdapter.ListItemHighlight>?,
autoSort: ListItemSort, autoSort: ListItemSort,
viewMode: NoteViewMode,
) { ) {
updateEditText(item, position, viewMode) updateEditText(item, position)
updateCheckBox(item, position) updateCheckBox(item, position)
updateDeleteButton(item, position, viewMode) updateDeleteButton(item, position)
updateSwipe(item.isChild, viewMode == NoteViewMode.EDIT && position != 0 && !item.checked) updateSwipe(item.isChild, position != 0 && !item.checked)
binding.DragHandle.apply { binding.DragHandle.apply {
visibility = visibility =
when { if (item.checked && autoSort == ListItemSort.AUTO_SORT_BY_CHECKED) {
viewMode != NoteViewMode.EDIT -> GONE INVISIBLE
item.checked && autoSort == ListItemSort.AUTO_SORT_BY_CHECKED -> INVISIBLE } else {
else -> VISIBLE VISIBLE
} }
contentDescription = "Drag$position" contentDescription = "Drag$position"
} }
@ -127,65 +123,18 @@ class ListItemVH(
binding.EditText.focusAndSelect(selectionStart, selectionEnd, inputMethodManager) binding.EditText.focusAndSelect(selectionStart, selectionEnd, inputMethodManager)
} }
private fun updateDeleteButton(item: ListItem, position: Int, viewMode: NoteViewMode) { private fun updateDeleteButton(item: ListItem, position: Int) {
binding.Delete.apply { binding.Delete.apply {
visibility = visibility = if (item.checked) VISIBLE else INVISIBLE
when { setOnClickListener { listManager.delete(absoluteAdapterPosition) }
viewMode != NoteViewMode.EDIT -> GONE
item.checked -> VISIBLE
else -> INVISIBLE
}
setOnClickListener {
listManager.delete(absoluteAdapterPosition, inCheckedList = inCheckedList)
}
contentDescription = "Delete$position" contentDescription = "Delete$position"
} }
} }
private fun updateEditText(item: ListItem, position: Int, viewMode: NoteViewMode) { private fun updateEditText(item: ListItem, position: Int) {
binding.EditText.apply { binding.EditText.apply {
setText(item.body) setText(item.body)
paintFlags = isEnabled = !item.checked
if (item.checked) {
paintFlags or Paint.STRIKE_THRU_TEXT_FLAG
} else {
paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv()
}
alpha = if (item.checked) 0.5f else 1.0f
contentDescription = "EditText$position"
if (viewMode == NoteViewMode.EDIT) {
setOnFocusChangeListener { _, hasFocus ->
binding.Delete.visibility = if (hasFocus) VISIBLE else INVISIBLE
}
binding.Content.descendantFocusability = ViewGroup.FOCUS_BEFORE_DESCENDANTS
} else {
onFocusChangeListener = null
binding.Content.descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS
}
setCanEdit(viewMode == NoteViewMode.EDIT)
isFocusable = !item.checked
when (viewMode) {
NoteViewMode.EDIT -> {
setOnClickListener(null)
setOnLongClickListener(null)
}
NoteViewMode.READ_ONLY -> {
setOnClickListener {
if (absoluteAdapterPosition != NO_POSITION) {
listManager.changeChecked(
absoluteAdapterPosition,
!item.checked,
inCheckedList,
)
}
}
setOnLongClickListener {
context?.copyToClipBoard(item.body)
true
}
}
}
setOnNextAction { listManager.add(bindingAdapterPosition + 1) }
setOnKeyListener { _, keyCode, event -> setOnKeyListener { _, keyCode, event ->
if ( if (
event.action == KeyEvent.ACTION_DOWN && event.action == KeyEvent.ACTION_DOWN &&
@ -195,15 +144,12 @@ class ListItemVH(
// TODO: when there are multiple checked items above it does not jump to the // TODO: when there are multiple checked items above it does not jump to the
// last // last
// unchecked item but always re-adds a new item // unchecked item but always re-adds a new item
listManager.delete( listManager.delete(absoluteAdapterPosition, false) != null
absoluteAdapterPosition,
inCheckedList = inCheckedList,
force = false,
)
} else { } else {
false false
} }
} }
contentDescription = "EditText$position"
} }
} }
@ -211,12 +157,10 @@ class ListItemVH(
private fun updateCheckBox(item: ListItem, position: Int) { private fun updateCheckBox(item: ListItem, position: Int) {
if (checkBoxListener == null) { if (checkBoxListener == null) {
checkBoxListener = OnCheckedChangeListener { _, isChecked -> checkBoxListener = OnCheckedChangeListener { buttonView, isChecked ->
binding.CheckBox.setOnCheckedChangeListener(null) buttonView!!.setOnCheckedChangeListener(null)
if (absoluteAdapterPosition != NO_POSITION) { listManager.changeChecked(absoluteAdapterPosition, isChecked)
listManager.changeChecked(absoluteAdapterPosition, isChecked, inCheckedList) buttonView.setOnCheckedChangeListener(checkBoxListener)
}
binding.CheckBox.setOnCheckedChangeListener(checkBoxListener)
} }
} }
binding.CheckBox.apply { binding.CheckBox.apply {
@ -253,31 +197,20 @@ class ListItemVH(
.findListSyntaxRegex(checkContains = true, plainNewLineAllowed = true) .findListSyntaxRegex(checkContains = true, plainNewLineAllowed = true)
?.let { listSyntaxRegex -> ?.let { listSyntaxRegex ->
val items = changedText.extractListItems(listSyntaxRegex) val items = changedText.extractListItems(listSyntaxRegex)
if (items.isNotEmpty()) { if (text.trim().length > count) {
listManager.startBatchChange(start) editText.setText(text.substring(0, start) + text.substring(start + count))
val position = absoluteAdapterPosition } else {
val itemHadTextBefore = text.trim().length > count listManager.delete(absoluteAdapterPosition, pushChange = false)
val firstPastedItemBody = items.firstBodyOrEmptyString() }
val updatedText = items.forEachIndexed { idx, it ->
if (itemHadTextBefore) { listManager.add(absoluteAdapterPosition + idx + 1, it, pushChange = true)
text.substring(0, start) + firstPastedItemBody
} else firstPastedItemBody
editText.changeText(position, updatedText)
items.drop(1).forEachIndexed { index, it ->
listManager.add(position + 1 + index, it, pushChange = false)
}
listManager.finishBatchChange(position + items.size - 1)
} }
} }
} }
return containsLines return containsLines
} }
private fun EditText.changeText(position: Int, after: CharSequence) { fun getSelection(): Pair<Int, Int> {
setText(after) return Pair(binding.EditText.selectionStart, binding.EditText.selectionEnd)
val stateAfter = EditTextState(editableText.clone(), selectionStart)
listManager.changeText(position, stateAfter, pushChange = false)
} }
fun getSelection() = with(binding.EditText) { Pair(selectionStart, selectionEnd) }
} }

View file

@ -1,41 +1,41 @@
package com.philkes.notallyx.presentation.view.note.listitem package com.philkes.notallyx.presentation.view.note.listitem
import android.text.TextWatcher
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.EditText import android.widget.EditText
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.data.model.ListItem import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.check import com.philkes.notallyx.data.model.areAllChecked
import com.philkes.notallyx.data.model.findChild
import com.philkes.notallyx.data.model.plus import com.philkes.notallyx.data.model.plus
import com.philkes.notallyx.data.model.shouldParentBeChecked import com.philkes.notallyx.presentation.view.note.listitem.sorting.ListItemSortedList
import com.philkes.notallyx.data.model.shouldParentBeUnchecked import com.philkes.notallyx.presentation.view.note.listitem.sorting.cloneList
import com.philkes.notallyx.presentation.view.note.listitem.adapter.CheckedListItemAdapter import com.philkes.notallyx.presentation.view.note.listitem.sorting.deleteItem
import com.philkes.notallyx.presentation.view.note.listitem.adapter.ListItemAdapter import com.philkes.notallyx.presentation.view.note.listitem.sorting.filter
import com.philkes.notallyx.presentation.view.note.listitem.adapter.ListItemVH import com.philkes.notallyx.presentation.view.note.listitem.sorting.findById
import com.philkes.notallyx.presentation.view.note.listitem.sorting.SortedItemsList import com.philkes.notallyx.presentation.view.note.listitem.sorting.findParent
import com.philkes.notallyx.presentation.view.note.listitem.sorting.isNotEmpty
import com.philkes.notallyx.presentation.view.note.listitem.sorting.lastIndex
import com.philkes.notallyx.presentation.view.note.listitem.sorting.moveItemRange
import com.philkes.notallyx.presentation.view.note.listitem.sorting.reversed
import com.philkes.notallyx.presentation.view.note.listitem.sorting.setChecked
import com.philkes.notallyx.presentation.view.note.listitem.sorting.setCheckedWithChildren
import com.philkes.notallyx.presentation.view.note.listitem.sorting.setIsChild
import com.philkes.notallyx.presentation.view.note.listitem.sorting.shiftItemOrders
import com.philkes.notallyx.presentation.view.note.listitem.sorting.toReadableString
import com.philkes.notallyx.presentation.viewmodel.preference.ListItemSort
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.autoSortByCheckedEnabled
import com.philkes.notallyx.utils.changehistory.ChangeCheckedForAllChange import com.philkes.notallyx.utils.changehistory.ChangeCheckedForAllChange
import com.philkes.notallyx.utils.changehistory.ChangeHistory import com.philkes.notallyx.utils.changehistory.ChangeHistory
import com.philkes.notallyx.utils.changehistory.DeleteCheckedChange import com.philkes.notallyx.utils.changehistory.DeleteCheckedChange
import com.philkes.notallyx.utils.changehistory.EditTextState import com.philkes.notallyx.utils.changehistory.EditTextState
import com.philkes.notallyx.utils.changehistory.ListAddChange import com.philkes.notallyx.utils.changehistory.ListAddChange
import com.philkes.notallyx.utils.changehistory.ListBatchChange
import com.philkes.notallyx.utils.changehistory.ListCheckedChange import com.philkes.notallyx.utils.changehistory.ListCheckedChange
import com.philkes.notallyx.utils.changehistory.ListDeleteChange import com.philkes.notallyx.utils.changehistory.ListDeleteChange
import com.philkes.notallyx.utils.changehistory.ListEditTextChange import com.philkes.notallyx.utils.changehistory.ListEditTextChange
import com.philkes.notallyx.utils.changehistory.ListIsChildChange import com.philkes.notallyx.utils.changehistory.ListIsChildChange
import com.philkes.notallyx.utils.changehistory.ListMoveChange import com.philkes.notallyx.utils.changehistory.ListMoveChange
import com.philkes.notallyx.utils.lastIndex
data class ListState(
val items: MutableList<ListItem>,
val checkedItems: MutableList<ListItem>?,
val focusedItemPos: Int? = null,
val cursorPos: Int? = null,
)
/** /**
* Should be used for all changes to the items list. Notifies the [RecyclerView.Adapter] and pushes * Should be used for all changes to the items list. Notifies the [RecyclerView.Adapter] and pushes
@ -49,104 +49,33 @@ class ListManager(
private val endSearch: (() -> Unit)?, private val endSearch: (() -> Unit)?,
val refreshSearch: ((refocusView: View?) -> Unit)?, val refreshSearch: ((refocusView: View?) -> Unit)?,
) { ) {
lateinit var adapter: ListItemAdapter
var checkedAdapter: CheckedListItemAdapter? = null
private var nextItemId: Int = 0 private var nextItemId: Int = 0
private val items: MutableList<ListItem> private lateinit var items: ListItemSortedList
get() = adapter.items internal lateinit var adapter: RecyclerView.Adapter<ListItemVH>
private var itemsChecked: SortedItemsList? = null
private var batchChangeBeforeState: ListState? = null
fun init(
adapter: ListItemAdapter,
itemsChecked: SortedItemsList? = null,
adapterChecked: CheckedListItemAdapter? = null,
) {
this.adapter = adapter
this.itemsChecked = itemsChecked
this.checkedAdapter = adapterChecked
nextItemId = this.items.size + (this.itemsChecked?.size() ?: 0)
Log.d(TAG, "initList:\n${this.items.toReadableString()}")
this.itemsChecked?.let { Log.d(TAG, "itemsChecked:\n${it}") }
}
internal fun getState(selectedPos: Int? = null): ListState {
val (pos, cursorPos) = recyclerView.getFocusedPositionAndCursor()
return ListState(
items.cloneList(),
itemsChecked?.toMutableList()?.cloneList(),
selectedPos ?: pos,
cursorPos,
)
}
internal fun setState(state: ListState) {
adapter.submitList(state.items) {
state.focusedItemPos?.let { itemPos -> focusItem(itemPos, state.cursorPos) }
}
this.itemsChecked?.setItems(state.checkedItems!!)
}
private fun focusItem(itemPos: Int, cursorPos: Int?) {
// Focus item's EditText and set cursor position
recyclerView.post {
if (itemPos in 0..items.size) {
recyclerView.smoothScrollToPosition(itemPos)
(recyclerView.findViewHolderForAdapterPosition(itemPos) as? ListItemVH?)?.let {
viewHolder ->
inputMethodManager?.let { inputManager ->
val maxCursorPos = viewHolder.binding.EditText.length()
viewHolder.focusEditText(
selectionStart = cursorPos?.coerceIn(0, maxCursorPos) ?: maxCursorPos,
inputMethodManager = inputManager,
)
}
}
}
}
}
fun add( fun add(
position: Int = items.size, position: Int = items.size(),
item: ListItem = defaultNewItem(position.coerceAtMost(items.size)), item: ListItem = defaultNewItem(position),
pushChange: Boolean = true, pushChange: Boolean = true,
) { ) {
val stateBefore = getState() endSearch?.invoke()
(item + item.children).forEach { setIdIfUnset(it) } (item + item.children).forEach { setIdIfUnset(it) }
val itemBeforeInsert = item.clone() as ListItem
val insertOrder = items.beginBatchedUpdates()
if (position < 1) { for ((idx, newItem) in (item + item.children).withIndex()) {
0 addItem(position + idx, newItem)
} else if (position <= items.lastIndex) {
items[position - 1].order!! + 1
} else {
items.lastOrNull()?.let { it.order!! + 1 } ?: 0
}
shiftItemOrdersHigher(insertOrder - 1, 1 + item.children.size)
item.order = insertOrder
item.children.forEachIndexed { index, child -> child.order = insertOrder + 1 + index }
val parentPos =
if (position <= items.lastIndex && items[position].isChild) {
findParent(items[position])?.first
} else null
val (insertPos, count) = items.addWithChildren(item)
if (item.isChild) {
items.addToParent(insertPos)
} else if (parentPos != null) {
val childrenBelow = items.removeChildrenBelowPositionFromParent(parentPos, insertPos)
item.children.addAll(childrenBelow)
} }
adapter.notifyItemRangeInserted(insertPos, count) items.endBatchedUpdates()
items.notifyPreviousFirstItem(insertPos, count)
if (pushChange) { if (pushChange) {
changeHistory.push(ListAddChange(stateBefore, getState(selectedPos = insertPos), this)) changeHistory.push(ListAddChange(position, item.id, itemBeforeInsert, this))
} }
val positionAfterAdd = items.findById(item.id)!!.first
recyclerView.post { recyclerView.post {
val viewHolder = recyclerView.findViewHolderForAdapterPosition(insertPos) as ListItemVH? val viewHolder =
recyclerView.findViewHolderForAdapterPosition(positionAfterAdd) as ListItemVH?
if (!item.checked && viewHolder != null) { if (!item.checked && viewHolder != null) {
inputMethodManager?.let { viewHolder.focusEditText(inputMethodManager = it) } inputMethodManager?.let { viewHolder.focusEditText(inputMethodManager = it) }
} }
@ -164,192 +93,228 @@ class ListManager(
*/ */
fun delete( fun delete(
position: Int = items.lastIndex, position: Int = items.lastIndex,
inCheckedList: Boolean = false,
force: Boolean = true, force: Boolean = true,
childrenToDelete: List<ListItem>? = null,
pushChange: Boolean = true, pushChange: Boolean = true,
allowFocusChange: Boolean = true, allowFocusChange: Boolean = true,
): Boolean { ): ListItem? {
// TODO endSearch?.invoke()
// endSearch?.invoke() if (position < 0 || position > items.lastIndex) {
val stateBefore = getState() return null
val items = this.items.toMutableList()
var result = false
if (position.isValidPosition(forCheckedList = inCheckedList)) {
return false
} }
var item: ListItem? = null
if (force || position > 0) { if (force || position > 0) {
val item = getItem(position, inCheckedList) item = items.deleteItem(position, childrenToDelete)
shiftItemOrdersHigher(item.order!! - 1, 1 + item.children.size, items = items)
if (inCheckedList) {
itemsChecked!!.removeFromParent(item)
itemsChecked!!.removeWithChildren(item)
} else {
val parent = items.removeFromParent(item)
parent?.updateParentChecked(items)
items.removeWithChildren(item)
}
result = true
adapter.submitList(items)
} }
if (!force && allowFocusChange) { if (!force && allowFocusChange) {
if (position > 0) { if (position > 0) {
this.moveFocusToNext(position - 2) this.moveFocusToNext(position - 2)
} else if (items.size > 1) { } else if (items.size() > 1) {
this.moveFocusToNext(position) this.moveFocusToNext(position)
} }
} }
if (pushChange && result) { if (item != null && pushChange) {
changeHistory.push(ListDeleteChange(stateBefore, getState(), this)) changeHistory.push(ListDeleteChange(item.order!!, item, this))
} }
return result return item
} }
/** @return position of the moved item afterwards and the moved item count. */ fun deleteById(
fun move(positionFrom: Int, positionTo: Int): Pair<Int, Int> { itemId: Int,
val itemsCheckedBefore = itemsChecked?.toMutableList()?.cloneList() force: Boolean = true,
val list = items.toMutableList() childrenToDelete: List<ListItem>? = null,
val movedItem = list[positionFrom] pushChange: Boolean = true,
// Do not allow to move parent into its own children allowFocusChange: Boolean = true,
if ( ): ListItem? {
!movedItem.isChild && return delete(
positionTo in (positionFrom..positionFrom + movedItem.children.size) items.findById(itemId)!!.first,
) { force,
return Pair(-1, -1) childrenToDelete,
} pushChange,
allowFocusChange,
val itemCount = 1 + movedItem.children.size )
val isMoveUpwards = positionFrom < positionTo
val fromOrder = list[positionFrom].order!!
val toOrder = list[positionTo].order!!
val insertOrder = if (isMoveUpwards) toOrder - itemCount + 1 else toOrder
val (orderRange, valueToAdd) =
if (isMoveUpwards) {
Pair(fromOrder + itemCount until toOrder + 1, -itemCount)
} else {
Pair(toOrder until fromOrder, itemCount)
}
shiftItemOrders(orderRange, valueToAdd, items = list)
itemsCheckedBefore?.shiftItemOrders(orderRange, valueToAdd)
list.removeFromParent(movedItem)
list.removeWithChildren(movedItem)
(movedItem + movedItem.children).forEachIndexed { index, item ->
item.order = insertOrder + index
}
val (insertIdx, count) = list.addWithChildren(movedItem)
adapter.submitList(list)
return Pair(insertIdx, count)
} }
/** Finishes a drag movement by updating [ListItem.isChild] accordingly. */ /** @return position of the moved item afterwards */
fun finishMove( fun move(
positionFrom: Int,
positionTo: Int, positionTo: Int,
count: Int, pushChange: Boolean = true,
parentBefore: ListItem?, updateChildren: Boolean = true,
stateBefore: ListState, isDrag: Boolean = false,
): Int? {
endSearch?.invoke()
val itemTo = items[positionTo]
val itemFrom = items[positionFrom]
// val itemBeforeMove = itemFrom.clone() as ListItem
val itemsBeforeMove = getItems()
// Disallow move unchecked item under any checked item (if auto-sort enabled)
if (isAutoSortByCheckedEnabled() && itemTo.checked || itemTo.isChildOf(itemFrom)) {
return null
}
val checkChildPosition = if (positionTo < positionFrom) positionTo - 1 else positionTo
val forceIsChild =
when {
isDrag -> null
positionTo == 0 && itemFrom.isChild -> false
itemFrom.isChild -> true // if child is moved parent could change
updateChildren && checkChildPosition.isBeforeChildItemOfOtherParent -> true
else -> null
}
val newPosition =
items.moveItemRange(
positionFrom,
itemFrom.itemCount,
positionTo,
forceIsChild = forceIsChild,
) ?: return null
finishMove(
positionFrom,
positionTo,
newPosition,
itemsBeforeMove,
updateIsChild = false,
updateChildren = false,
pushChange,
)
return newPosition
}
fun finishMove(
positionFrom: Int,
positionTo: Int,
newPosition: Int,
itemsBeforeMove: List<ListItem>,
updateIsChild: Boolean,
updateChildren: Boolean,
pushChange: Boolean, pushChange: Boolean,
) { ) {
val item = items[positionTo] if (updateIsChild) {
val itemBelow = items.getOrNull(positionTo + count) if (newPosition.isBeforeChildItemOfOtherParent) {
val forceIsChild = itemBelow?.isChild == true && !item.isChild items.setIsChild(newPosition, isChild = true, forceOnChildren = true)
val positionFrom = stateBefore.items.indexOfFirst { it.id == item.id } } else if (newPosition == 0) {
var isChildChanged = false items.setIsChild(newPosition, false)
if (positionTo == 0) { }
item.isChild = false
items.notifyPreviousFirstItem(0, count)
isChildChanged = true
} else if (forceIsChild) {
item.isChild = true
isChildChanged = true
} }
if (positionFrom == 0) { val item = items[newPosition]
adapter.notifyItemChanged(0) if (updateChildren) {
isChildChanged = true val forceValue = item.isChild
} items.forceItemIsChild(item, forceValue, resetBefore = true)
items.updateItemAt(items.findById(item.id)!!.first, item)
if (item.isChild) { } else if (item.isChild && newPosition > 0) {
items.refreshParent(positionTo)?.updateParentChecked() items.removeChildFromParent(item)
} items.updateChildInParent(newPosition, item)
parentBefore?.updateParentChecked()
if (isChildChanged) {
adapter.notifyItemChanged(positionTo)
} }
if (pushChange) { if (pushChange) {
changeHistory.push(ListMoveChange(stateBefore, getState(), this)) changeHistory.push(ListMoveChange(positionFrom, itemsBeforeMove, getItems(), this))
} }
} }
fun changeText(position: Int, value: EditTextState, pushChange: Boolean = true) { fun setItems(items: List<ListItem>) {
val stateBefore = getState() this.items.init(items)
}
fun changeText(
editText: EditText,
listener: TextWatcher,
position: Int,
value: EditTextState,
before: EditTextState? = null,
pushChange: Boolean = true,
) {
// if(!pushChange) { // if(!pushChange) {
endSearch?.invoke() endSearch?.invoke()
// } // }
val item = items[position] val item = items[position]
item.body = value.text.toString() item.body = value.text.toString()
if (pushChange) { if (pushChange) {
changeHistory.push(ListEditTextChange(stateBefore, getState(), this)) changeHistory.push(
ListEditTextChange(editText, position, before!!, value, listener, this)
)
// TODO: fix focus change // TODO: fix focus change
// refreshSearch?.invoke(editText) // refreshSearch?.invoke(editText)
} }
} }
fun changeChecked( fun changeChecked(position: Int, checked: Boolean, pushChange: Boolean = true) {
position: Int, val before = getItems()
checked: Boolean, val item = items[position]
inCheckedList: Boolean = false,
pushChange: Boolean = true,
) {
val beforeState = getState()
val item = getItem(position, inCheckedList)
if (item.checked == checked) { if (item.checked == checked) {
return return
} }
if (item.isChild) { if (item.isChild) {
changeCheckedChild(position, item, checked, inCheckedList) changeCheckedForChild(checked, item, pushChange, position, before)
} else { return
changeCheckedParent(item, checked, changeChildren = true) }
items.setCheckedWithChildren(position, checked)
if (pushChange) {
changeHistory.push(ListCheckedChange(before, getItems(), this))
}
}
private fun changeCheckedForChild(
checked: Boolean,
item: ListItem,
pushChange: Boolean,
position: Int,
before: List<ListItem>,
) {
var actualPosition = position
val (parentPosition, parent) = items.findParent(item)!!
if (!checked) {
// If a child is being unchecked and the parent was checked, the parent gets unchecked
// too
if (parent.checked) {
items.setChecked(parentPosition, false, recalcChildrenPositions = true)
actualPosition = items.findById(item.id)!!.first
}
}
items.setChecked(actualPosition, checked)
if (parent.children.areAllChecked() && !parent.checked) {
items.setChecked(parentPosition, true, recalcChildrenPositions = true)
} }
if (pushChange) { if (pushChange) {
changeHistory.push(ListCheckedChange(beforeState, getState(), this)) changeHistory.push(ListCheckedChange(before, getItems(), this))
} }
} }
fun changeCheckedForAll(checked: Boolean, pushChange: Boolean = true) { fun changeCheckedForAll(checked: Boolean, pushChange: Boolean = true) {
val stateBefore = getState() val parentIds = mutableListOf<Int>()
val parents = val changedIds = mutableListOf<Int>()
items.findParentsByChecked(!checked) + items
(itemsChecked?.findParentsByChecked(!checked) ?: listOf()) .reversed() // have to start from the bottom upwards, otherwise sort order will be wrong
parents.forEach { parent -> changeCheckedParent(parent, checked, true) } .forEach { item ->
if (!item.isChild) {
parentIds.add(item.id)
}
if (item.checked != checked) {
changedIds.add(item.id)
}
}
parentIds.forEach {
val (position, _) = items.findById(it)!!
changeChecked(position, checked, pushChange = false)
}
if (pushChange) { if (pushChange) {
changeHistory.push(ChangeCheckedForAllChange(stateBefore, getState(), this)) changeHistory.push(ChangeCheckedForAllChange(checked, changedIds, this))
} }
} }
fun changeIsChild(position: Int, isChild: Boolean, pushChange: Boolean = true) { fun checkByIds(
val stateBefore = getState() checked: Boolean,
items.findParentPosition(position)?.let { parentPos -> ids: Collection<Int>,
val nearestParent = items[parentPos] recalcChildrenPositions: Boolean = false,
val item = items[position] ): Pair<List<Int>, List<Int>> {
item.isChild = isChild return check(checked, ids.map { items.findById(it)!!.first }, recalcChildrenPositions)
if (isChild) { }
items.refreshParent(position)
} else {
nearestParent.children.apply {
val childIndex = indexOf(item)
val childrenBelow = filterIndexed { idx, _ -> idx > childIndex }
removeAll(childrenBelow)
remove(item)
item.children = childrenBelow.toMutableList()
}
}
item.updateParentChecked()
nearestParent.updateParentChecked()
}
fun changeIsChild(position: Int, isChild: Boolean, pushChange: Boolean = true) {
items.setIsChild(position, isChild)
if (pushChange) { if (pushChange) {
changeHistory.push(ListIsChildChange(stateBefore, getState(), this)) changeHistory.push(ListIsChildChange(isChild, position, this))
} }
} }
@ -364,168 +329,70 @@ class ListManager(
fun deleteCheckedItems(pushChange: Boolean = true) { fun deleteCheckedItems(pushChange: Boolean = true) {
endSearch?.invoke() endSearch?.invoke()
val stateBefore = getState() val itemsToDelete =
items.deleteCheckedItems().forEach { adapter.notifyItemRemoved(it) } items.filter { it.checked }.map { it.clone() as ListItem }.sortedBy { it.isChild }
itemsChecked?.deleteCheckedItems() items.beginBatchedUpdates()
if (pushChange) { itemsToDelete
changeHistory.push(DeleteCheckedChange(stateBefore, getState(), this)) .reversed() // delete children first so sorting works properly
} .forEach { items.deleteItem(it) }
} val deletedItems =
itemsToDelete.toMutableList().filter { item ->
fun findParent(item: ListItem) = items.findParent(item) ?: itemsChecked?.findParent(item) // If a parent with its children was deleted, remove the children item
// since DeleteCheckedChange uses listManager.add, which already adds the children
internal fun startBatchChange(cursorPos: Int? = null) { // from parent.children list
batchChangeBeforeState = getState() !(item.isChild &&
cursorPos?.let { batchChangeBeforeState = batchChangeBeforeState!!.copy(cursorPos = it) } itemsToDelete.any { parent -> parent.children.any { it.id == item.id } })
}
internal fun finishBatchChange(focusedItemPos: Int? = null) {
batchChangeBeforeState?.let {
val state =
focusedItemPos?.let {
getState().copy(focusedItemPos = focusedItemPos, cursorPos = null)
} ?: getState()
changeHistory.push(ListBatchChange(it, state, this))
}
}
internal fun getItem(position: Int, fromCheckedList: Boolean = false): ListItem {
return if (fromCheckedList) itemsChecked!![position] else items[position]
}
private fun RecyclerView.getFocusedPositionAndCursor(): Pair<Int?, Int?> {
return focusedChild?.let { view ->
val position = getChildAdapterPosition(view)
if (position == RecyclerView.NO_POSITION) {
return Pair(null, null)
} }
val viewHolder = recyclerView.findViewHolderForAdapterPosition(position) items.endBatchedUpdates()
val cursorPos = (viewHolder as? ListItemVH)?.binding?.EditText?.selectionStart if (pushChange) {
return Pair(position, cursorPos) changeHistory.push(DeleteCheckedChange(deletedItems, this))
} ?: Pair(null, null) }
} }
fun initList(items: ListItemSortedList) {
this.items = items
nextItemId = this.items.size()
Log.d(TAG, "initList:\n${this.items.toReadableString()}")
}
internal fun getItem(position: Int): ListItem {
return items[position]
}
internal fun getItems(): List<ListItem> = items.cloneList()
internal fun defaultNewItem(position: Int) = internal fun defaultNewItem(position: Int) =
ListItem( ListItem(
"", "",
false, false,
items.isNotEmpty() && items.isNotEmpty() &&
((position < items.size && items[position].isChild) || ((position < items.size() && items[position].isChild) ||
(position > 0 && items[position - 1].isChild)), (position > 0 && items[position - 1].isChild)),
null, null,
mutableListOf(), mutableListOf(),
nextItemId++, nextItemId++,
) )
private fun changeCheckedParent( private fun check(
parent: ListItem,
checked: Boolean, checked: Boolean,
changeChildren: Boolean, positions: Collection<Int>,
items: MutableList<ListItem> = this@ListManager.items, recalcChildrenPositions: Boolean = false,
) { ): Pair<List<Int>, List<Int>> {
if (checked) { return items.setChecked(positions, checked, recalcChildrenPositions)
// A parent from unchecked is checked
if (preferences.autoSortByCheckedEnabled) {
checkWithAutoSort(parent, items)
} else {
parent.check(true, checkChildren = changeChildren)
if (items == this@ListManager.items) {
adapter.notifyListItemChanged(parent.id)
}
}
} else {
if (preferences.autoSortByCheckedEnabled) {
uncheckWithAutoSort(parent, uncheckChildren = changeChildren)
} else {
parent.check(false, checkChildren = changeChildren)
if (items == this@ListManager.items) {
adapter.notifyListItemChanged(parent.id)
}
}
}
} }
private fun changeCheckedChild( private fun addItem(position: Int, newItem: ListItem) {
position: Int, setIdIfUnset(newItem)
child: ListItem, items.shiftItemOrders(position until items.size(), 1)
checked: Boolean, newItem.order = position
inCheckedList: Boolean, val forceIsChild =
) { when {
if (checked) { position == 0 -> false
child.checked = true (position - 1).isBeforeChildItemOfOtherParent -> true
adapter.notifyItemChanged(position) newItem.isChild && items.findParent(newItem) == null -> true
val (_, parent) = items.findParent(child)!! else -> null
parent.updateParentChecked()
} else {
if (inCheckedList) {
uncheckWithAutoSort(child)
} else {
child.checked = false
adapter.notifyItemChanged(position)
checkParent(child, false)
} }
} items.add(newItem, forceIsChild)
}
private fun checkWithAutoSort(
parent: ListItem,
items: MutableList<ListItem> = this@ListManager.items,
) {
val (pos, count) = items.removeWithChildren(parent)
if (items == this@ListManager.items) {
adapter.notifyItemRangeRemoved(pos, count)
items.notifyPreviousFirstItem(pos, 0)
}
parent.check(true)
itemsChecked!!.addWithChildren(parent)
}
private fun uncheckWithAutoSort(
item: ListItem,
uncheckChildren: Boolean = true,
items: MutableList<ListItem> = this@ListManager.items,
) {
if (item.isChild) {
val (_, parent) = itemsChecked!!.findParent(item)!!
itemsChecked!!.removeWithChildren(parent)
parent.findChild(item.id)!!.checked = false
parent.checked = false
val (insertPos, count) = items.addWithChildren(parent)
if (items == this@ListManager.items) {
adapter.notifyItemRangeInserted(insertPos, count)
items.notifyPreviousFirstItem(insertPos, count)
}
} else {
itemsChecked!!.removeWithChildren(item)
item.check(false, uncheckChildren)
val (insertPos, count) = items.addWithChildren(item)
if (items == this@ListManager.items) {
adapter.notifyItemRangeInserted(insertPos, count)
items.notifyPreviousFirstItem(insertPos, count)
}
}
}
private fun ListItem.updateParentChecked(
items: MutableList<ListItem> = this@ListManager.items
) {
if (isChild) {
return
}
if (shouldParentBeChecked()) {
changeCheckedParent(this, true, changeChildren = true, items = items)
}
if (shouldParentBeUnchecked()) {
changeCheckedParent(this, false, changeChildren = false, items = items)
}
}
private fun checkParent(item: ListItem, checked: Boolean) {
val (parentPos, parent) = items.findParent(item)!!
if (parent.checked != checked) {
parent.checked = checked
adapter.notifyItemChanged(parentPos)
}
} }
private fun setIdIfUnset(newItem: ListItem) { private fun setIdIfUnset(newItem: ListItem) {
@ -534,36 +401,32 @@ class ListManager(
} }
} }
/** Adds [valueToAdd] to all [ListItem.order] that are higher than [threshold] */ private fun isAutoSortByCheckedEnabled() =
private fun shiftItemOrdersHigher( preferences.listItemSorting.value == ListItemSort.AUTO_SORT_BY_CHECKED
threshold: Int,
valueToAdd: Int,
items: List<ListItem> = this.items,
) {
items.shiftItemOrdersHigher(threshold, valueToAdd)
itemsChecked?.shiftItemOrdersHigher(threshold, valueToAdd)
}
/** Adds [valueToAdd] to all [ListItem.order] that are in [orderRange] */ private val Int.isBeforeChildItemOfOtherParent: Boolean
private fun shiftItemOrders( get() {
orderRange: IntRange, if (this < 0) {
valueToAdd: Int, return false
items: List<ListItem> = this.items, }
) { val item = items[this]
items.shiftItemOrders(orderRange, valueToAdd) return item.isNextItemChild(this) && !items[this + item.itemCount].isChildOf(this)
itemsChecked?.shiftItemOrders(orderRange, valueToAdd)
}
private fun MutableList<ListItem>.notifyPreviousFirstItem(position: Int, count: Int) {
if (position == 0 && size > count) {
// To trigger enabling isChild swiping for the item that was previously at pos 0
adapter.notifyItemChanged(count)
} }
private val Int.isBeforeChildItem: Boolean
get() {
if (this < 0 || this > items.lastIndex - 1) {
return false
}
return items[this + 1].isChild
}
private fun ListItem.isNextItemChild(position: Int): Boolean {
return (position < items.size() - itemCount) && (items[position + this.itemCount].isChild)
} }
private fun Int.isValidPosition(forCheckedList: Boolean = false): Boolean { private fun ListItem.isChildOf(otherPosition: Int): Boolean {
return this < 0 || return isChildOf(items[otherPosition])
this > (if (forCheckedList) itemsChecked!!.lastIndex else items.lastIndex)
} }
companion object { companion object {

View file

@ -1,76 +0,0 @@
package com.philkes.notallyx.presentation.view.note.listitem.adapter
import android.view.ViewGroup
import androidx.annotation.ColorInt
import androidx.core.widget.NestedScrollView
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SortedList
import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.presentation.view.note.listitem.HighlightText
import com.philkes.notallyx.presentation.view.note.listitem.ListManager
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.TextSize
class CheckedListItemAdapter(
@ColorInt var backgroundColor: Int,
private val textSize: TextSize,
elevation: Float,
private val preferences: NotallyXPreferences,
private val listManager: ListManager,
private val isCheckedListAdapter: Boolean,
scrollView: NestedScrollView,
) : RecyclerView.Adapter<ListItemVH>(), HighlightText {
private lateinit var list: SortedList<ListItem>
private val itemAdapterBase =
object :
ListItemAdapterBase(
this,
backgroundColor,
textSize,
elevation,
preferences,
listManager,
isCheckedListAdapter,
scrollView,
) {
override fun getItem(position: Int): ListItem = list[position]
}
var viewMode: NoteViewMode = NoteViewMode.EDIT
set(value) {
field = value
notifyDataSetChanged()
}
internal fun setList(list: SortedList<ListItem>) {
this.list = list
}
override fun getItemCount(): Int {
return list.size()
}
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
itemAdapterBase.onAttachedToRecyclerView(recyclerView)
}
override fun onBindViewHolder(holder: ListItemVH, position: Int) {
itemAdapterBase.onBindViewHolder(holder, position, viewMode)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
itemAdapterBase.onCreateViewHolder(parent, viewType)
internal fun setBackgroundColor(@ColorInt color: Int) =
itemAdapterBase.setBackgroundColor(color)
internal fun clearHighlights() = itemAdapterBase.clearHighlights()
override fun highlightText(highlight: ListItemHighlight) =
itemAdapterBase.highlightText(highlight)
internal fun selectHighlight(pos: Int) = itemAdapterBase.selectHighlight(pos)
}

View file

@ -1,104 +0,0 @@
package com.philkes.notallyx.presentation.view.note.listitem.adapter
import android.view.ViewGroup
import androidx.annotation.ColorInt
import androidx.core.widget.NestedScrollView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.presentation.view.note.listitem.HighlightText
import com.philkes.notallyx.presentation.view.note.listitem.ListManager
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.TextSize
class ListItemAdapter(
@ColorInt var backgroundColor: Int,
private val textSize: TextSize,
elevation: Float,
private val preferences: NotallyXPreferences,
private val listManager: ListManager,
private val isCheckedListAdapter: Boolean,
scrollView: NestedScrollView,
) : ListAdapter<ListItem, ListItemVH>(DIFF_CALLBACK), HighlightText {
private val itemAdapterBase =
object :
ListItemAdapterBase(
this,
backgroundColor,
textSize,
elevation,
preferences,
listManager,
isCheckedListAdapter,
scrollView,
) {
override fun getItem(position: Int): ListItem = this@ListItemAdapter.getItem(position)
}
var viewMode: NoteViewMode = NoteViewMode.EDIT
set(value) {
field = value
notifyDataSetChanged()
}
lateinit var items: MutableList<ListItem>
private set
override fun submitList(list: MutableList<ListItem>?) {
list?.let { items = it }
super.submitList(list)
}
override fun submitList(list: MutableList<ListItem>?, commitCallback: Runnable?) {
list?.let { items = it }
super.submitList(list, commitCallback)
}
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
itemAdapterBase.onAttachedToRecyclerView(recyclerView)
}
override fun onBindViewHolder(holder: ListItemVH, position: Int) {
itemAdapterBase.onBindViewHolder(holder, position, viewMode)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
itemAdapterBase.onCreateViewHolder(parent, viewType)
internal fun setBackgroundColor(@ColorInt color: Int) =
itemAdapterBase.setBackgroundColor(color)
internal fun clearHighlights() = itemAdapterBase.clearHighlights()
override fun highlightText(highlight: ListItemHighlight) =
itemAdapterBase.highlightText(highlight)
internal fun selectHighlight(pos: Int) = itemAdapterBase.selectHighlight(pos)
internal fun notifyListItemChanged(id: Int) {
val list = currentList
val index = list.indexOfFirst { it.id == id }
val item = list[index]
if (item.isChild) {
notifyItemChanged(index)
} else {
notifyItemRangeChanged(index, item.children.size + 1)
}
}
companion object {
private val DIFF_CALLBACK =
object : DiffUtil.ItemCallback<ListItem>() {
override fun areItemsTheSame(oldItem: ListItem, newItem: ListItem) =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: ListItem, newItem: ListItem) =
oldItem.body == newItem.body &&
oldItem.isChild == newItem.isChild &&
oldItem.checked == newItem.checked
}
}
}

View file

@ -0,0 +1,38 @@
package com.philkes.notallyx.presentation.view.note.listitem.sorting
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SortedListAdapterCallback
import com.philkes.notallyx.data.model.ListItem
/** Sort algorithm that only sorts by [ListItem.order] */
class ListItemNoSortCallback(adapter: RecyclerView.Adapter<*>?) :
SortedListAdapterCallback<ListItem>(adapter) {
override fun compare(item1: ListItem?, item2: ListItem?): Int {
return when {
item1 == null && item2 == null -> 0
item1 == null && item2 != null -> -1
item1 != null && item2 == null -> 1
else -> {
val orderCmp = item1!!.order!!.compareTo(item2!!.order!!)
if (orderCmp == 0 && item1.isChildOf(item2)) {
return -1 // happens when a parent with children is moved up, the children is
// moved first
}
if (orderCmp == 0 && item2.isChildOf(item1)) {
return 1 // happens when a parent with children is moved down, the children is
// moved first
}
return orderCmp
}
}
}
override fun areContentsTheSame(oldItem: ListItem?, newItem: ListItem?): Boolean {
return oldItem == newItem
}
override fun areItemsTheSame(item1: ListItem?, item2: ListItem?): Boolean {
return item1?.id == item2?.id
}
}

View file

@ -1,80 +0,0 @@
package com.philkes.notallyx.presentation.view.note.listitem.sorting
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SortedList
import androidx.recyclerview.widget.SortedListAdapterCallback
import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.presentation.view.note.listitem.containsId
import com.philkes.notallyx.presentation.view.note.listitem.findParent
/**
* Sort algorithm that only sorts by [ListItem.order]. A children is always below it's parent and
* above parents with a lower order.
*/
class ListItemParentSortCallback(adapter: RecyclerView.Adapter<*>?) :
SortedListAdapterCallback<ListItem>(adapter) {
private var items: SortedList<ListItem>? = null
internal fun setItems(items: SortedList<ListItem>) {
this.items = items
}
override fun compare(item1: ListItem?, item2: ListItem?): Int {
return when {
item1 == null && item2 == null -> 0
item1 == null && item2 != null -> -1
item1 != null && item2 == null -> 1
item1!!.id == item2!!.id -> 0
!item1.isChild && item2.isChild -> {
val parent2 =
if (item1.children.containsId(item2.id)) {
item1
} else {
items!!.findParent(item2)!!.second
}
return when {
item1.id == parent2.id -> compareOrder(item1, item2)
else -> compare(item1, parent2)
}
}
item1.isChild && !item2.isChild -> {
val parent1 =
if (item2.children.containsId(item1.id)) {
item2
} else {
items!!.findParent(item1)!!.second
}
when {
item2.id == parent1.id -> compareOrder(item1, item2)
else -> compare(parent1, item2)
}
}
else -> {
return compareOrder(item1, item2)
}
}
}
private fun compareOrder(item1: ListItem, item2: ListItem): Int {
val orderCmp = item1.order!!.compareTo(item2.order!!)
if (orderCmp == 0 && item1.isChildOf(item2)) {
return -1 // happens when a parent with children is moved up, the children is
// moved first
}
if (orderCmp == 0 && item2.isChildOf(item1)) {
return 1 // happens when a parent with children is moved down, the children is
// moved first
}
return orderCmp
}
override fun areContentsTheSame(oldItem: ListItem?, newItem: ListItem?): Boolean {
return oldItem == newItem
}
override fun areItemsTheSame(item1: ListItem?, item2: ListItem?): Boolean {
return item1?.id == item2?.id
}
}

View file

@ -0,0 +1,89 @@
package com.philkes.notallyx.presentation.view.note.listitem.sorting
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SortedListAdapterCallback
import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.containsId
/**
* Sort algorithm that sorts items by [ListItem.checked] and [ListItem.order]. Children are always
* sorted below their parents.
*/
class ListItemSortedByCheckedCallback(adapter: RecyclerView.Adapter<*>?) :
SortedListAdapterCallback<ListItem>(adapter) {
internal lateinit var items: ListItemSortedList
fun setList(items: ListItemSortedList) {
this.items = items
}
override fun compare(item1: ListItem?, item2: ListItem?): Int {
return when {
item1 == null && item2 == null -> 0
item1 == null && item2 != null -> -1
item1 != null && item2 == null -> 1
item1!!.id == item2!!.id -> if (item1.checked) -1 else 1
item1.isChild && item2.isChild -> {
val parent1 = items.findParent(item1)!!.second
val parent2 = items.findParent(item2)!!.second
return when {
parent1.id == parent2.id -> item1.order!!.compareTo(item2.order!!)
else -> compare(parent1, parent2)
}
}
!item1.isChild && item2.isChild -> {
val parent2 =
if (item1.children.containsId(item2.id)) {
item1
} else {
items.findParent(item2)!!.second
}
return when {
item1.id == parent2.id -> compareChecked(item1, item2)
else -> compare(item1, parent2)
}
}
item1.isChild && !item2.isChild -> {
val parent1 =
if (item2.children.containsId(item1.id)) {
item2
} else {
items.findParent(item1)!!.second
}
when {
item2.id == parent1.id -> compareChecked(item1, item2)
else -> compare(parent1, item2)
}
}
else -> compareChecked(item1, item2)
}
}
private fun compareChecked(item1: ListItem, item2: ListItem): Int {
return when {
// if a parent gets checked and the child has not been checked yet the parent
// should be sorted under the not yet checked child
item1.isChild && !item2.isChild && item2.checked -> -1
item1.isChild && !item2.isChild && !item2.checked -> 1
item1.checked == item2.checked -> item1.order!!.compareTo(item2.order!!)
item1.checked && !item2.checked -> 1
!item1.checked && item2.checked -> -1
else -> 0
}
}
override fun areContentsTheSame(oldItem: ListItem?, newItem: ListItem?): Boolean {
val b = oldItem == newItem
return b
}
override fun areItemsTheSame(item1: ListItem?, item2: ListItem?): Boolean {
val b = item1?.id == item2?.id
return b
}
}

View file

@ -0,0 +1,191 @@
package com.philkes.notallyx.presentation.view.note.listitem.sorting
import androidx.recyclerview.widget.SortedList
import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.deepCopy
class ListItemSortedList(private val callback: Callback<ListItem>) :
SortedList<ListItem>(ListItem::class.java, callback) {
override fun updateItemAt(index: Int, item: ListItem?) {
updateChildStatus(item, index)
super.updateItemAt(index, item)
if (item?.isChild == false) {
item.children = item.children.map { findById(it.id)!!.second }.toMutableList()
}
}
override fun add(item: ListItem?): Int {
val position = super.add(item)
if (item?.isChild == true) {
updateChildInParent(position, item)
}
return position
}
fun add(item: ListItem, isChild: Boolean?) {
if (isChild != null) {
forceItemIsChild(item, isChild)
}
add(item)
}
fun forceItemIsChild(item: ListItem, newValue: Boolean, resetBefore: Boolean = false) {
if (resetBefore) {
if (item.isChild) {
// In this case it was already a child and moved to other position,
// therefore reset the child association
removeChildFromParent(item)
item.isChild = false
}
}
if (item.isChild != newValue) {
if (!item.isChild) {
item.children.clear()
} else {
removeChildFromParent(item)
}
item.isChild = newValue
}
if (item.isChild) {
updateChildInParent(item.order!!, item)
}
}
override fun removeItemAt(index: Int): ListItem {
val item = this[index]
val removedItem = super.removeItemAt(index)
if (item?.isChild == true) {
removeChildFromParent(item)
}
return removedItem
}
fun init(items: Collection<ListItem>) {
beginBatchedUpdates()
super.clear()
val initializedItems = items.deepCopy()
initList(initializedItems)
if (callback is ListItemSortedByCheckedCallback) {
val (children, parents) = initializedItems.partition { it.isChild }
// Need to use replaceAll for auto-sorting checked items
super.replaceAll(parents.toTypedArray(), false)
super.addAll(children.toTypedArray(), false)
} else {
super.addAll(initializedItems.toTypedArray(), false)
}
endBatchedUpdates()
}
fun init(vararg items: ListItem) {
init(items.toList())
}
private fun separateChildrenFromParent(item: ListItem) {
findParent(item)?.let { (_, parent) ->
val childIndex = parent.children.indexOfFirst { child -> child.id == item.id }
// If a child becomes a parent it inherits its children below it
val separatedChildren =
if (childIndex < parent.children.lastIndex)
parent.children.subList(childIndex + 1, parent.children.size)
else listOf()
item.children.clear()
item.children.addAll(separatedChildren)
while (parent.children.size >= childIndex + 1) {
parent.children.removeAt(childIndex)
}
}
}
private fun updateChildStatus(item: ListItem?, index: Int) {
val wasChild = this[index].isChild
if (item?.isChild == true) {
updateChildInParent(index, item)
} else if (wasChild && item?.isChild == false) {
// Child becomes parent
separateChildrenFromParent(item)
}
}
fun removeChildFromParent(item: ListItem) {
findParent(item)?.let { (_, parent) ->
val childIndex = parent.children.indexOfFirst { child -> child.id == item.id }
parent.children.removeAt(childIndex)
}
}
private fun initList(list: List<ListItem>) {
list.forEachIndexed { index, item -> item.id = index }
initOrders(list)
initChildren(list)
}
private fun initChildren(list: List<ListItem>) {
list.forEach { it.children.clear() }
var parent: ListItem? = null
list.forEach { item ->
if (item.isChild && parent != null) {
parent!!.children.add(item)
} else {
item.isChild = false
parent = item
}
}
}
/** Makes sure every [ListItem.order] is valid and correct */
private fun initOrders(list: List<ListItem>): Boolean {
var orders = list.map { it.order }.toMutableList()
var invalidOrderFound = false
list.forEachIndexed { idx, item ->
if (item.order == null || orders.count { it == idx } > 1) {
invalidOrderFound = true
if (orders.contains(idx)) {
shiftAllOrdersAfterItem(list, item)
}
item.order = idx
orders = list.map { it.order }.toMutableList()
}
}
return invalidOrderFound
}
private fun shiftAllOrdersAfterItem(list: List<ListItem>, item: ListItem) {
// Move all orders after the item to ensure no duplicate orders
val sortedByOrders = list.sortedBy { it.order }
val position = sortedByOrders.indexOfFirst { it.id == item.id }
for (i in position + 1..sortedByOrders.lastIndex) {
sortedByOrders[i].order = sortedByOrders[i].order!! + 1
}
}
fun updateChildInParent(position: Int, item: ListItem) {
val childIndex: Int?
val parentInfo = findParent(item)
val parent: ListItem?
if (parentInfo == null) {
val parentPosition = findLastIsNotChild(position - 1)!!
childIndex = position - parentPosition - 1
parent = this[parentPosition]
} else {
parent = parentInfo.second
childIndex = parent.children.indexOfFirst { child -> child.id == item.id }
parent.children.removeAt(childIndex)
}
parent!!.children.add(childIndex, item)
parent.children.addAll(childIndex + 1, item.children)
item.children.clear()
}
/** @return position of the found item and its difference to index */
private fun findLastIsNotChild(index: Int): Int? {
var position = index
while (this[position].isChild) {
if (position < 0) {
return null
}
position--
}
return position
}
}

View file

@ -0,0 +1,308 @@
package com.philkes.notallyx.presentation.view.note.listitem.sorting
import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.findChild
import com.philkes.notallyx.data.model.plus
fun ListItemSortedList.deleteItem(item: ListItem) {
val itemsBySortPosition = this.toMutableList().sortedBy { it.order }
val positionOfDeletedItem = itemsBySortPosition.indexOfFirst { it.id == item.id }
for (i in positionOfDeletedItem + 1..itemsBySortPosition.lastIndex) {
itemsBySortPosition[i].order = itemsBySortPosition[i].order!! - 1
}
val position = this.findById(item.id)!!.first
this.removeItemAt(position)
}
fun ListItemSortedList.moveItemRange(
fromIndex: Int,
itemCount: Int,
toIndex: Int,
forceIsChild: Boolean? = null,
): Int? {
if (fromIndex == toIndex || itemCount <= 0) return null
this.beginBatchedUpdates()
val isMoveUp = fromIndex < toIndex
val insertPosition = if (isMoveUp) toIndex - itemCount + 1 else toIndex
if (isMoveUp) {
this.shiftItemOrders(fromIndex + itemCount until toIndex + 1, -itemCount)
} else {
this.shiftItemOrders(toIndex until fromIndex, itemCount)
}
val itemsToMove =
(0 until itemCount)
.map { this[fromIndex + it] }
.mapIndexed { index, item ->
val movedItem = item.clone() as ListItem
movedItem.order = insertPosition + index
movedItem
}
itemsToMove.forEach { listItem ->
this.updateItemAt(this.findById(listItem.id)!!.first, listItem)
}
itemsToMove.forEach {
if (forceIsChild != null) {
val (_, item) = this.findById(it.id)!!
this.forceItemIsChild(item, forceIsChild, resetBefore = true)
itemsToMove.forEach { listItem ->
this.updateItemAt(this.findById(listItem.id)!!.first, listItem)
}
}
}
this.recalcPositions(
itemsToMove.reversed().map { it.id }
) // make sure children are at correct positions
this.endBatchedUpdates()
val newPosition = this.indexOfFirst { it.id == itemsToMove[0].id }!!
return newPosition
}
fun ListItemSortedList.deleteItem(
position: Int,
childrenToDelete: List<ListItem>? = null,
): ListItem {
this.beginBatchedUpdates()
val item = this[position]
val deletedItem = this[position].clone() as ListItem
val children = childrenToDelete ?: item.children
this.shiftItemOrders(position + children.size until this.size(), -(children.size + 1))
(item + children).indices.forEach { this.removeItemAt(position) }
this.endBatchedUpdates()
return deletedItem
}
fun ListItemSortedList.shiftItemOrders(positionRange: IntRange, valueToAdd: Int) {
this.forEach {
if (it.order!! in positionRange) {
it.order = it.order!! + valueToAdd
}
}
}
fun ListItemSortedList.toMutableList(): MutableList<ListItem> {
return this.indices.map { this[it] }.toMutableList()
}
fun ListItemSortedList.cloneList(): MutableList<ListItem> {
return this.indices.map { this[it].clone() as ListItem }.toMutableList()
}
fun ListItemSortedList.setIsChild(
position: Int,
isChild: Boolean,
forceOnChildren: Boolean = false,
forceNotify: Boolean = false,
) {
if (position == 0 && isChild) {
return
}
if (forceOnChildren) {
this.setIsChild((position..position + this[position].children.size).toList(), isChild)
} else {
val item = this[position].clone() as ListItem
val valueChanged = item.isChild != isChild
if (valueChanged || forceNotify) {
item.isChild = isChild
this.updateItemAt(position, item)
if (!item.isChild) {
this.recalcPositions(item.children.reversed().map { it.id })
}
}
}
}
fun ListItemSortedList.setIsChild(positions: List<Int>, isChild: Boolean) {
val changedPositions = mutableListOf<Int>()
val items = this.cloneList()
positions.forEach {
val item = items[it]
if (item.isChild != isChild) {
changedPositions.add(it)
item.isChild = isChild
}
}
updatePositions(changedPositions, items)
}
fun ListItemSortedList.setChecked(
position: Int,
checked: Boolean,
recalcChildrenPositions: Boolean = false,
): Int {
val item = this[position].clone() as ListItem
if (item.checked != checked) {
item.checked = checked
}
// this.beginBatchedUpdates() // TODO: less notifies?
val (_, changedPositionsAfterSort) = this.setChecked(listOf(position), checked, false)
if (recalcChildrenPositions) {
val children = if (checked) item.children.reversed() else item.children
// children.com.philkes.notallyx.recyclerview.forEach { child ->
//
// this.recalculatePositionOfItemAt(this.com.philkes.notallyx.recyclerview.findById(child.id)!!.first)
// }
recalcPositions(children.map { it.id })
}
// this.endBatchedUpdates()
return changedPositionsAfterSort[0]
}
fun ListItemSortedList.setChecked(
positions: Collection<Int>,
checked: Boolean,
recalcChildrenPositions: Boolean = false,
): Pair<List<Int>, List<Int>> {
val changedPositions = mutableListOf<Int>()
val items = this.cloneList()
positions.forEach {
val item = items[it]
if (item.checked != checked) {
changedPositions.add(it)
item.checked = checked
}
}
val changedPositionsAfterSort =
updatePositions(changedPositions, items, recalcChildrenPositions)
return Pair(changedPositions, changedPositionsAfterSort)
}
/**
* Checks item at position and its children
*
* @return The position of the checked item afterwards
*/
fun ListItemSortedList.setCheckedWithChildren(position: Int, checked: Boolean): Int {
val parent = this[position]
val positionsWithChildren =
(position..position + parent.children.size)
.reversed() // children have to be checked first for correct sorting
.toList()
val (_, changedPositionsAfterSort) = this.setChecked(positionsWithChildren, checked, true)
return changedPositionsAfterSort.reversed()[0]
}
fun ListItemSortedList.findById(id: Int): Pair<Int, ListItem>? {
val position = this.indexOfFirst { it.id == id } ?: return null
return Pair(position, this[position])
}
fun ListItemSortedList.toReadableString(): String {
return map { "$it order: ${it.order} id: ${it.id}" }.joinToString("\n")
}
fun ListItemSortedList.findParent(childItem: ListItem): Pair<Int, ListItem>? {
this.indices.forEach {
if (this[it].findChild(childItem.id) != null) {
return Pair(it, this[it])
}
}
return null
}
fun ListItemSortedList.reversed(): List<ListItem> {
return toMutableList().reversed()
}
private fun ListItemSortedList.updatePositions(
changedPositions: MutableList<Int>,
updatedItems: MutableList<ListItem>,
recalcChildrenPositions: Boolean = false,
): List<Int> {
this.beginBatchedUpdates()
val idsOfChildren = mutableSetOf<Int>()
changedPositions.forEach {
val updatedItem = updatedItems[it]
val newPosition = this.indexOfFirst { item -> item.id == updatedItem.id }!!
if (!updatedItem.isChild) {
idsOfChildren.addAll(
updatedItem.children
.reversed() // start recalculations from the lowest child upwards
.map { item -> item.id }
)
}
this.updateItemAt(newPosition, updatedItem)
}
if (recalcChildrenPositions) {
// idsOfChildren.com.philkes.notallyx.recyclerview.forEach { childId ->
//
// this.recalculatePositionOfItemAt(this.com.philkes.notallyx.recyclerview.findById(childId)!!.first)
// }
recalcPositions(idsOfChildren)
}
val changedPositionsAfterSort =
changedPositions
.map { pos -> this.indexOfFirst { item -> item.id == updatedItems[pos].id }!! }
.toList()
this.endBatchedUpdates()
return changedPositionsAfterSort
}
fun ListItemSortedList.recalcPositions(itemIds: Collection<Int> = this.map { it.id }) {
itemIds.forEach { id -> this.recalculatePositionOfItemAt(this.findById(id)!!.first) }
}
fun <R> ListItemSortedList.map(transform: (ListItem) -> R): List<R> {
return (0 until this.size()).map { transform.invoke(this[it]) }
}
fun <R> ListItemSortedList.mapIndexed(transform: (Int, ListItem) -> R): List<R> {
return (0 until this.size()).mapIndexed { idx, it -> transform.invoke(idx, this[it]) }
}
fun ListItemSortedList.forEach(function: (item: ListItem) -> Unit) {
return (0 until this.size()).forEach { function.invoke(this[it]) }
}
fun ListItemSortedList.forEachIndexed(function: (idx: Int, item: ListItem) -> Unit) {
for (i in 0 until this.size()) {
function.invoke(i, this[i])
}
}
fun ListItemSortedList.filter(function: (item: ListItem) -> Boolean): List<ListItem> {
val list = mutableListOf<ListItem>()
for (i in 0 until this.size()) {
if (function.invoke(this[i])) {
list.add(this[i])
}
}
return list.toList()
}
fun ListItemSortedList.find(function: (item: ListItem) -> Boolean): ListItem? {
for (i in 0 until this.size()) {
if (function.invoke(this[i])) {
return this[i]
}
}
return null
}
fun ListItemSortedList.indexOfFirst(function: (item: ListItem) -> Boolean): Int? {
for (i in 0 until this.size()) {
if (function.invoke(this[i])) {
return i
}
}
return null
}
val ListItemSortedList.lastIndex: Int
get() = this.size() - 1
val ListItemSortedList.indices: IntRange
get() = (0 until this.size())
fun ListItemSortedList.isNotEmpty(): Boolean {
return size() > 0
}
fun ListItemSortedList.isEmpty(): Boolean {
return size() == 0
}

View file

@ -1,12 +0,0 @@
package com.philkes.notallyx.presentation.view.note.listitem.sorting
import androidx.recyclerview.widget.SortedList
import com.philkes.notallyx.data.model.ListItem
class SortedItemsList(val callback: ListItemParentSortCallback) :
SortedList<ListItem>(ListItem::class.java, callback) {
init {
this.callback.setItems(this)
}
}

View file

@ -4,15 +4,13 @@ import android.app.Application
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.room.withTransaction import androidx.room.withTransaction
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
@ -29,6 +27,7 @@ import com.philkes.notallyx.data.imports.NotesImporter
import com.philkes.notallyx.data.model.Attachment import com.philkes.notallyx.data.model.Attachment
import com.philkes.notallyx.data.model.Audio import com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.data.model.BaseNote import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Color
import com.philkes.notallyx.data.model.Content import com.philkes.notallyx.data.model.Content
import com.philkes.notallyx.data.model.Converters import com.philkes.notallyx.data.model.Converters
import com.philkes.notallyx.data.model.FileAttachment import com.philkes.notallyx.data.model.FileAttachment
@ -38,20 +37,14 @@ import com.philkes.notallyx.data.model.Item
import com.philkes.notallyx.data.model.Label import com.philkes.notallyx.data.model.Label
import com.philkes.notallyx.data.model.SearchResult import com.philkes.notallyx.data.model.SearchResult
import com.philkes.notallyx.data.model.toNoteIdReminders import com.philkes.notallyx.data.model.toNoteIdReminders
import com.philkes.notallyx.presentation.activity.main.fragment.settings.SettingsFragment.Companion.EXTRA_SHOW_IMPORT_BACKUPS_FOLDER
import com.philkes.notallyx.presentation.getQuantityString import com.philkes.notallyx.presentation.getQuantityString
import com.philkes.notallyx.presentation.restartApplication
import com.philkes.notallyx.presentation.setCancelButton import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.showToast import com.philkes.notallyx.presentation.showToast
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
import com.philkes.notallyx.presentation.view.misc.Progress import com.philkes.notallyx.presentation.view.misc.Progress
import com.philkes.notallyx.presentation.viewmodel.preference.BasePreference import com.philkes.notallyx.presentation.viewmodel.preference.BasePreference
import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.EMPTY_PATH import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.EMPTY_PATH
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.START_VIEW_DEFAULT
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.START_VIEW_UNLABELED
import com.philkes.notallyx.presentation.viewmodel.preference.Theme
import com.philkes.notallyx.utils.ActionMode import com.philkes.notallyx.utils.ActionMode
import com.philkes.notallyx.utils.Cache import com.philkes.notallyx.utils.Cache
import com.philkes.notallyx.utils.MIME_TYPE_JSON import com.philkes.notallyx.utils.MIME_TYPE_JSON
@ -70,11 +63,7 @@ import com.philkes.notallyx.utils.getBackupDir
import com.philkes.notallyx.utils.getExternalImagesDirectory import com.philkes.notallyx.utils.getExternalImagesDirectory
import com.philkes.notallyx.utils.log import com.philkes.notallyx.utils.log
import com.philkes.notallyx.utils.scheduleNoteReminders import com.philkes.notallyx.utils.scheduleNoteReminders
import com.philkes.notallyx.utils.security.decryptDatabase
import com.philkes.notallyx.utils.security.encryptDatabase
import java.io.File
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import javax.crypto.Cipher
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -102,13 +91,11 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
val folder = NotNullLiveData(Folder.NOTES) val folder = NotNullLiveData(Folder.NOTES)
var currentLabel: String? = CURRENT_LABEL_EMPTY
var keyword = String() var keyword = String()
set(value) { set(value) {
if (field != value || searchResults?.value?.isEmpty() == true) { if (field != value || searchResults?.value?.isEmpty() == true) {
field = value field = value
searchResults!!.fetch(keyword, folder.value, currentLabel) searchResults!!.fetch(keyword, folder.value)
} }
} }
@ -116,7 +103,6 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
private val pinned = Header(app.getString(R.string.pinned)) private val pinned = Header(app.getString(R.string.pinned))
private val others = Header(app.getString(R.string.others)) private val others = Header(app.getString(R.string.others))
private val archived = Header(app.getString(R.string.archived))
val preferences = NotallyXPreferences.getInstance(app) val preferences = NotallyXPreferences.getInstance(app)
@ -128,14 +114,9 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
val actionMode = ActionMode() val actionMode = ActionMode()
internal var showRefreshBackupsFolderAfterThemeChange = false
private var labelsHiddenObserver: Observer<Set<String>>? = null
init { init {
NotallyDatabase.getDatabase(app).observeForever(::init) NotallyDatabase.getDatabase(app).observeForever(::init)
folder.observeForever { newFolder -> folder.observeForever { newFolder -> searchResults!!.fetch(keyword, newFolder) }
searchResults!!.fetch(keyword, newFolder, currentLabel)
}
} }
private fun init(database: NotallyDatabase) { private fun init(database: NotallyDatabase) {
@ -145,7 +126,6 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
commonDao = database.getCommonDao() commonDao = database.getCommonDao()
labels = labelDao.getAll() labels = labelDao.getAll()
// colors = baseNoteDao.getAllColorsAsync()
reminders = baseNoteDao.getAllRemindersAsync() reminders = baseNoteDao.getAllRemindersAsync()
allNotes?.removeObserver(allNotesObserver!!) allNotes?.removeObserver(allNotesObserver!!)
@ -153,12 +133,11 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
allNotes = baseNoteDao.getAllAsync() allNotes = baseNoteDao.getAllAsync()
allNotes!!.observeForever(allNotesObserver!!) allNotes!!.observeForever(allNotesObserver!!)
labelsHiddenObserver?.let { preferences.labelsHidden.removeObserver(it) } if (baseNotes == null) {
labelsHiddenObserver = Observer { labelsHidden -> baseNotes = Content(baseNoteDao.getFrom(Folder.NOTES), ::transform)
baseNotes = null } else {
initBaseNotes(labelsHidden) baseNotes!!.setObserver(baseNoteDao.getFrom(Folder.NOTES))
} }
preferences.labelsHidden.observeForever(labelsHiddenObserver!!)
if (deletedNotes == null) { if (deletedNotes == null) {
deletedNotes = Content(baseNoteDao.getFrom(Folder.DELETED), ::transform) deletedNotes = Content(baseNoteDao.getFrom(Folder.DELETED), ::transform)
@ -192,18 +171,6 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
} }
} }
private fun initBaseNotes(labelsHidden: Set<String>) {
val overviewNotes =
baseNoteDao.getFrom(Folder.NOTES).map { list ->
list.filter { baseNote -> baseNote.labels.none { labelsHidden.contains(it) } }
}
if (baseNotes == null) {
baseNotes = Content(overviewNotes, ::transform)
} else {
baseNotes!!.setObserver(overviewNotes)
}
}
fun getNotesByLabel(label: String): Content { fun getNotesByLabel(label: String): Content {
if (labelCache[label] == null) { if (labelCache[label] == null) {
labelCache[label] = Content(baseNoteDao.getBaseNotesByLabel(label), ::transform) labelCache[label] = Content(baseNoteDao.getBaseNotesByLabel(label), ::transform)
@ -211,11 +178,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
return requireNotNull(labelCache[label]) return requireNotNull(labelCache[label])
} }
fun getNotesWithoutLabel(): Content { private fun transform(list: List<BaseNote>) = transform(list, pinned, others)
return Content(baseNoteDao.getBaseNotesWithoutLabel(Folder.NOTES), ::transform)
}
private fun transform(list: List<BaseNote>) = transform(list, pinned, others, archived)
fun disableBackups() { fun disableBackups() {
val value = preferences.backupsFolder.value val value = preferences.backupsFolder.value
@ -241,67 +204,39 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
} }
savePreference(preferences.backupsFolder, newBackupsFolder) savePreference(preferences.backupsFolder, newBackupsFolder)
} }
showRefreshBackupsFolderAfterThemeChange = false
} }
fun enableDataInPublic(callback: (() -> Unit)? = null) { fun enableDataInPublic() {
viewModelScope.launch { viewModelScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val database = NotallyDatabase.getDatabase(app, observePreferences = false).value val database = NotallyDatabase.getDatabase(app, observePreferences = false).value
database.checkpoint() database.checkpoint()
val directory = NotallyDatabase.getExternalDatabaseFile(app).parentFile NotallyDatabase.getInternalDatabaseFile(app)
NotallyDatabase.getInternalDatabaseFiles(app).forEach { .copyTo(NotallyDatabase.getExternalDatabaseFile(app), overwrite = true)
it.copyTo(File(directory, it.name), overwrite = true)
}
// database.close()
} }
savePreference(preferences.dataInPublicFolder, true) savePreference(preferences.dataInPublicFolder, true)
callback?.invoke()
} }
} }
fun disableDataInPublic(callback: (() -> Unit)? = null) { fun disableDataInPublic() {
viewModelScope.launch { viewModelScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val database = NotallyDatabase.getDatabase(app, observePreferences = false).value val database = NotallyDatabase.getDatabase(app, observePreferences = false).value
database.checkpoint() database.checkpoint()
val directory = NotallyDatabase.getInternalDatabaseFile(app).parentFile NotallyDatabase.getExternalDatabaseFile(app)
val oldFiles = NotallyDatabase.getExternalDatabaseFiles(app) .copyTo(NotallyDatabase.getInternalDatabaseFile(app), overwrite = true)
oldFiles.forEach { it.copyTo(File(directory, it.name), overwrite = true) } NotallyDatabase.getExternalDatabaseFiles(app).forEach {
// database.close()
oldFiles.forEach {
if (it.exists()) { if (it.exists()) {
it.delete() it.delete()
} }
} }
} }
savePreference(preferences.dataInPublicFolder, false) savePreference(preferences.dataInPublicFolder, false)
callback?.invoke()
} }
} }
fun enableBiometricLock(cipher: Cipher) {
savePreference(preferences.iv, cipher.iv)
val passphrase = preferences.databaseEncryptionKey.init(cipher)
encryptDatabase(app, passphrase)
savePreference(preferences.fallbackDatabaseEncryptionKey, passphrase)
savePreference(preferences.biometricLock, BiometricLock.ENABLED)
}
@RequiresApi(Build.VERSION_CODES.M)
fun disableBiometricLock(cipher: Cipher? = null, callback: (() -> Unit)? = null) {
val encryptedPassphrase = preferences.databaseEncryptionKey.value
val passphrase =
cipher?.doFinal(encryptedPassphrase)
?: preferences.fallbackDatabaseEncryptionKey.value!!
database.close()
decryptDatabase(app, passphrase)
savePreference(preferences.biometricLock, BiometricLock.DISABLED)
callback?.invoke()
}
fun <T> savePreference(preference: BasePreference<T>, value: T) { fun <T> savePreference(preference: BasePreference<T>, value: T) {
viewModelScope.launch(Dispatchers.IO) { preference.save(value) } executeAsync { preference.save(value) }
} }
/** /**
@ -335,7 +270,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
fun importZipBackup(uri: Uri, password: String) { fun importZipBackup(uri: Uri, password: String) {
val exceptionHandler = CoroutineExceptionHandler { _, throwable -> val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
app.log(TAG, throwable = throwable) app.log(TAG, throwable = throwable)
app.showToast("${app.getString(R.string.invalid_backup)}: ${throwable.message}") app.showToast(R.string.invalid_backup)
} }
val backupDir = app.getBackupDir() val backupDir = app.getBackupDir()
@ -347,7 +282,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
fun importXmlBackup(uri: Uri) { fun importXmlBackup(uri: Uri) {
val exceptionHandler = CoroutineExceptionHandler { _, throwable -> val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
app.log(TAG, throwable = throwable) app.log(TAG, throwable = throwable)
app.showToast("${app.getString(R.string.invalid_backup)}: ${throwable.message}") app.showToast(R.string.invalid_backup)
} }
viewModelScope.launch(exceptionHandler) { viewModelScope.launch(exceptionHandler) {
@ -364,16 +299,19 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
} }
fun importFromOtherApp(uri: Uri, importSource: ImportSource) { fun importFromOtherApp(uri: Uri, importSource: ImportSource) {
val database = NotallyDatabase.getDatabase(app, observePreferences = false).value val database = NotallyDatabase.getDatabase(app).value
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
app.log(TAG, throwable = throwable)
if (throwable is ImportException) {
app.showToast(throwable.textResId)
} else {
app.showToast("${app.getString(R.string.invalid_backup)}: ${throwable.message}")
}
}
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Toast.makeText(
app,
if (throwable is ImportException) {
throwable.textResId
} else R.string.invalid_backup,
Toast.LENGTH_LONG,
)
.show()
app.log(TAG, throwable = throwable)
}
viewModelScope.launch(exceptionHandler) { viewModelScope.launch(exceptionHandler) {
val importedNotes = val importedNotes =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@ -395,7 +333,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
} }
} }
fun exportNotesToFolder(folderUri: Uri, notes: Collection<BaseNote>) { fun exportSelectedNotesToFolder(folderUri: Uri) {
val exceptionHandler = CoroutineExceptionHandler { _, throwable -> val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
app.log(TAG, throwable = throwable) app.log(TAG, throwable = throwable)
actionMode.close(true) actionMode.close(true)
@ -403,6 +341,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
app.showToast(R.string.something_went_wrong) app.showToast(R.string.something_went_wrong)
} }
viewModelScope.launch(exceptionHandler) { viewModelScope.launch(exceptionHandler) {
val notes = actionMode.selectedNotes.values
val counter = AtomicInteger(0) val counter = AtomicInteger(0)
for (note in notes) { for (note in notes) {
exportProgress.postValue(Progress(total = notes.size)) exportProgress.postValue(Progress(total = notes.size))
@ -439,24 +378,16 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
} }
} }
fun exportSelectedNotesToFolder(folderUri: Uri) {
exportNotesToFolder(folderUri, actionMode.selectedNotes.values)
}
fun pinBaseNotes(pinned: Boolean) { fun pinBaseNotes(pinned: Boolean) {
val id = actionMode.selectedIds.toLongArray() val id = actionMode.selectedIds.toLongArray()
actionMode.close(true) actionMode.close(true)
viewModelScope.launch(Dispatchers.IO) { baseNoteDao.updatePinned(id, pinned) } executeAsync { baseNoteDao.updatePinned(id, pinned) }
} }
fun colorBaseNote(color: String) { fun colorBaseNote(color: Color) {
val ids = actionMode.selectedIds.toLongArray() val ids = actionMode.selectedIds.toLongArray()
actionMode.close(true) actionMode.close(true)
viewModelScope.launch(Dispatchers.IO) { baseNoteDao.updateColor(ids, color) } executeAsync { baseNoteDao.updateColor(ids, color) }
}
fun changeColor(oldColor: String, newColor: String) {
viewModelScope.launch(Dispatchers.IO) { baseNoteDao.updateColor(oldColor, newColor) }
} }
fun moveBaseNotes(folder: Folder): LongArray { fun moveBaseNotes(folder: Folder): LongArray {
@ -467,9 +398,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
} }
fun moveBaseNotes(ids: LongArray, folder: Folder) { fun moveBaseNotes(ids: LongArray, folder: Folder) {
viewModelScope.launch( executeAsync {
Dispatchers.IO
) { // Only reminders of notes in NOTES folder are active
baseNoteDao.move(ids, folder) baseNoteDao.move(ids, folder)
val notes = baseNoteDao.getByIds(ids).toNoteIdReminders() val notes = baseNoteDao.getByIds(ids).toNoteIdReminders()
// Only reminders of notes in NOTES folder are active // Only reminders of notes in NOTES folder are active
@ -482,7 +411,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
fun updateBaseNoteLabels(labels: List<String>, id: Long) { fun updateBaseNoteLabels(labels: List<String>, id: Long) {
actionMode.close(true) actionMode.close(true)
viewModelScope.launch(Dispatchers.IO) { baseNoteDao.updateLabels(id, labels) } executeAsync { baseNoteDao.updateLabels(id, labels) }
} }
fun deleteSelectedBaseNotes() { fun deleteSelectedBaseNotes() {
@ -498,7 +427,6 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
app.cancelNoteReminders(noteReminders) app.cancelNoteReminders(noteReminders)
deleteBaseNotes(ids) deleteBaseNotes(ids)
withContext(Dispatchers.IO) { labelDao.deleteAll() } withContext(Dispatchers.IO) { labelDao.deleteAll() }
savePreference(preferences.startView, START_VIEW_DEFAULT)
app.showToast(R.string.cleared_data) app.showToast(R.string.cleared_data)
} }
} }
@ -549,16 +477,13 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
suspend fun getAllLabels() = withContext(Dispatchers.IO) { labelDao.getArrayOfAll() } suspend fun getAllLabels() = withContext(Dispatchers.IO) { labelDao.getArrayOfAll() }
fun deleteLabel(value: String) { fun deleteLabel(value: String) {
viewModelScope.launch(Dispatchers.IO) { commonDao.deleteLabel(value) } executeAsync { commonDao.deleteLabel(value) }
val labelsHiddenPreference = preferences.labelsHidden val labelsHiddenPreference = preferences.labelsHiddenInNavigation
val labelsHidden = labelsHiddenPreference.value.toMutableSet() val labelsHidden = labelsHiddenPreference.value.toMutableSet()
if (labelsHidden.contains(value)) { if (labelsHidden.contains(value)) {
labelsHidden.remove(value) labelsHidden.remove(value)
savePreference(labelsHiddenPreference, labelsHidden) savePreference(labelsHiddenPreference, labelsHidden)
} }
if (preferences.startView.value == value) {
savePreference(preferences.startView, START_VIEW_DEFAULT)
}
} }
fun insertLabel(label: Label, onComplete: (success: Boolean) -> Unit) = fun insertLabel(label: Label, onComplete: (success: Boolean) -> Unit) =
@ -566,7 +491,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
fun updateLabel(oldValue: String, newValue: String, onComplete: (success: Boolean) -> Unit) { fun updateLabel(oldValue: String, newValue: String, onComplete: (success: Boolean) -> Unit) {
executeAsyncWithCallback({ commonDao.updateLabel(oldValue, newValue) }, onComplete) executeAsyncWithCallback({ commonDao.updateLabel(oldValue, newValue) }, onComplete)
val labelsHiddenPreference = preferences.labelsHidden val labelsHiddenPreference = preferences.labelsHiddenInNavigation
val labelsHidden = labelsHiddenPreference.value.toMutableSet() val labelsHidden = labelsHiddenPreference.value.toMutableSet()
if (labelsHidden.contains(oldValue)) { if (labelsHidden.contains(oldValue)) {
labelsHidden.remove(oldValue) labelsHidden.remove(oldValue)
@ -575,200 +500,91 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
} }
} }
fun resetPreferences(callback: (restartRequired: Boolean) -> Unit) { fun closeDatabase() {
database.close()
}
private fun executeAsync(function: suspend () -> Unit) {
viewModelScope.launch(Dispatchers.IO) { function() }
}
fun resetPreferences() {
val backupsFolder = preferences.backupsFolder.value val backupsFolder = preferences.backupsFolder.value
val publicFolder = preferences.dataInPublicFolder.value
val isThemeDefault = preferences.theme.value == Theme.FOLLOW_SYSTEM
val finishCallback = { callback(!isThemeDefault) }
if (preferences.biometricLock.value == BiometricLock.ENABLED) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
disableBiometricLock {
finishResetPreferencesAfterBiometric(
publicFolder,
backupsFolder,
finishCallback,
)
}
} else finishResetPreferencesAfterBiometric(publicFolder, backupsFolder, finishCallback)
} else finishResetPreferencesAfterBiometric(publicFolder, backupsFolder, finishCallback)
}
private fun finishResetPreferencesAfterBiometric(
publicFolder: Boolean,
backupsFolder: String,
callback: (() -> Unit),
) {
if (publicFolder) {
refreshDataInPublicFolder(false) { finishResetPreferences(backupsFolder, callback) }
} else finishResetPreferences(backupsFolder, callback)
}
private fun finishResetPreferences(backupsFolder: String, callback: () -> Unit) {
preferences.reset() preferences.reset()
refreshDataInPublicFolder()
if (backupsFolder != EMPTY_PATH) { if (backupsFolder != EMPTY_PATH) {
clearPersistedUriPermissions(backupsFolder) clearPersistedUriPermissions(backupsFolder)
} }
callback()
app.restartApplication(R.id.Settings)
} }
fun importPreferences( fun importPreferences(
context: Context, context: Context,
uri: Uri, uri: Uri,
askForUriPermissions: (uri: Uri) -> Unit, askForUriPermissions: (uri: Uri) -> Unit,
onSuccess: () -> Unit, ): Boolean {
onFailure: () -> Unit,
) {
val oldBackupsFolder = preferences.backupsFolder.value val oldBackupsFolder = preferences.backupsFolder.value
val dataInPublicFolderBefore = preferences.dataInPublicFolder.value
val themeBefore = preferences.theme.value
val useDynamicColorsBefore = preferences.useDynamicColors.value
val oldStartView = preferences.startView.value
val success = preferences.import(context, uri) val success = preferences.import(context, uri)
refreshDataInPublicFolder()
val dataInPublicFolder = preferences.dataInPublicFolder.getFreshValue()
if (dataInPublicFolderBefore != dataInPublicFolder) {
refreshDataInPublicFolder(dataInPublicFolder) {
preferences.dataInPublicFolder.refresh()
finishImportPreferences(
oldBackupsFolder,
themeBefore,
useDynamicColorsBefore,
oldStartView,
context,
askForUriPermissions,
) {
if (success) {
onSuccess()
} else onFailure()
}
}
} else
finishImportPreferences(
oldBackupsFolder,
themeBefore,
useDynamicColorsBefore,
oldStartView,
context,
askForUriPermissions,
) {
if (success) {
onSuccess()
} else onFailure()
}
}
private fun finishImportPreferences(
oldBackupsFolder: String,
themeBefore: Theme,
useDynamicColorsBefore: Boolean,
oldStartView: String,
context: Context,
askForUriPermissions: (uri: Uri) -> Unit,
callback: () -> Unit,
) {
val backupFolder = preferences.backupsFolder.getFreshValue() val backupFolder = preferences.backupsFolder.getFreshValue()
val hasUseDynamicColorsChange =
useDynamicColorsBefore != preferences.useDynamicColors.getFreshValue()
if (oldBackupsFolder != backupFolder) { if (oldBackupsFolder != backupFolder) {
showRefreshBackupsFolderAfterThemeChange = true refreshBackupsFolder(context, backupFolder, askForUriPermissions)
if (themeBefore == preferences.theme.getFreshValue() && !hasUseDynamicColorsChange) {
refreshBackupsFolder(context, backupFolder, askForUriPermissions)
}
} else {
showRefreshBackupsFolderAfterThemeChange = false
}
val startView = preferences.startView.getFreshValue()
if (oldStartView != startView) {
refreshStartView(startView, oldStartView)
}
preferences.theme.refresh()
callback()
if (showRefreshBackupsFolderAfterThemeChange) {
app.restartApplication(R.id.Settings, EXTRA_SHOW_IMPORT_BACKUPS_FOLDER to true)
} }
return success
} }
fun refreshBackupsFolder( private fun refreshBackupsFolder(
context: Context, context: Context,
backupFolder: String = preferences.backupsFolder.value, backupFolder: String,
askForUriPermissions: (uri: Uri) -> Unit, askForUriPermissions: (uri: Uri) -> Unit,
) { ) {
try { try {
val backupFolderUri = backupFolder.toUri() val backupFolderUri = backupFolder.toUri()
MaterialAlertDialogBuilder(context) MaterialAlertDialogBuilder(context)
.setMessage(R.string.auto_backups_folder_rechoose) .setMessage(R.string.auto_backups_folder_rechoose)
.setCancelButton { _, _ -> showRefreshBackupsFolderAfterThemeChange = false } .setCancelButton()
.setOnDismissListener { showRefreshBackupsFolderAfterThemeChange = false }
.setPositiveButton(R.string.choose_folder) { _, _ -> .setPositiveButton(R.string.choose_folder) { _, _ ->
askForUriPermissions(backupFolderUri) askForUriPermissions(backupFolderUri)
} }
.show() .show()
} catch (e: Exception) { } catch (e: Exception) {
showRefreshBackupsFolderAfterThemeChange = false
disableBackups() disableBackups()
} }
} }
private fun refreshDataInPublicFolder(dataInPublicFolder: Boolean, callback: () -> Unit) { private fun refreshDataInPublicFolder() {
if (dataInPublicFolder) { val dataInPublicFolderBefore = preferences.dataInPublicFolder.value
enableDataInPublic(callback) val dataInPublicFolderAfter = preferences.dataInPublicFolder.getFreshValue()
} else { if (dataInPublicFolderBefore != dataInPublicFolderAfter) {
disableDataInPublic(callback) if (dataInPublicFolderAfter) {
} enableDataInPublic()
} } else {
disableDataInPublic()
private fun refreshStartView(startView: String, oldStartView: String) {
if (startView in setOf(START_VIEW_DEFAULT, START_VIEW_UNLABELED)) {
savePreference(preferences.startView, startView)
} else {
viewModelScope.launch {
val startViewLabelExists =
withContext(Dispatchers.IO) { labelDao.exists(startView) }
savePreference(
preferences.startView,
if (startViewLabelExists) startView else oldStartView,
)
} }
} }
preferences.dataInPublicFolder.refresh()
} }
companion object { companion object {
private const val TAG = "BaseNoteModel" private const val TAG = "BaseNoteModel"
const val CURRENT_LABEL_EMPTY = "" fun transform(list: List<BaseNote>, pinned: Header, others: Header): List<Item> {
val CURRENT_LABEL_NONE: String? = null
fun transform(
list: List<BaseNote>,
pinned: Header,
others: Header,
archived: Header,
): List<Item> {
if (list.isEmpty()) { if (list.isEmpty()) {
return list return list
} else { } else {
val firstPinnedNote = list.indexOfFirst { baseNote -> baseNote.pinned } val firstNote = list[0]
val firstUnpinnedNote = return if (firstNote.pinned) {
list.indexOfFirst { baseNote -> val newList = ArrayList<Item>(list.size + 2)
!baseNote.pinned && baseNote.folder != Folder.ARCHIVED newList.add(pinned)
val firstUnpinnedNote = list.indexOfFirst { baseNote -> !baseNote.pinned }
list.forEachIndexed { index, baseNote ->
if (index == firstUnpinnedNote) {
newList.add(others)
}
newList.add(baseNote)
} }
val mutableList: MutableList<Item> = list.toMutableList() newList
if (firstPinnedNote != -1) { } else list
mutableList.add(firstPinnedNote, pinned)
if (firstUnpinnedNote != -1) {
mutableList.add(firstUnpinnedNote + 1, others)
}
}
val firstArchivedNote =
mutableList.indexOfFirst { item ->
item is BaseNote && item.folder == Folder.ARCHIVED
}
if (firstArchivedNote != -1) {
mutableList.add(firstArchivedNote, archived)
}
return mutableList
} }
} }
} }

View file

@ -0,0 +1,24 @@
package com.philkes.notallyx.presentation.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import com.philkes.notallyx.data.NotallyDatabase
import com.philkes.notallyx.data.dao.LabelDao
import com.philkes.notallyx.data.model.Label
class LabelModel(app: Application) : AndroidViewModel(app) {
private lateinit var labelDao: LabelDao
lateinit var labels: LiveData<List<String>>
init {
NotallyDatabase.getDatabase(app).observeForever {
labelDao = it.getLabelDao()
labels = labelDao.getAll()
}
}
fun insertLabel(label: Label, onComplete: (success: Boolean) -> Unit) =
executeAsyncWithCallback({ labelDao.insert(label) }, onComplete)
}

View file

@ -19,14 +19,12 @@ import com.philkes.notallyx.R
import com.philkes.notallyx.data.NotallyDatabase import com.philkes.notallyx.data.NotallyDatabase
import com.philkes.notallyx.data.dao.BaseNoteDao import com.philkes.notallyx.data.dao.BaseNoteDao
import com.philkes.notallyx.data.dao.NoteIdReminder import com.philkes.notallyx.data.dao.NoteIdReminder
import com.philkes.notallyx.data.imports.txt.extractListItems
import com.philkes.notallyx.data.imports.txt.findListSyntaxRegex
import com.philkes.notallyx.data.model.Audio import com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.data.model.BaseNote import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Color
import com.philkes.notallyx.data.model.FileAttachment import com.philkes.notallyx.data.model.FileAttachment
import com.philkes.notallyx.data.model.Folder import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.data.model.ListItem import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.data.model.Reminder import com.philkes.notallyx.data.model.Reminder
import com.philkes.notallyx.data.model.SpanRepresentation import com.philkes.notallyx.data.model.SpanRepresentation
import com.philkes.notallyx.data.model.Type import com.philkes.notallyx.data.model.Type
@ -43,7 +41,7 @@ import com.philkes.notallyx.presentation.widget.WidgetProvider
import com.philkes.notallyx.utils.Cache import com.philkes.notallyx.utils.Cache
import com.philkes.notallyx.utils.Event import com.philkes.notallyx.utils.Event
import com.philkes.notallyx.utils.FileError import com.philkes.notallyx.utils.FileError
import com.philkes.notallyx.utils.backup.checkBackupOnSave import com.philkes.notallyx.utils.backup.checkAutoSave
import com.philkes.notallyx.utils.backup.importAudio import com.philkes.notallyx.utils.backup.importAudio
import com.philkes.notallyx.utils.backup.importFile import com.philkes.notallyx.utils.backup.importFile
import com.philkes.notallyx.utils.cancelNoteReminders import com.philkes.notallyx.utils.cancelNoteReminders
@ -75,7 +73,7 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
var id = 0L var id = 0L
var folder = Folder.NOTES var folder = Folder.NOTES
var color = BaseNote.COLOR_DEFAULT var color = Color.DEFAULT
var title = String() var title = String()
var pinned = false var pinned = false
@ -87,13 +85,10 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
var body: Editable = SpannableStringBuilder() var body: Editable = SpannableStringBuilder()
val items = ArrayList<ListItem>() val items = ArrayList<ListItem>()
val images = NotNullLiveData<List<FileAttachment>>(emptyList()) val images = NotNullLiveData<List<FileAttachment>>(emptyList())
val files = NotNullLiveData<List<FileAttachment>>(emptyList()) val files = NotNullLiveData<List<FileAttachment>>(emptyList())
val audios = NotNullLiveData<List<Audio>>(emptyList()) val audios = NotNullLiveData<List<Audio>>(emptyList())
val reminders = NotNullLiveData<List<Reminder>>(emptyList()) val reminders = NotNullLiveData<List<Reminder>>(emptyList())
val viewMode = NotNullLiveData(NoteViewMode.EDIT)
val addingFiles = MutableLiveData<Progress>() val addingFiles = MutableLiveData<Progress>()
val eventBus = MutableLiveData<Event<List<FileError>>>() val eventBus = MutableLiveData<Event<List<FileError>>>()
@ -102,7 +97,7 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
var audioRoot = app.getExternalAudioDirectory() var audioRoot = app.getExternalAudioDirectory()
var filesRoot = app.getExternalFilesDirectory() var filesRoot = app.getExternalFilesDirectory()
var originalNote: BaseNote? = null private lateinit var originalNote: BaseNote
init { init {
database.observeForever { baseNoteDao = it.getBaseNoteDao() } database.observeForever { baseNoteDao = it.getBaseNoteDao() }
@ -252,7 +247,6 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
files.value = baseNote.files files.value = baseNote.files
audios.value = baseNote.audios audios.value = baseNote.audios
reminders.value = baseNote.reminders reminders.value = baseNote.reminders
viewMode.value = baseNote.viewMode
} else { } else {
originalNote = createBaseNote() originalNote = createBaseNote()
app.showToast(R.string.cant_find_note) app.showToast(R.string.cant_find_note)
@ -275,7 +269,7 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
withContext(Dispatchers.IO) { app.deleteAttachments(attachments) } withContext(Dispatchers.IO) { app.deleteAttachments(attachments) }
} }
if (checkAutoSave) { if (checkAutoSave) {
app.checkBackupOnSave(preferences, forceFullBackup = true) app.checkAutoSave(preferences, forceFullBackup = true)
} }
} }
@ -284,26 +278,19 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
this.items.addAll(items) this.items.addAll(items)
} }
suspend fun saveNote(checkBackupOnSave: Boolean = true): Long { suspend fun saveNote(): Long {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val note = getBaseNote() val note = getBaseNote()
val id = baseNoteDao.insert(note) val id = baseNoteDao.insert(note)
if (checkBackupOnSave) { app.checkAutoSave(
checkBackupOnSave(note) preferences,
} note = note,
originalNote = note.deepCopy() forceFullBackup = originalNote.attachmentsDifferFrom(note),
)
return@withContext id return@withContext id
} }
} }
suspend fun checkBackupOnSave(note: BaseNote = getBaseNote()) {
app.checkBackupOnSave(
preferences,
note = note,
forceFullBackup = originalNote?.attachmentsDifferFrom(note) == true,
)
}
fun isEmpty(): Boolean { fun isEmpty(): Boolean {
return title.isEmpty() && return title.isEmpty() &&
body.isEmpty() && body.isEmpty() &&
@ -329,7 +316,7 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
withContext(Dispatchers.IO) { baseNoteDao.updateAudios(id, audios.value) } withContext(Dispatchers.IO) { baseNoteDao.updateAudios(id, audios.value) }
} }
fun getBaseNote(): BaseNote { private fun getBaseNote(): BaseNote {
val spans = getFilteredSpans(body) val spans = getFilteredSpans(body)
val body = this.body.toString() val body = this.body.toString()
val nonEmptyItems = this.items.filter { item -> item.body.isNotEmpty() } val nonEmptyItems = this.items.filter { item -> item.body.isNotEmpty() }
@ -350,7 +337,6 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
files.value, files.value,
audios.value, audios.value,
reminders.value, reminders.value,
viewMode.value,
) )
} }
@ -449,34 +435,6 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
withContext(Dispatchers.IO) { baseNoteDao.updateReminders(id, updatedReminders) } withContext(Dispatchers.IO) { baseNoteDao.updateReminders(id, updatedReminders) }
} }
suspend fun convertTo(noteType: Type) {
when (noteType) {
Type.NOTE -> {
body = SpannableStringBuilder(items.joinToString(separator = "\n") { it.body })
type = Type.NOTE
setItems(ArrayList())
}
Type.LIST -> {
val text = body.toString()
val listSyntaxRegex =
text.findListSyntaxRegex(checkContains = true, plainNewLineAllowed = true)
if (listSyntaxRegex != null) {
setItems(text.extractListItems(listSyntaxRegex))
} else {
setItems(
text.lines().mapIndexed { idx, itemText ->
ListItem(itemText, false, false, idx, mutableListOf())
}
)
}
type = Type.LIST
body = SpannableStringBuilder()
}
}
Cache.list = ArrayList()
saveNote(checkBackupOnSave = false)
}
enum class FileType { enum class FileType {
IMAGE, IMAGE,
ANY, ANY,

View file

@ -29,7 +29,6 @@ class NotallyXPreferences private constructor(private val context: Context) {
} }
val theme = createEnumPreference(preferences, "theme", Theme.FOLLOW_SYSTEM, R.string.theme) val theme = createEnumPreference(preferences, "theme", Theme.FOLLOW_SYSTEM, R.string.theme)
val useDynamicColors = BooleanPreference("useDynamicColors", preferences, false)
val textSize = val textSize =
createEnumPreference(preferences, "textSize", TextSize.MEDIUM, R.string.text_size) createEnumPreference(preferences, "textSize", TextSize.MEDIUM, R.string.text_size)
val dateFormat = val dateFormat =
@ -39,14 +38,12 @@ class NotallyXPreferences private constructor(private val context: Context) {
val notesView = createEnumPreference(preferences, "view", NotesView.LIST, R.string.view) val notesView = createEnumPreference(preferences, "view", NotesView.LIST, R.string.view)
val notesSorting = NotesSortPreference(preferences) val notesSorting = NotesSortPreference(preferences)
val startView =
StringPreference("startView", preferences, START_VIEW_DEFAULT, R.string.start_view)
val listItemSorting = val listItemSorting =
createEnumPreference( createEnumPreference(
preferences, preferences,
"listItemSorting", "checkedListItemSorting",
ListItemSort.AUTO_SORT_BY_CHECKED, ListItemSort.NO_AUTO_SORT,
R.string.list_item_auto_sort, R.string.checked_list_item_sorting,
) )
val maxItems = val maxItems =
@ -76,27 +73,21 @@ class NotallyXPreferences private constructor(private val context: Context) {
10, 10,
R.string.max_lines_to_display_title, R.string.max_lines_to_display_title,
) )
val labelsHidden = StringSetPreference("labelsHiddenInNavigation", preferences, setOf()) val labelsHiddenInNavigation =
val labelTagsHiddenInOverview = StringSetPreference("labelsHiddenInNavigation", preferences, setOf())
val labelsHiddenInOverview =
BooleanPreference( BooleanPreference(
"labelsHiddenInOverview", "labelsHiddenInOverview",
preferences, preferences,
false, false,
R.string.labels_hidden_in_overview_title, R.string.labels_hidden_in_overview_title,
) )
val imagesHiddenInOverview =
BooleanPreference(
"imagesHiddenInOverview",
preferences,
false,
R.string.images_hidden_in_overview_title,
)
val maxLabels = val maxLabels =
IntPreference( IntPreference(
"maxLabelsInNavigation", "maxLabelsInNavigation",
preferences, preferences,
5, 5,
0, 1,
20, 20,
R.string.max_labels_to_display, R.string.max_labels_to_display,
) )
@ -118,16 +109,6 @@ class NotallyXPreferences private constructor(private val context: Context) {
) )
} }
val autoSaveAfterIdleTime =
IntPreference(
"autoSaveAfterIdleTime",
preferences,
5,
-1,
20,
R.string.auto_save_after_idle_time,
)
val biometricLock = val biometricLock =
createEnumPreference( createEnumPreference(
preferences, preferences,
@ -142,8 +123,6 @@ class NotallyXPreferences private constructor(private val context: Context) {
val fallbackDatabaseEncryptionKey by lazy { val fallbackDatabaseEncryptionKey by lazy {
ByteArrayPreference("fallback_database_encryption_key", encryptedPreferences, ByteArray(0)) ByteArrayPreference("fallback_database_encryption_key", encryptedPreferences, ByteArray(0))
} }
val secureFlag =
BooleanPreference("secureFlag", preferences, false, R.string.disallow_screenshots)
val dataInPublicFolder = val dataInPublicFolder =
BooleanPreference("dataOnExternalStorage", preferences, false, R.string.data_in_public) BooleanPreference("dataOnExternalStorage", preferences, false, R.string.data_in_public)
@ -212,17 +191,15 @@ class NotallyXPreferences private constructor(private val context: Context) {
context.importPreferences(uri, preferences.edit()).also { reload() } context.importPreferences(uri, preferences.edit()).also { reload() }
fun reset() { fun reset() {
preferences.edit().clear().commit() preferences.edit().clear().apply()
encryptedPreferences.edit().clear().apply() encryptedPreferences.edit().clear().apply()
backupsFolder.refresh() backupsFolder.refresh()
dataInPublicFolder.refresh()
theme.refresh()
reload() reload()
startView.refresh()
} }
private fun reload() { private fun reload() {
setOf( setOf(
theme,
textSize, textSize,
dateFormat, dateFormat,
applyDateFormatInNoteView, applyDateFormatInNoteView,
@ -232,15 +209,12 @@ class NotallyXPreferences private constructor(private val context: Context) {
maxItems, maxItems,
maxLines, maxLines,
maxTitle, maxTitle,
secureFlag, labelsHiddenInNavigation,
labelsHidden, labelsHiddenInOverview,
labelTagsHiddenInOverview,
maxLabels, maxLabels,
periodicBackups, periodicBackups,
backupPassword, backupPassword,
backupOnSave, biometricLock,
autoSaveAfterIdleTime,
imagesHiddenInOverview,
) )
.forEach { it.refresh() } .forEach { it.refresh() }
} }
@ -248,8 +222,6 @@ class NotallyXPreferences private constructor(private val context: Context) {
companion object { companion object {
private const val TAG = "NotallyXPreferences" private const val TAG = "NotallyXPreferences"
const val EMPTY_PATH = "emptyPath" const val EMPTY_PATH = "emptyPath"
const val START_VIEW_DEFAULT = ""
const val START_VIEW_UNLABELED = "com.philkes.notallyx.startview.UNLABELED"
@Volatile private var instance: NotallyXPreferences? = null @Volatile private var instance: NotallyXPreferences? = null
@ -263,6 +235,3 @@ class NotallyXPreferences private constructor(private val context: Context) {
} }
} }
} }
val NotallyXPreferences.autoSortByCheckedEnabled
get() = listItemSorting.value == ListItemSort.AUTO_SORT_BY_CHECKED

View file

@ -49,8 +49,7 @@ enum class SortDirection(val textResId: Int, val iconResId: Int) {
enum class NotesSortBy(val textResId: Int, val iconResId: Int, val value: String) { enum class NotesSortBy(val textResId: Int, val iconResId: Int, val value: String) {
TITLE(R.string.title, R.drawable.sort_by_alpha, "autoSortByTitle"), TITLE(R.string.title, R.drawable.sort_by_alpha, "autoSortByTitle"),
CREATION_DATE(R.string.creation_date, R.drawable.calendar_add_on, "autoSortByCreationDate"), CREATION_DATE(R.string.creation_date, R.drawable.calendar_add_on, "autoSortByCreationDate"),
MODIFIED_DATE(R.string.modified_date, R.drawable.edit_calendar, "autoSortByModifiedDate"), MODIFIED_DATE(R.string.modified_date, R.drawable.edit_calendar, "autoSortByModifiedDate");
COLOR(R.string.color, R.drawable.change_color, "autoSortByColor");
companion object { companion object {
fun fromValue(value: String): NotesSortBy? { fun fromValue(value: String): NotesSortBy? {

View file

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

View file

@ -1,6 +1,6 @@
package com.philkes.notallyx.presentation.widget package com.philkes.notallyx.presentation.widget
import android.content.res.ColorStateList import android.content.Intent
import android.graphics.Paint import android.graphics.Paint
import android.os.Build import android.os.Build
import android.util.TypedValue import android.util.TypedValue
@ -11,13 +11,9 @@ import com.philkes.notallyx.NotallyXApplication
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.data.NotallyDatabase import com.philkes.notallyx.data.NotallyDatabase
import com.philkes.notallyx.data.model.BaseNote import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.Type import com.philkes.notallyx.data.model.Type
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.widget.WidgetProvider.Companion.extractWidgetColors import com.philkes.notallyx.presentation.widget.WidgetProvider.Companion.getSelectNoteIntent
import com.philkes.notallyx.presentation.widget.WidgetProvider.Companion.getWidgetCheckedChangeIntent
import com.philkes.notallyx.presentation.widget.WidgetProvider.Companion.getWidgetOpenNoteIntent
import com.philkes.notallyx.presentation.widget.WidgetProvider.Companion.getWidgetSelectNoteIntent
class WidgetFactory( class WidgetFactory(
private val app: NotallyXApplication, private val app: NotallyXApplication,
@ -79,12 +75,10 @@ class WidgetFactory(
setViewVisibility(R.id.Note, View.VISIBLE) setViewVisibility(R.id.Note, View.VISIBLE)
} else setViewVisibility(R.id.Note, View.GONE) } else setViewVisibility(R.id.Note, View.GONE)
setOnClickFillInIntent(R.id.ChangeNote, getWidgetSelectNoteIntent(widgetId)) val intent = Intent(WidgetProvider.ACTION_OPEN_NOTE)
setOnClickFillInIntent(R.id.LinearLayout, getWidgetOpenNoteIntent(note.type, note.id)) setOnClickFillInIntent(R.id.LinearLayout, intent)
val (_, controlsColor) = app.extractWidgetColors(note.color, preferences) setOnClickFillInIntent(R.id.ChangeNote, getSelectNoteIntent(widgetId))
setTextViewsTextColor(listOf(R.id.Title, R.id.Note), controlsColor)
setImageViewColor(R.id.ChangeNote, controlsColor)
} }
} }
@ -96,14 +90,11 @@ class WidgetFactory(
preferences.textSize.value.displayTitleSize, preferences.textSize.value.displayTitleSize,
) )
setTextViewText(R.id.Title, list.title) setTextViewText(R.id.Title, list.title)
setOnClickFillInIntent(R.id.ChangeNote, getWidgetSelectNoteIntent(widgetId))
val openNoteWidgetIntent = getWidgetOpenNoteIntent(list.type, list.id)
setOnClickFillInIntent(R.id.LinearLayout, openNoteWidgetIntent)
setOnClickFillInIntent(R.id.Title, openNoteWidgetIntent)
val (_, controlsColor) = app.extractWidgetColors(list.color, preferences) val intent = Intent(WidgetProvider.ACTION_OPEN_LIST)
setTextViewsTextColor(listOf(R.id.Title), controlsColor) setOnClickFillInIntent(R.id.LinearLayout, intent)
setImageViewColor(R.id.ChangeNote, controlsColor)
setOnClickFillInIntent(R.id.ChangeNote, getSelectNoteIntent(widgetId))
} }
} }
@ -111,68 +102,57 @@ class WidgetFactory(
val item = list.items[index] val item = list.items[index]
val view = val view =
if (item.isChild) { if (item.isChild) {
// Since RemoteViews.view.setViewLayoutMargin is only available with API Level >= 31
// use other layout that uses marginStart to indent child
RemoteViews(app.packageName, R.layout.widget_list_child_item) RemoteViews(app.packageName, R.layout.widget_list_child_item)
} else { } else {
RemoteViews(app.packageName, R.layout.widget_list_item) RemoteViews(app.packageName, R.layout.widget_list_item)
} }
return view.apply { return view.apply {
val (_, controlsColor) = app.extractWidgetColors(list.color, preferences) setTextViewTextSize(
R.id.CheckBox,
TypedValue.COMPLEX_UNIT_SP,
preferences.textSize.value.displayBodySize,
)
setTextViewText(R.id.CheckBox, item.body)
setInt(
R.id.CheckBox,
"setPaintFlags",
if (item.checked) {
Paint.STRIKE_THRU_TEXT_FLAG or Paint.ANTI_ALIAS_FLAG
} else {
Paint.ANTI_ALIAS_FLAG
},
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
setListItemTextView(item, R.id.CheckBox, controlsColor)
setCompoundButtonChecked(R.id.CheckBox, item.checked) setCompoundButtonChecked(R.id.CheckBox, item.checked)
val checkIntent = getWidgetCheckedChangeIntent(list.id, index) val intent = Intent(WidgetProvider.ACTION_CHECKED_CHANGED)
setOnCheckedChangeResponse( intent.putExtra(WidgetProvider.EXTRA_POSITION, index)
R.id.CheckBox, val response = RemoteViews.RemoteResponse.fromFillInIntent(intent)
RemoteViews.RemoteResponse.fromFillInIntent(checkIntent), setOnCheckedChangeResponse(R.id.CheckBox, response)
)
setColorStateList(
R.id.CheckBox,
"setButtonTintList",
ColorStateList.valueOf(controlsColor),
)
} else { } else {
setListItemTextView(item, R.id.CheckBoxText, controlsColor) val intent = Intent(WidgetProvider.ACTION_OPEN_LIST)
setImageViewResource( if (item.checked) {
R.id.CheckBox, setTextViewCompoundDrawablesRelative(
if (item.checked) R.drawable.checkbox_fill else R.drawable.checkbox_outline, R.id.CheckBox,
) R.drawable.checkbox_fill,
setOnClickFillInIntent( 0,
R.id.LinearLayout, 0,
getWidgetCheckedChangeIntent(list.id, index), 0,
) )
setImageViewColor(R.id.CheckBox, controlsColor) } else
setTextViewCompoundDrawablesRelative(
R.id.CheckBox,
R.drawable.checkbox_outline,
0,
0,
0,
)
setOnClickFillInIntent(R.id.CheckBox, intent)
} }
setTextViewsTextColor(listOf(R.id.Title), controlsColor)
} }
} }
private fun RemoteViews.setListItemTextView(item: ListItem, textViewId: Int, fontColor: Int) {
setTextViewTextSize(
textViewId,
TypedValue.COMPLEX_UNIT_SP,
preferences.textSize.value.displayBodySize,
)
setTextViewText(textViewId, item.body)
setInt(
textViewId,
"setPaintFlags",
if (item.checked) {
Paint.STRIKE_THRU_TEXT_FLAG or Paint.ANTI_ALIAS_FLAG
} else {
Paint.ANTI_ALIAS_FLAG
},
)
setInt(textViewId, "setTextColor", fontColor)
}
private fun RemoteViews.setTextViewsTextColor(viewIds: List<Int>, color: Int) {
viewIds.forEach { viewId -> setInt(viewId, "setTextColor", color) }
}
private fun RemoteViews.setImageViewColor(viewId: Int, color: Int) {
setInt(viewId, "setColorFilter", color)
}
override fun getViewTypeCount() = 3 override fun getViewTypeCount() = 3
override fun hasStableIds(): Boolean { override fun hasStableIds(): Boolean {

View file

@ -5,48 +5,41 @@ import android.app.PendingIntent
import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider import android.appwidget.AppWidgetProvider
import android.content.Context import android.content.Context
import android.content.ContextWrapper
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.widget.RemoteViews import android.widget.RemoteViews
import androidx.core.content.ContextCompat
import com.philkes.notallyx.NotallyXApplication
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.data.NotallyDatabase import com.philkes.notallyx.data.NotallyDatabase
import com.philkes.notallyx.data.dao.BaseNoteDao import com.philkes.notallyx.data.dao.BaseNoteDao
import com.philkes.notallyx.data.model.BaseNote import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Type import com.philkes.notallyx.data.model.Type
import com.philkes.notallyx.data.model.findChildrenPositions
import com.philkes.notallyx.data.model.findParentPosition
import com.philkes.notallyx.presentation.activity.ConfigureWidgetActivity import com.philkes.notallyx.presentation.activity.ConfigureWidgetActivity
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_SELECTED_BASE_NOTE import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_SELECTED_BASE_NOTE
import com.philkes.notallyx.presentation.activity.note.EditListActivity import com.philkes.notallyx.presentation.activity.note.EditListActivity
import com.philkes.notallyx.presentation.activity.note.EditNoteActivity import com.philkes.notallyx.presentation.activity.note.EditNoteActivity
import com.philkes.notallyx.presentation.extractColor
import com.philkes.notallyx.presentation.getContrastFontColor
import com.philkes.notallyx.presentation.view.note.listitem.findChildrenPositions
import com.philkes.notallyx.presentation.view.note.listitem.findParentPosition
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.Theme
import com.philkes.notallyx.utils.embedIntentExtras import com.philkes.notallyx.utils.embedIntentExtras
import com.philkes.notallyx.utils.getOpenNotePendingIntent import com.philkes.notallyx.utils.getOpenNotePendingIntent
import com.philkes.notallyx.utils.isSystemInDarkMode
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class WidgetProvider : AppWidgetProvider() { class WidgetProvider : AppWidgetProvider() {
@OptIn(DelicateCoroutinesApi::class)
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent) super.onReceive(context, intent)
when (intent.action) { when (intent.action) {
ACTION_NOTES_MODIFIED -> { ACTION_NOTES_MODIFIED -> {
val app = context.applicationContext as NotallyXApplication
val noteIds = intent.getLongArrayExtra(EXTRA_MODIFIED_NOTES) val noteIds = intent.getLongArrayExtra(EXTRA_MODIFIED_NOTES)
if (noteIds != null) { if (noteIds != null) {
updateWidgets(context, noteIds, locked = app.locked.value) updateWidgets(context, noteIds)
} }
} }
ACTION_OPEN_NOTE -> openActivity(context, intent, EditNoteActivity::class.java) ACTION_OPEN_NOTE -> openActivity(context, intent, EditNoteActivity::class.java)
@ -60,10 +53,10 @@ class WidgetProvider : AppWidgetProvider() {
private fun checkChanged(intent: Intent, context: Context) { private fun checkChanged(intent: Intent, context: Context) {
val noteId = intent.getLongExtra(EXTRA_SELECTED_BASE_NOTE, 0) val noteId = intent.getLongExtra(EXTRA_SELECTED_BASE_NOTE, 0)
val position = intent.getIntExtra(EXTRA_POSITION, 0) val position = intent.getIntExtra(EXTRA_POSITION, 0)
var checked = val checked =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
intent.getBooleanExtra(RemoteViews.EXTRA_CHECKED, false) intent.getBooleanExtra(RemoteViews.EXTRA_CHECKED, false)
} else null } else false
val database = val database =
NotallyDatabase.getDatabase( NotallyDatabase.getDatabase(
context.applicationContext as Application, context.applicationContext as Application,
@ -77,18 +70,14 @@ class WidgetProvider : AppWidgetProvider() {
val baseNoteDao = database.getBaseNoteDao() val baseNoteDao = database.getBaseNoteDao()
val note = baseNoteDao.get(noteId)!! val note = baseNoteDao.get(noteId)!!
val item = note.items[position] val item = note.items[position]
if (checked == null) {
checked = !item.checked
}
if (item.isChild) { if (item.isChild) {
changeChildChecked(note, position, checked!!, baseNoteDao, noteId) changeChildChecked(note, position, checked, baseNoteDao, noteId)
} else { } else {
val childrenPositions = note.items.findChildrenPositions(position) val childrenPositions = note.items.findChildrenPositions(position)
baseNoteDao.updateChecked(noteId, childrenPositions + position, checked!!) baseNoteDao.updateChecked(noteId, childrenPositions + position, checked)
} }
} finally { } finally {
val app = context.applicationContext as NotallyXApplication updateWidgets(context, longArrayOf(noteId))
updateWidgets(context, longArrayOf(noteId), locked = app.locked.value)
pendingResult.finish() pendingResult.finish()
} }
} }
@ -138,19 +127,28 @@ class WidgetProvider : AppWidgetProvider() {
appWidgetManager: AppWidgetManager, appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray, appWidgetIds: IntArray,
) { ) {
val app = context.applicationContext as NotallyXApplication val app = context.applicationContext as Application
val preferences = NotallyXPreferences.getInstance(app) val preferences = NotallyXPreferences.getInstance(app)
appWidgetIds.forEach { id -> appWidgetIds.forEach { id ->
val noteId = preferences.getWidgetData(id) val noteId = preferences.getWidgetData(id)
val noteType = preferences.getWidgetNoteType(id) ?: return val noteType = preferences.getWidgetNoteType(id)
updateWidget(app, appWidgetManager, id, noteId, noteType, locked = app.locked.value) updateWidget(context, appWidgetManager, id, noteId, noteType)
} }
} }
private fun Context.getOpenNoteIntent(noteId: Long): PendingIntent {
val intent = Intent(this, WidgetProvider::class.java)
intent.putExtra(EXTRA_SELECTED_BASE_NOTE, noteId)
intent.embedIntentExtras()
val flags =
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT or Intent.FILL_IN_ACTION
return PendingIntent.getBroadcast(this, 0, intent, flags)
}
companion object { companion object {
fun updateWidgets(context: Context, noteIds: LongArray? = null, locked: Boolean) { fun updateWidgets(context: Context, noteIds: LongArray? = null, locked: Boolean = false) {
val app = context.applicationContext as Application val app = context.applicationContext as Application
val preferences = NotallyXPreferences.getInstance(app) val preferences = NotallyXPreferences.getInstance(app)
@ -159,7 +157,7 @@ class WidgetProvider : AppWidgetProvider() {
updatableWidgets.forEach { (id, noteId) -> updatableWidgets.forEach { (id, noteId) ->
updateWidget( updateWidget(
app, context,
manager, manager,
id, id,
noteId, noteId,
@ -170,7 +168,7 @@ class WidgetProvider : AppWidgetProvider() {
} }
fun updateWidget( fun updateWidget(
context: ContextWrapper, context: Context,
manager: AppWidgetManager, manager: AppWidgetManager,
id: Int, id: Int,
noteId: Long, noteId: Long,
@ -184,144 +182,40 @@ class WidgetProvider : AppWidgetProvider() {
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id) intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id)
intent.embedIntentExtras() intent.embedIntentExtras()
MainScope().launch { val view =
val database = NotallyDatabase.getDatabase(context).value
val color =
withContext(Dispatchers.IO) { database.getBaseNoteDao().getColorOfNote(noteId) }
if (color == null) {
val app = context.applicationContext as Application
val preferences = NotallyXPreferences.getInstance(app)
preferences.deleteWidget(id)
val view =
RemoteViews(context.packageName, R.layout.widget).apply {
setRemoteAdapter(R.id.ListView, intent)
setEmptyView(R.id.ListView, R.id.Empty)
setOnClickPendingIntent(
R.id.Empty,
Intent(context, WidgetProvider::class.java)
.apply {
action = ACTION_SELECT_NOTE
data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
}
.asPendingIntent(context),
)
setPendingIntentTemplate(
R.id.ListView,
Intent(context, WidgetProvider::class.java).asPendingIntent(context),
)
}
manager.updateAppWidget(id, view)
manager.notifyAppWidgetViewDataChanged(id, R.id.ListView)
return@launch
}
if (!locked) { if (!locked) {
val view = RemoteViews(context.packageName, R.layout.widget).apply {
RemoteViews(context.packageName, R.layout.widget).apply { setRemoteAdapter(R.id.ListView, intent)
setRemoteAdapter(R.id.ListView, intent) setEmptyView(R.id.ListView, R.id.Empty)
setEmptyView(R.id.ListView, R.id.Empty) setOnClickFillInIntent(R.id.Empty, getSelectNoteIntent(id))
setOnClickPendingIntent( setPendingIntentTemplate(
R.id.Empty, R.id.ListView,
Intent(context, WidgetProvider::class.java) noteType?.let { context.getOpenNotePendingIntent(noteId, it) },
.apply { )
action = ACTION_SELECT_NOTE }
data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
}
.asPendingIntent(context),
)
setPendingIntentTemplate(
R.id.ListView,
Intent(context, WidgetProvider::class.java).asPendingIntent(context),
)
val preferences = NotallyXPreferences.getInstance(context)
val (backgroundColor, _) =
context.extractWidgetColors(color, preferences)
noteType?.let {
setOnClickPendingIntent(
R.id.Layout,
Intent(context, WidgetProvider::class.java)
.setOpenNoteIntent(noteType, noteId)
.asPendingIntent(context),
)
}
setInt(R.id.Layout, "setBackgroundColor", backgroundColor)
}
manager.updateAppWidget(id, view)
manager.notifyAppWidgetViewDataChanged(id, R.id.ListView)
} else { } else {
val view = RemoteViews(context.packageName, R.layout.widget_locked).apply {
RemoteViews(context.packageName, R.layout.widget_locked).apply { noteType?.let {
noteType?.let { val lockedPendingIntent =
val lockedPendingIntent = context.getOpenNotePendingIntent(noteId, noteType)
context.getOpenNotePendingIntent(noteId, noteType) setOnClickPendingIntent(R.id.Layout, lockedPendingIntent)
setOnClickPendingIntent(R.id.Layout, lockedPendingIntent) setOnClickPendingIntent(R.id.Text, lockedPendingIntent)
setOnClickPendingIntent(R.id.Text, lockedPendingIntent)
}
setTextViewCompoundDrawablesRelative(
R.id.Text,
0,
R.drawable.lock_big,
0,
0,
)
} }
manager.updateAppWidget(id, view) setTextViewCompoundDrawablesRelative(
R.id.Text,
0,
R.drawable.lock_big,
0,
0,
)
}
} }
manager.updateAppWidget(id, view)
if (!locked) {
manager.notifyAppWidgetViewDataChanged(id, R.id.ListView)
} }
} }
fun getWidgetOpenNoteIntent(noteType: Type, noteId: Long): Intent {
return Intent().setOpenNoteIntent(noteType, noteId)
}
fun getWidgetCheckedChangeIntent(listNoteId: Long, position: Int): Intent {
return Intent().apply {
action = ACTION_CHECKED_CHANGED
putExtra(EXTRA_POSITION, position)
putExtra(EXTRA_SELECTED_BASE_NOTE, listNoteId)
data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
}
}
private fun Intent.setOpenNoteIntent(noteType: Type, noteId: Long) = apply {
action =
when (noteType) {
Type.LIST -> ACTION_OPEN_LIST
Type.NOTE -> ACTION_OPEN_NOTE
}
putExtra(EXTRA_SELECTED_BASE_NOTE, noteId)
data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
}
private fun Intent.asPendingIntent(context: Context): PendingIntent =
PendingIntent.getBroadcast(
context,
0,
this,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
)
fun Context.extractWidgetColors(
color: String,
preferences: NotallyXPreferences,
): Pair<Int, Int> {
val backgroundColor =
if (color == BaseNote.COLOR_DEFAULT) {
val id =
when (preferences.theme.value) {
Theme.DARK -> R.color.md_theme_surface_dark
Theme.LIGHT -> R.color.md_theme_surface
Theme.FOLLOW_SYSTEM -> {
if (isSystemInDarkMode()) R.color.md_theme_surface_dark
else R.color.md_theme_surface
}
}
ContextCompat.getColor(this, id)
} else extractColor(color)
return Pair(backgroundColor, getContrastFontColor(backgroundColor))
}
private fun openActivity(context: Context, originalIntent: Intent, clazz: Class<*>) { private fun openActivity(context: Context, originalIntent: Intent, clazz: Class<*>) {
val id = originalIntent.getLongExtra(EXTRA_SELECTED_BASE_NOTE, 0) val id = originalIntent.getLongExtra(EXTRA_SELECTED_BASE_NOTE, 0)
val widgetId = originalIntent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, 0) val widgetId = originalIntent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, 0)
@ -343,18 +237,19 @@ class WidgetProvider : AppWidgetProvider() {
return intent return intent
} }
fun sendBroadcast(context: Context, ids: LongArray) = fun sendBroadcast(context: Context, ids: LongArray) {
Intent(context, WidgetProvider::class.java).apply { val intent = Intent(context, WidgetProvider::class.java)
action = ACTION_NOTES_MODIFIED intent.action = ACTION_NOTES_MODIFIED
putExtra(EXTRA_MODIFIED_NOTES, ids) intent.putExtra(EXTRA_MODIFIED_NOTES, ids)
context.sendBroadcast(this) context.sendBroadcast(intent)
} }
fun getWidgetSelectNoteIntent(id: Int) = fun getSelectNoteIntent(id: Int): Intent {
Intent(ACTION_SELECT_NOTE).apply { return Intent(ACTION_SELECT_NOTE).apply {
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id) putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id)
data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME)) data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
} }
}
private const val EXTRA_MODIFIED_NOTES = "com.philkes.notallyx.EXTRA_MODIFIED_NOTES" private const val EXTRA_MODIFIED_NOTES = "com.philkes.notallyx.EXTRA_MODIFIED_NOTES"
private const val ACTION_NOTES_MODIFIED = "com.philkes.notallyx.ACTION_NOTE_MODIFIED" private const val ACTION_NOTES_MODIFIED = "com.philkes.notallyx.ACTION_NOTE_MODIFIED"

View file

@ -7,7 +7,6 @@ import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
class ActionMode { class ActionMode {
val enabled = NotNullLiveData(false) val enabled = NotNullLiveData(false)
val loading = NotNullLiveData(false)
val count = NotNullLiveData(0) val count = NotNullLiveData(0)
val selectedNotes = HashMap<Long, BaseNote>() val selectedNotes = HashMap<Long, BaseNote>()
val selectedIds = selectedNotes.keys val selectedIds = selectedNotes.keys
@ -44,17 +43,8 @@ class ActionMode {
} }
} }
fun updateSelected(availableItemIds: List<Long>?) {
selectedNotes.keys
.filter { availableItemIds?.contains(it) == false }
.forEach { selectedNotes.remove(it) }
refresh()
}
fun isEnabled() = enabled.value fun isEnabled() = enabled.value
// We assume selectedNotes.size is 1 // We assume selectedNotes.size is 1
fun getFirstNote() = selectedNotes.values.first() fun getFirstNote() = selectedNotes.values.first()
fun isEmpty() = selectedNotes.values.isEmpty()
} }

View file

@ -80,7 +80,7 @@ private fun Context.createReminderAlarmIntent(noteId: Long, reminderId: Long): P
intent.putExtra(ReminderReceiver.EXTRA_NOTE_ID, noteId) intent.putExtra(ReminderReceiver.EXTRA_NOTE_ID, noteId)
return PendingIntent.getBroadcast( return PendingIntent.getBroadcast(
this, this,
(noteId.toString() + reminderId.toString()).toInt(), 0,
intent, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
) )

View file

@ -1,6 +1,7 @@
package com.philkes.notallyx.utils package com.philkes.notallyx.utils
import android.app.Activity import android.app.Activity
import android.app.KeyguardManager
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
@ -11,11 +12,10 @@ import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.ContextWrapper import android.content.ContextWrapper
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.content.pm.PackageManager
import android.hardware.biometrics.BiometricManager import android.hardware.biometrics.BiometricManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.util.Log import android.util.Log
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
@ -32,19 +32,16 @@ import com.philkes.notallyx.BuildConfig
import com.philkes.notallyx.R import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.BaseNote import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Type import com.philkes.notallyx.data.model.Type
import com.philkes.notallyx.data.model.toText
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_SELECTED_BASE_NOTE import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_SELECTED_BASE_NOTE
import com.philkes.notallyx.presentation.activity.note.EditListActivity import com.philkes.notallyx.presentation.activity.note.EditListActivity
import com.philkes.notallyx.presentation.activity.note.EditNoteActivity import com.philkes.notallyx.presentation.activity.note.EditNoteActivity
import com.philkes.notallyx.presentation.showToast import com.philkes.notallyx.presentation.showToast
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
import java.io.BufferedReader
import java.io.File import java.io.File
import java.io.InputStreamReader
import java.io.OutputStreamWriter import java.io.OutputStreamWriter
import java.io.PrintWriter import java.io.PrintWriter
import java.lang.UnsupportedOperationException
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.text.DateFormat import java.text.DateFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@ -98,7 +95,7 @@ fun ClipboardManager.getLatestText(): CharSequence? {
return primaryClip?.let { if (it.itemCount > 0) it.getItemAt(0)!!.text else null } return primaryClip?.let { if (it.itemCount > 0) it.getItemAt(0)!!.text else null }
} }
fun Context.copyToClipBoard(text: CharSequence) { fun Activity.copyToClipBoard(text: CharSequence) {
ContextCompat.getSystemService(this, ClipboardManager::class.java)?.let { ContextCompat.getSystemService(this, ClipboardManager::class.java)?.let {
val clip = ClipData.newPlainText("label", text) val clip = ClipData.newPlainText("label", text)
it.setPrimaryClip(clip) it.setPrimaryClip(clip)
@ -123,15 +120,30 @@ fun Context.getFileName(uri: Uri): String? =
} }
fun Context.canAuthenticateWithBiometrics(): Int { fun Context.canAuthenticateWithBiometrics(): Int {
val biometricManager = androidx.biometric.BiometricManager.from(this) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val authenticators = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val keyguardManager = ContextCompat.getSystemService(this, KeyguardManager::class.java)
androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG or val packageManager: PackageManager = this.packageManager
androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL if (!packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)) {
return BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE
}
if (keyguardManager?.isKeyguardSecure == false) {
return BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED
}
} else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
val biometricManager: BiometricManager =
this.getSystemService(BiometricManager::class.java)
return biometricManager.canAuthenticate()
} else { } else {
androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG val biometricManager: BiometricManager =
this.getSystemService(BiometricManager::class.java)
return biometricManager.canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
)
} }
return biometricManager.canAuthenticate(authenticators) }
return BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE
} }
fun Context.getUriForFile(file: File): Uri = fun Context.getUriForFile(file: File): Uri =
@ -139,8 +151,6 @@ fun Context.getUriForFile(file: File): Uri =
private val LOG_DATE_FORMATTER = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH) private val LOG_DATE_FORMATTER = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH)
fun Context.getMimeType(uri: Uri) = contentResolver.getType(uri)
fun ContextWrapper.log( fun ContextWrapper.log(
tag: String, tag: String,
msg: String? = null, msg: String? = null,
@ -155,7 +165,8 @@ fun ContextWrapper.log(
fun ContextWrapper.getLastExceptionLog(): String? { fun ContextWrapper.getLastExceptionLog(): String? {
val logFile = getLogFile() val logFile = getLogFile()
if (logFile.exists()) { if (logFile.exists()) {
return logFile.readText().substringAfterLast("[Start]") val logContents = logFile.readText().substringAfterLast("[Start]")
return URLEncoder.encode(logContents, StandardCharsets.UTF_8.toString())
} }
return null return null
} }
@ -171,10 +182,8 @@ fun Context.logToFile(
stackTrace: String? = null, stackTrace: String? = null,
) { ) {
msg?.let { msg?.let {
if (throwable != null) { if (throwable != null || stackTrace != null) {
Log.e(tag, it, throwable) Log.e(tag, it)
} else if (stackTrace != null) {
Log.e(tag, "$it: $stackTrace")
} else { } else {
Log.i(tag, it) Log.i(tag, it)
} }
@ -185,24 +194,16 @@ fun Context.logToFile(
val logFile = val logFile =
folder.findFile(fileName).let { folder.findFile(fileName).let {
if (it == null || !it.exists()) { if (it == null || !it.exists()) {
folder.createFile("text/plain", fileName.removeSuffix(".txt")) folder.createFile("text/plain", fileName)
} else if (it.isLargerThanKb(MAX_LOGS_FILE_SIZE_KB)) { } else if (it.isLargerThanKb(MAX_LOGS_FILE_SIZE_KB)) {
it.delete() it.delete()
folder.createFile("text/plain", fileName.removeSuffix(".txt")) folder.createFile("text/plain", fileName)
} else it } else it
} }
logFile?.let { file -> logFile?.let { file ->
val contentResolver = contentResolver val contentResolver = contentResolver
val (outputStream, logFileContents) = val outputStream = contentResolver.openOutputStream(file.uri, "wa")
try {
Pair(contentResolver.openOutputStream(file.uri, "wa"), null)
} catch (e: UnsupportedOperationException) {
Pair(
contentResolver.openOutputStream(file.uri, "w"),
contentResolver.readFileContents(file.uri),
)
}
outputStream?.use { output -> outputStream?.use { output ->
val writer = PrintWriter(OutputStreamWriter(output, Charsets.UTF_8)) val writer = PrintWriter(OutputStreamWriter(output, Charsets.UTF_8))
@ -210,7 +211,6 @@ fun Context.logToFile(
val formatter = DateFormat.getDateTimeInstance() val formatter = DateFormat.getDateTimeInstance()
val time = formatter.format(System.currentTimeMillis()) val time = formatter.format(System.currentTimeMillis())
logFileContents?.let { writer.println(it) }
if (throwable != null || stackTrace != null) { if (throwable != null || stackTrace != null) {
writer.println("[Start]") writer.println("[Start]")
} }
@ -240,17 +240,6 @@ fun Fragment.reportBug(stackTrace: String?) {
} }
} }
fun Fragment.getExtraBooleanFromBundleOrIntent(
bundle: Bundle?,
key: String,
defaultValue: Boolean,
): Boolean {
return bundle.getExtraBooleanOrDefault(
key,
activity?.intent?.getBooleanExtra(key, defaultValue) ?: defaultValue,
)
}
fun Context.reportBug(stackTrace: String?) { fun Context.reportBug(stackTrace: String?) {
catchNoBrowserInstalled { startActivity(createReportBugIntent(stackTrace)) } catchNoBrowserInstalled { startActivity(createReportBugIntent(stackTrace)) }
} }
@ -268,53 +257,26 @@ fun Context.createReportBugIntent(
title: String? = null, title: String? = null,
body: String? = null, body: String? = null,
): Intent { ): Intent {
fun String?.asQueryParam(paramName: String): String {
return this?.let { "&$paramName=${URLEncoder.encode(this, "UTF-8")}" } ?: ""
}
return Intent( return Intent(
Intent.ACTION_VIEW, Intent.ACTION_VIEW,
Uri.parse( Uri.parse(
"https://github.com/PhilKes/NotallyX/issues/new?labels=bug&projects=&template=bug_report.yml${ "https://github.com/PhilKes/NotallyX/issues/new?labels=bug&projects=&template=bug_report.yml${title?.let { "&title=$it" }}${body?.let { "&what-happened=$it" }}&version=${BuildConfig.VERSION_NAME}&android-version=${Build.VERSION.SDK_INT}${stackTrace?.let { "&logs=$stackTrace" } ?: ""}"
title.asQueryParam("title")
}&version=${BuildConfig.VERSION_NAME}&android-version=${Build.VERSION.SDK_INT}${
stackTrace.asQueryParam("logs")
}${
body.asQueryParam("what-happened")
}"
.take(2000) .take(2000)
), ),
) )
.wrapWithChooser(this) .wrapWithChooser(this)
} }
fun ContextWrapper.shareNote(note: BaseNote) { fun Context.shareNote(title: String, body: CharSequence) {
val body =
when (note.type) {
Type.NOTE -> note.body
Type.LIST -> note.items.toMutableList().toText()
}
val filesUris =
note.images
.map { File(getExternalImagesDirectory(), it.localName) }
.map { getUriForFile(it) }
shareNote(note.title, body, filesUris)
}
private fun Context.shareNote(title: String, body: CharSequence, imageUris: List<Uri>) {
val text = body.truncate(150_000) val text = body.truncate(150_000)
val intent = val intent =
Intent(if (imageUris.size > 1) Intent.ACTION_SEND_MULTIPLE else Intent.ACTION_SEND) Intent(Intent.ACTION_SEND)
.apply { .apply {
type = if (imageUris.isEmpty()) "text/*" else "image/*" type = "text/plain"
putExtra(Intent.EXTRA_TEXT, text.toString()) putExtra(Intent.EXTRA_TEXT, text.toString())
putExtra(Intent.EXTRA_TITLE, title) putExtra(Intent.EXTRA_TITLE, title)
putExtra(Intent.EXTRA_SUBJECT, title) putExtra(Intent.EXTRA_SUBJECT, title)
if (imageUris.size > 1) {
putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(imageUris))
} else if (imageUris.isNotEmpty()) {
putExtra(Intent.EXTRA_STREAM, imageUris.first())
}
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
.wrapWithChooser(this) .wrapWithChooser(this)
startActivity(intent) startActivity(intent)
@ -347,16 +309,11 @@ fun Context.getOpenNotePendingIntent(noteId: Long, noteType: Type): PendingInten
return PendingIntent.getActivity( return PendingIntent.getActivity(
this, this,
0, 0,
getOpenNoteIntent(noteId, noteType, addPendingFlags = false), getOpenNoteIntent(noteId, noteType, addPendingFlags = true),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
} }
fun Context.isSystemInDarkMode(): Boolean {
val currentNightMode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
return currentNightMode == Configuration.UI_MODE_NIGHT_YES
}
fun ContentResolver.determineMimeTypeAndExtension(uri: Uri, proposedMimeType: String?) = fun ContentResolver.determineMimeTypeAndExtension(uri: Uri, proposedMimeType: String?) =
if (proposedMimeType != null && proposedMimeType.contains("/")) { if (proposedMimeType != null && proposedMimeType.contains("/")) {
Pair(proposedMimeType, ".${uri.lastPathSegment?.substringAfterLast(".")}") Pair(proposedMimeType, ".${uri.lastPathSegment?.substringAfterLast(".")}")
@ -417,21 +374,3 @@ fun Uri.toReadablePath(): String {
.replaceFirst("/tree/primary:", "Internal Storage/") .replaceFirst("/tree/primary:", "Internal Storage/")
.replaceFirst("/tree/.*:".toRegex(), "External Storage/") .replaceFirst("/tree/.*:".toRegex(), "External Storage/")
} }
fun Activity.resetApplication() {
val resetApplicationIntent = packageManager.getLaunchIntentForPackage(packageName)
resetApplicationIntent?.setFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
)
startActivity(resetApplicationIntent)
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
}
fun Bundle?.getExtraBooleanOrDefault(key: String, defaultValue: Boolean): Boolean {
return this?.getBoolean(key, defaultValue) ?: defaultValue
}
fun ContentResolver.readFileContents(uri: Uri) =
openInputStream(uri)?.use { inputStream ->
BufferedReader(InputStreamReader(inputStream)).use { reader -> reader.readText() }
} ?: ""

View file

@ -1,251 +0,0 @@
package com.philkes.notallyx.utils
import android.app.Activity
import android.view.View
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.core.widget.doAfterTextChanged
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Color
import com.philkes.notallyx.data.model.ColorString
import com.philkes.notallyx.databinding.DialogColorBinding
import com.philkes.notallyx.databinding.DialogColorPickerBinding
import com.philkes.notallyx.presentation.createTextView
import com.philkes.notallyx.presentation.dp
import com.philkes.notallyx.presentation.extractColor
import com.philkes.notallyx.presentation.setLightStatusAndNavBar
import com.philkes.notallyx.presentation.showAndFocus
import com.philkes.notallyx.presentation.view.main.ColorAdapter
import com.philkes.notallyx.presentation.view.misc.ItemListener
import com.skydoves.colorpickerview.ColorEnvelope
import com.skydoves.colorpickerview.listeners.ColorEnvelopeListener
fun Activity.showColorSelectDialog(
colors: List<String>,
currentColor: String?,
setNavigationbarLight: Boolean?,
callback: (selectedColor: String, oldColor: String?) -> Unit,
deleteCallback: (colorToDelete: String, newColor: String) -> Unit,
) {
val actualColors =
colors.toMutableList().apply {
remove(BaseNote.COLOR_DEFAULT)
remove(BaseNote.COLOR_NEW)
add(0, BaseNote.COLOR_DEFAULT)
add(0, BaseNote.COLOR_NEW)
}
val dialog = MaterialAlertDialogBuilder(this).setTitle(R.string.change_color).create()
val colorAdapter =
ColorAdapter(
actualColors,
currentColor,
object : ItemListener {
override fun onClick(position: Int) {
dialog.dismiss()
val selectedColor = actualColors[position]
if (selectedColor == BaseNote.COLOR_NEW) {
showEditColorDialog(
actualColors,
null,
setNavigationbarLight,
callback,
deleteCallback,
)
} else callback(selectedColor, null)
}
override fun onLongClick(position: Int) {
val oldColor = actualColors[position]
if (oldColor == BaseNote.COLOR_DEFAULT || oldColor == BaseNote.COLOR_NEW) {
return
}
dialog.dismiss()
showEditColorDialog(
actualColors,
oldColor,
setNavigationbarLight,
callback,
deleteCallback,
)
}
},
)
DialogColorBinding.inflate(layoutInflater).apply {
MainListView.adapter = colorAdapter
dialog.setView(root)
dialog.setOnShowListener {
setNavigationbarLight?.let { dialog.window?.setLightStatusAndNavBar(it) }
}
dialog.show()
}
}
private fun Activity.showEditColorDialog(
colors: List<String>,
oldColor: String?,
setNavigationbarLight: Boolean?,
callback: (selectedColor: String, oldColor: String?) -> Unit,
deleteCallback: (colorToDelete: String, newColor: String) -> Unit,
) {
val selectedColor = oldColor?.let { extractColor(it) } ?: extractColor(BaseNote.COLOR_DEFAULT)
var editTextChangedByUser = false
val binding =
DialogColorPickerBinding.inflate(layoutInflater).apply {
BrightnessSlideBar.setSelectorDrawableRes(
com.skydoves.colorpickerview.R.drawable.colorpickerview_wheel
)
ColorPicker.apply {
BrightnessSlideBar.attachColorPickerView(ColorPicker)
attachBrightnessSlider(BrightnessSlideBar)
setInitialColor(selectedColor)
ColorPicker.postDelayed({ ColorPicker.selectByHsvColor(selectedColor) }, 100)
ColorCode.doAfterTextChanged { text ->
val isValueChangedByUser = ColorCode.hasFocus()
val hexCode = text.toString()
if (isValueChangedByUser && hexCode.length == 6) {
try {
val color = this@showEditColorDialog.extractColor("#$hexCode")
editTextChangedByUser = true
ColorPicker.selectByHsvColor(color)
} catch (e: Exception) {}
}
}
CopyCode.setOnClickListener { _ ->
this@showEditColorDialog.copyToClipBoard(ColorCode.text)
}
}
Restore.setOnClickListener { ColorPicker.selectByHsvColor(selectedColor) }
ExistingColors.apply {
val existingColors = Color.allColorStrings()
val colorAdapter =
ColorAdapter(
existingColors,
null,
object : ItemListener {
override fun onClick(position: Int) {
ColorPicker.selectByHsvColor(
this@showEditColorDialog.extractColor(existingColors[position])
)
}
override fun onLongClick(position: Int) {}
},
)
adapter = colorAdapter
}
}
MaterialAlertDialogBuilder(this).apply {
setTitle(if (oldColor != null) R.string.edit_color else R.string.new_color)
setView(binding.root)
setPositiveButton(R.string.save) { _, _ ->
val newColor = binding.ColorPicker.colorEnvelope.toColorString()
if (newColor == oldColor) {
callback(oldColor, null)
} else {
callback(newColor, oldColor)
}
}
setNegativeButton(R.string.back) { _, _ ->
showColorSelectDialog(colors, oldColor, setNavigationbarLight, callback, deleteCallback)
}
oldColor?.let {
setNeutralButton(R.string.delete) { _, _ ->
showDeleteColorDialog(
colors,
oldColor,
setNavigationbarLight,
callback,
deleteCallback,
)
}
}
showAndFocus(
allowFullSize = true,
onShowListener = {
setNavigationbarLight?.let {
window?.apply { setLightStatusAndNavBar(it, binding.root) }
}
},
applyToPositiveButton = { positiveButton ->
binding.apply {
BrightnessSlideBar.setSelectorDrawableRes(
com.skydoves.colorpickerview.R.drawable.colorpickerview_wheel
)
ColorPicker.setColorListener(
ColorEnvelopeListener { color, _ ->
TileView.setPaintColor(color.color)
val colorString = color.toColorString()
val isSaveEnabled = colorString == oldColor || colorString !in colors
positiveButton.isEnabled = isSaveEnabled
ColorExistsText.visibility =
if (isSaveEnabled) View.INVISIBLE else View.VISIBLE
if (!editTextChangedByUser) {
ColorCode.setText(color.hexCode.argbToRgbString())
} else editTextChangedByUser = false
}
)
}
},
)
}
}
private fun Activity.showDeleteColorDialog(
colors: List<String>,
oldColor: String,
setNavigationbarLight: Boolean?,
callback: (selectedColor: String, oldColor: String?) -> Unit,
deleteCallback: (colorToDelete: String, newColor: String) -> Unit,
) {
val dialog =
MaterialAlertDialogBuilder(this)
.setCustomTitle(createTextView(R.string.delete_color_message))
.setNeutralButton(R.string.back) { _, _ ->
showEditColorDialog(
colors,
oldColor,
setNavigationbarLight,
callback,
deleteCallback,
)
}
.create()
val selectableColors = colors.filter { it != BaseNote.COLOR_NEW && it != oldColor }
val colorAdapter =
ColorAdapter(
selectableColors,
null,
object : ItemListener {
override fun onClick(position: Int) {
dialog.dismiss()
val selectedColor = selectableColors[position]
deleteCallback(oldColor, selectedColor)
}
override fun onLongClick(position: Int) {}
},
)
DialogColorBinding.inflate(layoutInflater).apply {
MainListView.apply {
updatePadding(left = 2.dp, right = 2.dp)
(layoutManager as? GridLayoutManager)?.let { it.spanCount = 6 }
adapter = colorAdapter
}
Message.isVisible = false
dialog.setView(root)
dialog.setOnShowListener {
setNavigationbarLight?.let { window?.apply { setLightStatusAndNavBar(it, root) } }
}
dialog.show()
}
}
private fun ColorEnvelope.toColorString(): ColorString {
return "#${hexCode.argbToRgbString()}"
}
private fun ColorString.argbToRgbString(): ColorString = substring(2)

View file

@ -1,35 +1,59 @@
package com.philkes.notallyx.utils package com.philkes.notallyx.utils
import android.os.Bundle import android.os.Bundle
import android.widget.Button
import android.widget.ImageView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import cat.ereza.customactivityoncrash.CustomActivityOnCrash import cat.ereza.customactivityoncrash.CustomActivityOnCrash
import com.philkes.notallyx.databinding.ActivityErrorBinding import com.philkes.notallyx.R
import com.philkes.notallyx.presentation.dp
/** /**
* Activity used when the app is about to crash. Implicitly used by * Activity used when the app is about to crash. Implicitly used by cat.ereza:customactivityoncrash.
* `cat.ereza:customactivityoncrash`.
*/ */
class ErrorActivity : AppCompatActivity() { class ErrorActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val binding = ActivityErrorBinding.inflate(layoutInflater) setContentView(
setContentView(binding.root) cat.ereza.customactivityoncrash.R.layout.customactivityoncrash_default_error_activity
binding.apply { )
RestartButton.setOnClickListener { findViewById<ImageView>(
CustomActivityOnCrash.restartApplication( cat.ereza.customactivityoncrash.R.id.customactivityoncrash_error_activity_image
this@ErrorActivity, )
CustomActivityOnCrash.getConfigFromIntent(intent)!!, .apply {
minimumWidth = 100.dp(this@ErrorActivity)
minimumHeight = 100.dp(this@ErrorActivity)
setImageResource(R.drawable.error)
}
findViewById<Button>(
cat.ereza.customactivityoncrash.R.id
.customactivityoncrash_error_activity_restart_button
)
.apply {
setText(
cat.ereza.customactivityoncrash.R.string
.customactivityoncrash_error_activity_restart_app
) )
setOnClickListener {
CustomActivityOnCrash.restartApplication(
this@ErrorActivity,
CustomActivityOnCrash.getConfigFromIntent(intent)!!,
)
}
} }
val stackTrace = CustomActivityOnCrash.getStackTraceFromIntent(intent)
val stackTrace = CustomActivityOnCrash.getStackTraceFromIntent(intent) stackTrace?.let { application.log(TAG, stackTrace = it) }
stackTrace?.let { findViewById<Button>(
application.log(TAG, stackTrace = it) cat.ereza.customactivityoncrash.R.id
Exception.text = stackTrace.lines().firstOrNull() ?: "" .customactivityoncrash_error_activity_more_info_button
)
.apply {
setText(R.string.report_bug)
setOnClickListener {
reportBug(CustomActivityOnCrash.getStackTraceFromIntent(intent))
}
} }
ReportButton.setOnClickListener { reportBug(stackTrace) }
}
} }
companion object { companion object {

View file

@ -103,14 +103,6 @@ fun File.toRelativePathFrom(baseFolderName: String): String {
return relativePath.trimStart(File.separatorChar) return relativePath.trimStart(File.separatorChar)
} }
fun File.recreateDir(): File {
if (exists()) {
deleteRecursively()
}
mkdirs()
return this
}
fun ContextWrapper.deleteAttachments( fun ContextWrapper.deleteAttachments(
attachments: Collection<Attachment>, attachments: Collection<Attachment>,
ids: LongArray? = null, ids: LongArray? = null,
@ -149,7 +141,7 @@ fun Context.getExportedPath() = getEmptyFolder("exported")
fun ContextWrapper.getLogsDir() = File(filesDir, "logs").also { it.mkdir() } fun ContextWrapper.getLogsDir() = File(filesDir, "logs").also { it.mkdir() }
const val APP_LOG_FILE_NAME = "notallyx-logs.txt" const val APP_LOG_FILE_NAME = "Log.v1.txt"
fun ContextWrapper.getLogFile(): File { fun ContextWrapper.getLogFile(): File {
return File(getLogsDir(), APP_LOG_FILE_NAME) return File(getLogsDir(), APP_LOG_FILE_NAME)

View file

@ -1,63 +0,0 @@
package com.philkes.notallyx.utils
import androidx.recyclerview.widget.SortedList
fun <R, C> SortedList<R>.map(transform: (R) -> C): List<C> {
return (0 until this.size()).map { transform.invoke(this[it]) }
}
fun <R, C> SortedList<R>.mapIndexed(transform: (Int, R) -> C): List<C> {
return (0 until this.size()).mapIndexed { idx, it -> transform.invoke(idx, this[it]) }
}
fun <R> SortedList<R>.forEach(function: (item: R) -> Unit) {
return (0 until this.size()).forEach { function.invoke(this[it]) }
}
fun <R> SortedList<R>.forEachIndexed(function: (idx: Int, item: R) -> Unit) {
for (i in 0 until this.size()) {
function.invoke(i, this[i])
}
}
fun <R> SortedList<R>.filter(function: (item: R) -> Boolean): List<R> {
val list = mutableListOf<R>()
for (i in 0 until this.size()) {
if (function.invoke(this[i] as R)) {
list.add(this[i] as R)
}
}
return list.toList()
}
fun <R> SortedList<R>.find(function: (item: R) -> Boolean): R? {
for (i in 0 until this.size()) {
if (function.invoke(this[i])) {
return this[i]
}
}
return null
}
fun <R> SortedList<R>.indexOfFirst(function: (item: R) -> Boolean): Int? {
for (i in 0 until this.size()) {
if (function.invoke(this[i])) {
return i
}
}
return null
}
val SortedList<*>.lastIndex: Int
get() = this.size() - 1
val SortedList<*>.indices: IntRange
get() = (0 until this.size())
fun SortedList<*>.isNotEmpty(): Boolean {
return size() > 0
}
fun SortedList<*>.isEmpty(): Boolean {
return size() == 0
}

View file

@ -1,7 +1,6 @@
package com.philkes.notallyx.utils.backup package com.philkes.notallyx.utils.backup
import android.content.Context import android.content.Context
import android.content.ContextWrapper
import androidx.work.Worker import androidx.work.Worker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
@ -9,6 +8,6 @@ class AutoBackupWorker(private val context: Context, params: WorkerParameters) :
Worker(context, params) { Worker(context, params) {
override fun doWork(): Result { override fun doWork(): Result {
return (context.applicationContext as ContextWrapper).createBackup() return context.createBackup()
} }
} }

View file

@ -13,14 +13,11 @@ import android.os.Build
import android.print.PdfPrintListener import android.print.PdfPrintListener
import android.print.printPdf import android.print.printPdf
import android.util.Log import android.util.Log
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.lifecycleScope
import androidx.work.Data import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ListenableWorker.Result import androidx.work.ListenableWorker.Result
@ -36,9 +33,7 @@ import com.philkes.notallyx.data.model.FileAttachment
import com.philkes.notallyx.data.model.toHtml import com.philkes.notallyx.data.model.toHtml
import com.philkes.notallyx.data.model.toJson import com.philkes.notallyx.data.model.toJson
import com.philkes.notallyx.data.model.toTxt import com.philkes.notallyx.data.model.toTxt
import com.philkes.notallyx.presentation.activity.LockedActivity
import com.philkes.notallyx.presentation.activity.main.MainActivity import com.philkes.notallyx.presentation.activity.main.MainActivity
import com.philkes.notallyx.presentation.view.misc.MenuDialog
import com.philkes.notallyx.presentation.view.misc.Progress import com.philkes.notallyx.presentation.view.misc.Progress
import com.philkes.notallyx.presentation.viewmodel.BackupFile import com.philkes.notallyx.presentation.viewmodel.BackupFile
import com.philkes.notallyx.presentation.viewmodel.ExportMimeType import com.philkes.notallyx.presentation.viewmodel.ExportMimeType
@ -56,16 +51,12 @@ import com.philkes.notallyx.utils.getExportedPath
import com.philkes.notallyx.utils.getExternalAudioDirectory import com.philkes.notallyx.utils.getExternalAudioDirectory
import com.philkes.notallyx.utils.getExternalFilesDirectory import com.philkes.notallyx.utils.getExternalFilesDirectory
import com.philkes.notallyx.utils.getExternalImagesDirectory import com.philkes.notallyx.utils.getExternalImagesDirectory
import com.philkes.notallyx.utils.getUriForFile
import com.philkes.notallyx.utils.listZipFiles import com.philkes.notallyx.utils.listZipFiles
import com.philkes.notallyx.utils.log import com.philkes.notallyx.utils.log
import com.philkes.notallyx.utils.logToFile import com.philkes.notallyx.utils.logToFile
import com.philkes.notallyx.utils.nameWithoutExtension
import com.philkes.notallyx.utils.recreateDir
import com.philkes.notallyx.utils.removeTrailingParentheses import com.philkes.notallyx.utils.removeTrailingParentheses
import com.philkes.notallyx.utils.security.decryptDatabase import com.philkes.notallyx.utils.security.decryptDatabase
import com.philkes.notallyx.utils.security.getInitializedCipherForDecryption import com.philkes.notallyx.utils.security.getInitializedCipherForDecryption
import com.philkes.notallyx.utils.wrapWithChooser
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.IOException import java.io.IOException
@ -78,7 +69,6 @@ import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import net.lingala.zip4j.ZipFile import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.exception.ZipException import net.lingala.zip4j.exception.ZipException
@ -98,7 +88,7 @@ const val OUTPUT_DATA_EXCEPTION = "exception"
private const val ON_SAVE_BACKUP_FILE = "NotallyX_AutoBackup.zip" private const val ON_SAVE_BACKUP_FILE = "NotallyX_AutoBackup.zip"
private const val PERIODIC_BACKUP_FILE_PREFIX = "NotallyX_Backup_" private const val PERIODIC_BACKUP_FILE_PREFIX = "NotallyX_Backup_"
fun ContextWrapper.createBackup(): Result { fun Context.createBackup(): Result {
val app = applicationContext as Application val app = applicationContext as Application
val preferences = NotallyXPreferences.getInstance(app) val preferences = NotallyXPreferences.getInstance(app)
val (_, maxBackups) = preferences.periodicBackups.value val (_, maxBackups) = preferences.periodicBackups.value
@ -106,11 +96,7 @@ fun ContextWrapper.createBackup(): Result {
if (path != EMPTY_PATH) { if (path != EMPTY_PATH) {
val uri = Uri.parse(path) val uri = Uri.parse(path)
val folder = val folder = requireNotNull(DocumentFile.fromTreeUri(app, uri))
requireBackupFolder(
path,
"Periodic Backup failed, because auto-backup path '$path' is invalid",
) ?: return Result.success()
fun log(msg: String? = null, throwable: Throwable? = null, stackTrace: String? = null) { fun log(msg: String? = null, throwable: Throwable? = null, stackTrace: String? = null) {
logToFile( logToFile(
TAG, TAG,
@ -121,60 +107,79 @@ fun ContextWrapper.createBackup(): Result {
stackTrace = stackTrace, stackTrace = stackTrace,
) )
} }
try {
if (folder.exists()) {
val formatter = SimpleDateFormat("yyyyMMdd-HHmmssSSS", Locale.ENGLISH) val formatter = SimpleDateFormat("yyyyMMdd-HHmmssSSS", Locale.ENGLISH)
val backupFilePrefix = PERIODIC_BACKUP_FILE_PREFIX val backupFilePrefix = PERIODIC_BACKUP_FILE_PREFIX
val name = "$backupFilePrefix${formatter.format(System.currentTimeMillis())}.zip" val name = "$backupFilePrefix${formatter.format(System.currentTimeMillis())}"
log(msg = "Creating '$uri/$name'...") log(msg = "Creating '$uri/$name.zip'...")
val zipUri = requireNotNull(folder.createFile(MIME_TYPE_ZIP, name)).uri try {
val exportedNotes = app.exportAsZip(zipUri, password = preferences.backupPassword.value) val zipUri = requireNotNull(folder.createFile(MIME_TYPE_ZIP, name)).uri
log(msg = "Exported $exportedNotes notes") val exportedNotes =
val backupFiles = folder.listZipFiles(backupFilePrefix) app.exportAsZip(zipUri, password = preferences.backupPassword.value)
log(msg = "Found ${backupFiles.size} backups") log(msg = "Exported $exportedNotes notes")
val backupsToBeDeleted = backupFiles.drop(maxBackups) val backupFiles = folder.listZipFiles(backupFilePrefix)
if (backupsToBeDeleted.isNotEmpty()) { log(msg = "Found ${backupFiles.size} backups")
log( val backupsToBeDeleted = backupFiles.drop(maxBackups)
msg = if (backupsToBeDeleted.isNotEmpty()) {
"Deleting ${backupsToBeDeleted.size} oldest backups (maxBackups: $maxBackups): ${backupsToBeDeleted.joinToString { "'${it.name.toString()}'" }}" log(
msg =
"Deleting ${backupsToBeDeleted.size} oldest backups (maxBackups: $maxBackups): ${backupsToBeDeleted.joinToString { "'${it.name.toString()}'" }}"
)
}
backupsToBeDeleted.forEach {
if (it.exists()) {
it.delete()
}
}
log(msg = "Finished backup to '$zipUri'")
preferences.periodicBackupLastExecution.save(Date().time)
return Result.success(
Data.Builder().putString(OUTPUT_DATA_BACKUP_URI, zipUri.path!!).build()
)
} catch (e: Exception) {
log(msg = "Failed creating backup to '$uri/$name'", throwable = e)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
) {
postErrorNotification(e)
}
} else {
postErrorNotification(e)
}
return Result.success(
Data.Builder().putString(OUTPUT_DATA_EXCEPTION, e.message).build()
) )
} }
backupsToBeDeleted.forEach { } else {
if (it.exists()) { log(msg = "Folder '${folder.uri}' does not exist, therefore skipping auto-backup")
it.delete()
}
}
log(msg = "Finished backup to '$zipUri'")
preferences.periodicBackupLastExecution.save(Date().time)
return Result.success(
Data.Builder().putString(OUTPUT_DATA_BACKUP_URI, zipUri.path!!).build()
)
} catch (e: Exception) {
log(msg = "Failed creating backup to '$path'", throwable = e)
tryPostErrorNotification(e)
return Result.success(
Data.Builder().putString(OUTPUT_DATA_EXCEPTION, e.message).build()
)
} }
} }
return Result.success() return Result.success()
} }
fun ContextWrapper.autoBackupOnSave(backupPath: String, password: String, savedNote: BaseNote?) { fun ContextWrapper.autoBackupOnSave(backupPath: String, password: String, savedNote: BaseNote?) {
val folder = val backupFolder =
requireBackupFolder( try {
backupPath, DocumentFile.fromTreeUri(this, backupPath.toUri())!!
"Auto backup on note save (${savedNote?.let { "id: '${savedNote.id}, title: '${savedNote.title}'" }}) failed, because auto-backup path '$backupPath' is invalid", } catch (e: Exception) {
) ?: return log(
fun log(msg: String? = null, throwable: Throwable? = null) { TAG,
logToFile(TAG, folder, NOTALLYX_BACKUP_LOGS_FILE, msg = msg, throwable = throwable) msg =
} "Auto backup on note save (${savedNote?.let {"id: '${savedNote.id}, title: '${savedNote.title}'" }}) failed, because auto-backup path is invalid",
throwable = e,
)
return
}
try { try {
var backupFile = folder.findFile(ON_SAVE_BACKUP_FILE) var backupFile = backupFolder.findFile(ON_SAVE_BACKUP_FILE)
if (savedNote == null || backupFile == null || !backupFile.exists()) { if (savedNote == null || backupFile == null || !backupFile.exists()) {
backupFile = folder.createFile(MIME_TYPE_ZIP, ON_SAVE_BACKUP_FILE) backupFile = backupFolder.createFile(MIME_TYPE_ZIP, ON_SAVE_BACKUP_FILE)
exportAsZip(backupFile!!.uri, password = password) exportAsZip(backupFile!!.uri, password = password)
} else { } else {
val (_, file) = copyDatabase() NotallyDatabase.getDatabase(this, observePreferences = false).value.checkpoint()
val files = val files =
with(savedNote) { with(savedNote) {
images.map { images.map {
@ -192,45 +197,26 @@ fun ContextWrapper.autoBackupOnSave(backupPath: String, password: String, savedN
audios.map { audios.map {
BackupFile(SUBFOLDER_AUDIOS, File(getExternalAudioDirectory(), it.name)) BackupFile(SUBFOLDER_AUDIOS, File(getExternalAudioDirectory(), it.name))
} + } +
BackupFile(null, file) BackupFile(
null,
NotallyDatabase.getCurrentDatabaseFile(this@autoBackupOnSave),
)
} }
try { exportToZip(backupFile.uri, files, password)
exportToZip(backupFile.uri, files, password)
} catch (e: ZipException) {
log(
msg =
"Re-creating full backup since existing auto backup ZIP is corrupt: ${e.message}"
)
backupFile.delete()
autoBackupOnSave(backupPath, password, savedNote)
}
} }
} catch (e: Exception) { } catch (e: Exception) {
log( logToFile(
"Auto backup on note save (${savedNote?.let { "id: '${savedNote.id}, title: '${savedNote.title}'" }}) failed", TAG,
e, backupFolder,
NOTALLYX_BACKUP_LOGS_FILE,
msg =
"Auto backup on note save (${savedNote?.let {"id: '${savedNote.id}, title: '${savedNote.title}'" }}) failed",
throwable = e,
) )
tryPostErrorNotification(e)
} }
} }
private fun ContextWrapper.requireBackupFolder(path: String, msg: String): DocumentFile? { fun ContextWrapper.checkAutoSave(
return try {
val folder = DocumentFile.fromTreeUri(this, path.toUri())!!
if (!folder.exists()) {
log(TAG, msg = msg)
tryPostErrorNotification(IllegalArgumentException("Folder '$path' does not exist"))
return null
}
folder
} catch (e: Exception) {
log(TAG, msg = msg, throwable = e)
tryPostErrorNotification(IllegalArgumentException("Folder '$path' does not exist", e))
return null
}
}
suspend fun ContextWrapper.checkBackupOnSave(
preferences: NotallyXPreferences, preferences: NotallyXPreferences,
note: BaseNote? = null, note: BaseNote? = null,
forceFullBackup: Boolean = false, forceFullBackup: Boolean = false,
@ -241,9 +227,7 @@ suspend fun ContextWrapper.checkBackupOnSave(
if (forceFullBackup) { if (forceFullBackup) {
deleteModifiedNoteBackup(backupPath) deleteModifiedNoteBackup(backupPath)
} }
withContext(Dispatchers.IO) { autoBackupOnSave(backupPath, preferences.backupPassword.value, note)
autoBackupOnSave(backupPath, preferences.backupPassword.value, note)
}
} }
} }
} }
@ -265,81 +249,78 @@ fun ContextWrapper.exportAsZip(
backupProgress: MutableLiveData<Progress>? = null, backupProgress: MutableLiveData<Progress>? = null,
): Int { ): Int {
backupProgress?.postValue(Progress(indeterminate = true)) backupProgress?.postValue(Progress(indeterminate = true))
val tempFile = File.createTempFile("export", "tmp", cacheDir) val tempFile = File.createTempFile("export", "tmp", cacheDir)
try { val zipFile =
val zipFile = ZipFile(tempFile, if (password != PASSWORD_EMPTY) password.toCharArray() else null)
ZipFile(tempFile, if (password != PASSWORD_EMPTY) password.toCharArray() else null) val zipParameters =
val zipParameters = ZipParameters().apply {
ZipParameters().apply { isEncryptFiles = password != PASSWORD_EMPTY
isEncryptFiles = password != PASSWORD_EMPTY if (!compress) {
if (!compress) { compressionLevel = CompressionLevel.NO_COMPRESSION
compressionLevel = CompressionLevel.NO_COMPRESSION
}
encryptionMethod = EncryptionMethod.AES
} }
encryptionMethod = EncryptionMethod.AES
val (databaseOriginal, databaseCopy) = copyDatabase()
zipFile.addFile(databaseCopy, zipParameters.copy(DATABASE_NAME))
databaseCopy.delete()
val imageRoot = getExternalImagesDirectory()
val fileRoot = getExternalFilesDirectory()
val audioRoot = getExternalAudioDirectory()
val totalNotes = databaseOriginal.getBaseNoteDao().count()
val images = databaseOriginal.getBaseNoteDao().getAllImages().toFileAttachments()
val files = databaseOriginal.getBaseNoteDao().getAllFiles().toFileAttachments()
val audios = databaseOriginal.getBaseNoteDao().getAllAudios()
val totalAttachments = images.count() + files.count() + audios.size
backupProgress?.postValue(Progress(0, totalAttachments))
val counter = AtomicInteger(0)
images.export(
zipFile,
zipParameters,
imageRoot,
SUBFOLDER_IMAGES,
this,
backupProgress,
totalAttachments,
counter,
)
files.export(
zipFile,
zipParameters,
fileRoot,
SUBFOLDER_FILES,
this,
backupProgress,
totalAttachments,
counter,
)
audios
.asSequence()
.flatMap { string -> Converters.jsonToAudios(string) }
.forEach { audio ->
try {
backupFile(zipFile, zipParameters, audioRoot, SUBFOLDER_AUDIOS, audio.name)
} catch (exception: Exception) {
log(TAG, throwable = exception)
} finally {
backupProgress?.postValue(Progress(counter.incrementAndGet(), totalAttachments))
}
}
zipFile.close()
contentResolver.openOutputStream(fileUri)?.use { outputStream ->
FileInputStream(zipFile.file).use { inputStream ->
inputStream.copyTo(outputStream)
outputStream.flush()
}
zipFile.file.delete()
} }
backupProgress?.postValue(Progress(inProgress = false))
return totalNotes val (databaseOriginal, databaseCopy) = copyDatabase()
} finally { zipFile.addFile(databaseCopy, zipParameters.copy(DATABASE_NAME))
tempFile.delete() databaseCopy.delete()
val imageRoot = getExternalImagesDirectory()
val fileRoot = getExternalFilesDirectory()
val audioRoot = getExternalAudioDirectory()
val totalNotes = databaseOriginal.getBaseNoteDao().count()
val images = databaseOriginal.getBaseNoteDao().getAllImages().toFileAttachments()
val files = databaseOriginal.getBaseNoteDao().getAllFiles().toFileAttachments()
val audios = databaseOriginal.getBaseNoteDao().getAllAudios()
val totalAttachments = images.count() + files.count() + audios.size
backupProgress?.postValue(Progress(0, totalAttachments))
val counter = AtomicInteger(0)
images.export(
zipFile,
zipParameters,
imageRoot,
SUBFOLDER_IMAGES,
this,
backupProgress,
totalAttachments,
counter,
)
files.export(
zipFile,
zipParameters,
fileRoot,
SUBFOLDER_FILES,
this,
backupProgress,
totalAttachments,
counter,
)
audios
.asSequence()
.flatMap { string -> Converters.jsonToAudios(string) }
.forEach { audio ->
try {
backupFile(zipFile, zipParameters, audioRoot, SUBFOLDER_AUDIOS, audio.name)
} catch (exception: Exception) {
log(TAG, throwable = exception)
} finally {
backupProgress?.postValue(Progress(counter.incrementAndGet(), totalAttachments))
}
}
zipFile.close()
contentResolver.openOutputStream(fileUri)?.use { outputStream ->
FileInputStream(zipFile.file).use { inputStream ->
inputStream.copyTo(outputStream)
outputStream.flush()
}
zipFile.file.delete()
} }
backupProgress?.postValue(Progress(inProgress = false))
return totalNotes
} }
fun Context.exportToZip( fun Context.exportToZip(
@ -347,31 +328,29 @@ fun Context.exportToZip(
files: List<BackupFile>, files: List<BackupFile>,
password: String = PASSWORD_EMPTY, password: String = PASSWORD_EMPTY,
): Boolean { ): Boolean {
val tempDir = File(cacheDir, "export").recreateDir() val tempDir = File(cacheDir, "tempZip").apply { mkdirs() }
try { val zipInputStream = contentResolver.openInputStream(zipUri) ?: return false
val zipInputStream = contentResolver.openInputStream(zipUri) ?: return false extractZipToDirectory(zipInputStream, tempDir, password)
extractZipToDirectory(zipInputStream, tempDir, password) files.forEach { file ->
files.forEach { file -> val targetFile = File(tempDir, "${file.first?.let { "$it/" } ?: ""}${file.second.name}")
val targetFile = File(tempDir, "${file.first?.let { "$it/" } ?: ""}${file.second.name}") file.second.copyTo(targetFile, overwrite = true)
file.second.copyTo(targetFile, overwrite = true)
}
val zipOutputStream = contentResolver.openOutputStream(zipUri, "w") ?: return false
createZipFromDirectory(tempDir, zipOutputStream, password)
} finally {
tempDir.deleteRecursively()
} }
val zipOutputStream = contentResolver.openOutputStream(zipUri, "w") ?: return false
createZipFromDirectory(tempDir, zipOutputStream, password)
tempDir.deleteRecursively()
return true return true
} }
private fun extractZipToDirectory(zipInputStream: InputStream, outputDir: File, password: String) { private fun extractZipToDirectory(zipInputStream: InputStream, outputDir: File, password: String) {
val tempZipFile = File.createTempFile("extractedZip", null, outputDir)
try { try {
val tempZipFile = File.createTempFile("tempZip", ".zip", outputDir)
tempZipFile.outputStream().use { zipOutputStream -> zipInputStream.copyTo(zipOutputStream) } tempZipFile.outputStream().use { zipOutputStream -> zipInputStream.copyTo(zipOutputStream) }
val zipFile = val zipFile =
ZipFile(tempZipFile, if (password != PASSWORD_EMPTY) password.toCharArray() else null) ZipFile(tempZipFile, if (password != PASSWORD_EMPTY) password.toCharArray() else null)
zipFile.extractAll(outputDir.absolutePath) zipFile.extractAll(outputDir.absolutePath)
} finally {
tempZipFile.delete() tempZipFile.delete()
} catch (e: ZipException) {
e.printStackTrace()
} }
} }
@ -381,9 +360,10 @@ private fun createZipFromDirectory(
password: String = PASSWORD_EMPTY, password: String = PASSWORD_EMPTY,
compress: Boolean = false, compress: Boolean = false,
) { ) {
val tempZipFile = File.createTempFile("tempZip", ".zip")
try { try {
val tempZipFile = File.createTempFile("tempZip", ".zip")
tempZipFile.deleteOnExit() tempZipFile.deleteOnExit()
val zipFile = val zipFile =
ZipFile(tempZipFile, if (password != PASSWORD_EMPTY) password.toCharArray() else null) ZipFile(tempZipFile, if (password != PASSWORD_EMPTY) password.toCharArray() else null)
val zipParameters = val zipParameters =
@ -397,8 +377,9 @@ private fun createZipFromDirectory(
} }
zipFile.addFolder(sourceDir, zipParameters) zipFile.addFolder(sourceDir, zipParameters)
tempZipFile.inputStream().use { inputStream -> inputStream.copyTo(zipOutputStream) } tempZipFile.inputStream().use { inputStream -> inputStream.copyTo(zipOutputStream) }
} finally {
tempZipFile.delete() tempZipFile.delete()
} catch (e: Exception) {
e.printStackTrace()
} }
} }
@ -406,7 +387,6 @@ fun ContextWrapper.copyDatabase(): Pair<NotallyDatabase, File> {
val database = NotallyDatabase.getDatabase(this, observePreferences = false).value val database = NotallyDatabase.getDatabase(this, observePreferences = false).value
database.checkpoint() database.checkpoint()
val preferences = NotallyXPreferences.getInstance(this) val preferences = NotallyXPreferences.getInstance(this)
val databaseFile = NotallyDatabase.getCurrentDatabaseFile(this)
return if ( return if (
preferences.biometricLock.value == BiometricLock.ENABLED && preferences.biometricLock.value == BiometricLock.ENABLED &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
@ -414,11 +394,16 @@ fun ContextWrapper.copyDatabase(): Pair<NotallyDatabase, File> {
val cipher = getInitializedCipherForDecryption(iv = preferences.iv.value!!) val cipher = getInitializedCipherForDecryption(iv = preferences.iv.value!!)
val passphrase = cipher.doFinal(preferences.databaseEncryptionKey.value) val passphrase = cipher.doFinal(preferences.databaseEncryptionKey.value)
val decryptedFile = File(cacheDir, DATABASE_NAME) val decryptedFile = File(cacheDir, DATABASE_NAME)
decryptDatabase(this, passphrase, databaseFile, decryptedFile) decryptDatabase(
this,
passphrase,
decryptedFile,
NotallyDatabase.getCurrentDatabaseName(this),
)
Pair(database, decryptedFile) Pair(database, decryptedFile)
} else { } else {
val dbFile = File(cacheDir, DATABASE_NAME) val dbFile = File(cacheDir, DATABASE_NAME)
databaseFile.copyTo(dbFile, overwrite = true) NotallyDatabase.getCurrentDatabaseFile(this).copyTo(dbFile, overwrite = true)
Pair(database, dbFile) Pair(database, dbFile)
} }
} }
@ -504,7 +489,7 @@ private fun ZipParameters.copy(fileNameInZip: String? = this.fileNameInZip): Zip
} }
fun exportPdfFile( fun exportPdfFile(
app: Context, app: Application,
note: BaseNote, note: BaseNote,
folder: DocumentFile, folder: DocumentFile,
fileName: String = note.title, fileName: String = note.title,
@ -514,14 +499,13 @@ fun exportPdfFile(
total: Int? = null, total: Int? = null,
duplicateFileCount: Int = 1, duplicateFileCount: Int = 1,
) { ) {
val validFileName = fileName.ifBlank { app.getString(R.string.note) } val filePath = "$fileName.${ExportMimeType.PDF.fileExtension}"
val filePath = "$validFileName.${ExportMimeType.PDF.fileExtension}"
if (folder.findFile(filePath)?.exists() == true) { if (folder.findFile(filePath)?.exists() == true) {
return exportPdfFile( return exportPdfFile(
app, app,
note, note,
folder, folder,
"${validFileName.removeTrailingParentheses()} ($duplicateFileCount)", "${fileName.removeTrailingParentheses()} ($duplicateFileCount)",
pdfPrintListener, pdfPrintListener,
progress, progress,
counter, counter,
@ -555,7 +539,7 @@ fun exportPdfFile(
} }
suspend fun exportPlainTextFile( suspend fun exportPlainTextFile(
app: Context, app: Application,
note: BaseNote, note: BaseNote,
exportType: ExportMimeType, exportType: ExportMimeType,
folder: DocumentFile, folder: DocumentFile,
@ -579,9 +563,8 @@ suspend fun exportPlainTextFile(
) )
} }
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val validFileName = fileName.takeIf { it.isNotBlank() } ?: app.getString(R.string.note)
val file = val file =
folder.createFile(exportType.mimeType, validFileName)?.let { folder.createFile(exportType.mimeType, fileName)?.let {
app.contentResolver.openOutputStream(it.uri)?.use { stream -> app.contentResolver.openOutputStream(it.uri)?.use { stream ->
OutputStreamWriter(stream).use { writer -> OutputStreamWriter(stream).use { writer ->
writer.write( writer.write(
@ -623,166 +606,52 @@ fun Context.exportPreferences(preferences: NotallyXPreferences, uri: Uri): Boole
} }
} }
private fun Context.tryPostErrorNotification(e: Throwable) { private fun Context.postErrorNotification(e: Throwable) {
fun postErrorNotification(e: Throwable) { getSystemService<NotificationManager>()?.let { manager ->
getSystemService<NotificationManager>()?.let { manager -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { manager.createChannelIfNotExists(NOTIFICATION_CHANNEL_ID)
manager.createChannelIfNotExists(NOTIFICATION_CHANNEL_ID) }
} val notification =
val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) .setSmallIcon(R.drawable.error)
.setSmallIcon(R.drawable.error) .setContentTitle(getString(R.string.auto_backup_failed))
.setContentTitle(getString(R.string.auto_backup_failed)) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setStyle(
.setStyle( NotificationCompat.BigTextStyle()
NotificationCompat.BigTextStyle() .bigText(
.bigText( getString(
getString( R.string.auto_backup_error_message,
R.string.auto_backup_error_message, "${e.javaClass.simpleName}: ${e.localizedMessage}",
"${e.javaClass.simpleName}: ${e.localizedMessage}",
)
) )
)
.addAction(
R.drawable.settings,
getString(R.string.settings),
PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
putExtra(MainActivity.EXTRA_FRAGMENT_TO_OPEN, R.id.Settings)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
),
)
.addAction(
R.drawable.error,
getString(R.string.report_bug),
PendingIntent.getActivity(
this,
0,
createReportBugIntent(
e.stackTraceToString(),
title = "Auto Backup failed",
body = "Error occurred during auto backup, see logs below",
),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
),
)
.build()
manager.notify(NOTIFICATION_ID, notification)
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
) {
postErrorNotification(e)
}
} else {
postErrorNotification(e)
}
}
fun LockedActivity<*>.exportNotes(
mimeType: ExportMimeType,
notes: Collection<BaseNote>,
saveFileResultLauncher: ActivityResultLauncher<Intent>,
exportToFolderResultLauncher: ActivityResultLauncher<Intent>,
) {
baseModel.selectedExportMimeType = mimeType
if (notes.size == 1) {
val baseNote = notes.first()
when (mimeType) {
ExportMimeType.PDF -> {
exportPdfFile(
this,
baseNote,
DocumentFile.fromFile(getExportedPath()),
pdfPrintListener =
object : PdfPrintListener {
override fun onSuccess(file: DocumentFile) {
showFileOptionsDialog(
file,
ExportMimeType.PDF.mimeType,
saveFileResultLauncher,
)
}
override fun onFailure(message: CharSequence?) {
Toast.makeText(
this@exportNotes,
R.string.something_went_wrong,
Toast.LENGTH_SHORT,
)
.show()
}
},
)
}
ExportMimeType.TXT,
ExportMimeType.JSON,
ExportMimeType.HTML ->
lifecycleScope.launch {
exportPlainTextFile(
this@exportNotes,
baseNote,
mimeType,
DocumentFile.fromFile(getExportedPath()),
) )
?.let { )
showFileOptionsDialog(it, mimeType.mimeType, saveFileResultLauncher) .addAction(
} R.drawable.settings,
} getString(R.string.settings),
} PendingIntent.getActivity(
} else { this,
lifecycleScope.launch { 0,
val intent = Intent(this, MainActivity::class.java).apply {
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) putExtra(MainActivity.EXTRA_FRAGMENT_TO_OPEN, R.id.Settings)
.apply { addCategory(Intent.CATEGORY_DEFAULT) } },
.wrapWithChooser(this@exportNotes) PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
exportToFolderResultLauncher.launch(intent) ),
} )
.addAction(
R.drawable.error,
getString(R.string.report_bug),
PendingIntent.getActivity(
this,
0,
createReportBugIntent(
e.stackTraceToString(),
title = "Auto Backup failed",
body = "Error occurred during auto backup, see logs below",
),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
),
)
.build()
manager.notify(NOTIFICATION_ID, notification)
} }
} }
private fun LockedActivity<*>.showFileOptionsDialog(
file: DocumentFile,
mimeType: String,
resultLauncher: ActivityResultLauncher<Intent>,
) {
MenuDialog(this)
.add(R.string.view_file) { viewFile(getUriForFile(File(file.uri.path!!)), mimeType) }
.add(R.string.save_to_device) { saveFileToDevice(file, mimeType, resultLauncher) }
.show()
}
private fun LockedActivity<*>.viewFile(uri: Uri, mimeType: String) {
val intent =
Intent(Intent.ACTION_VIEW)
.apply {
setDataAndType(uri, mimeType)
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
}
.wrapWithChooser(this)
startActivity(intent)
}
private fun LockedActivity<*>.saveFileToDevice(
file: DocumentFile,
mimeType: String,
resultLauncher: ActivityResultLauncher<Intent>,
) {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT)
.apply {
type = mimeType
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_TITLE, file.nameWithoutExtension!!)
}
.wrapWithChooser(this)
baseModel.selectedExportFile = file
resultLauncher.launch(intent)
}

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