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
id: android-version
attributes:
label: Android Version (API Level)
label: Android Version
description: What Android version are you using?
- type: textarea
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

1
.gitignore vendored
View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -11,12 +11,14 @@
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# 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 ** 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
android:name="android.permission.ACCESS_NETWORK_STATE"
tools:node="remove" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
<application
@ -68,30 +69,9 @@
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="file" android:mimeType="text/*" />
<data android:scheme="content" android:mimeType="text/*" />
<data android:scheme="file" android:mimeType="application/json" />
<data android:scheme="content" android:mimeType="application/json" />
<data android:scheme="file" android:mimeType="application/xml" />
<data android:scheme="content" android:mimeType="application/xml" />
</intent-filter>
</activity>
<activity android:name=".presentation.activity.note.ViewImageActivity" />

View file

@ -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
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Bundle
import androidx.appcompat.app.AppCompatDelegate
import androidx.lifecycle.Observer
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.google.android.material.color.DynamicColors
import com.philkes.notallyx.presentation.setEnabledSecureFlag
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
@ -36,7 +32,7 @@ import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class NotallyXApplication : Application(), Application.ActivityLifecycleCallbacks {
class NotallyXApplication : Application() {
private lateinit var biometricLockObserver: Observer<BiometricLock>
private lateinit var preferences: NotallyXPreferences
@ -46,17 +42,11 @@ class NotallyXApplication : Application(), Application.ActivityLifecycleCallback
override fun onCreate() {
super.onCreate()
registerActivityLifecycleCallbacks(this)
if (isTestRunner()) return
preferences = NotallyXPreferences.getInstance(this)
if (preferences.useDynamicColors.value) {
if (DynamicColors.isDynamicColorAvailable()) {
DynamicColors.applyToActivitiesIfAvailable(this)
}
} else {
setTheme(R.style.AppTheme)
}
preferences.theme.observeForeverWithPrevious { (oldTheme, theme) ->
preferences.theme.observeForever { theme ->
when (theme) {
Theme.DARK ->
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
@ -69,9 +59,6 @@ class NotallyXApplication : Application(), Application.ActivityLifecycleCallback
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
)
}
if (oldTheme != null) {
WidgetProvider.updateWidgets(this, locked = locked.value)
}
}
preferences.backupsFolder.observeForeverWithPrevious { (backupFolderBefore, backupFolder) ->
@ -172,20 +159,4 @@ class NotallyXApplication : Application(), Application.ActivityLifecycleCallback
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.LabelDao
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Color
import com.philkes.notallyx.data.model.Converters
import com.philkes.notallyx.data.model.Label
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.data.model.toColorString
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
@ -29,11 +26,10 @@ import com.philkes.notallyx.utils.getExternalMediaDirectory
import com.philkes.notallyx.utils.security.SQLCipherUtils
import com.philkes.notallyx.utils.security.getInitializedCipherForDecryption
import java.io.File
import net.sqlcipher.database.SQLiteDatabase
import net.sqlcipher.database.SupportFactory
@TypeConverters(Converters::class)
@Database(entities = [BaseNote::class, Label::class], version = 9)
@Database(entities = [BaseNote::class, Label::class], version = 7)
abstract class NotallyDatabase : RoomDatabase() {
abstract fun getLabelDao(): LabelDao
@ -47,7 +43,7 @@ abstract class NotallyDatabase : RoomDatabase() {
}
private var biometricLockObserver: Observer<BiometricLock>? = null
private var dataInPublicFolderObserver: Observer<Boolean>? = null
private var externalDataFolderObserver: Observer<Boolean>? = null
companion object {
@ -79,16 +75,7 @@ abstract class NotallyDatabase : RoomDatabase() {
return context.getDatabasePath(DATABASE_NAME)
}
fun getInternalDatabaseFiles(context: ContextWrapper): List<File> {
val directory = context.getDatabasePath(DATABASE_NAME).parentFile
return listOf(
File(directory, DATABASE_NAME),
File(directory, "$DATABASE_NAME-shm"),
File(directory, "$DATABASE_NAME-wal"),
)
}
private fun getCurrentDatabaseName(context: ContextWrapper): String {
fun getCurrentDatabaseName(context: ContextWrapper): String {
return if (NotallyXPreferences.getInstance(context).dataInPublicFolder.value) {
getExternalDatabaseFile(context).absolutePath
} else {
@ -109,10 +96,6 @@ abstract class NotallyDatabase : RoomDatabase() {
}
}
fun getFreshDatabase(context: ContextWrapper): NotallyDatabase {
return createInstance(context, NotallyXPreferences.getInstance(context), false)
}
private fun createInstance(
context: ContextWrapper,
preferences: NotallyXPreferences,
@ -131,14 +114,11 @@ abstract class NotallyDatabase : RoomDatabase() {
Migration5,
Migration6,
Migration7,
Migration8,
Migration9,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
SQLiteDatabase.loadLibs(context)
if (preferences.biometricLock.value == BiometricLock.ENABLED) {
if (
SQLCipherUtils.getDatabaseState(getCurrentDatabaseFile(context)) ==
SQLCipherUtils.getDatabaseState(context, getCurrentDatabaseName(context)) ==
SQLCipherUtils.State.ENCRYPTED
) {
initializeDecryption(preferences, instanceBuilder)
@ -147,7 +127,7 @@ abstract class NotallyDatabase : RoomDatabase() {
}
} else {
if (
SQLCipherUtils.getDatabaseState(getCurrentDatabaseFile(context)) ==
SQLCipherUtils.getDatabaseState(context, getCurrentDatabaseName(context)) ==
SQLCipherUtils.State.ENCRYPTED
) {
preferences.biometricLock.save(BiometricLock.ENABLED)
@ -170,18 +150,18 @@ abstract class NotallyDatabase : RoomDatabase() {
instance.biometricLockObserver!!
)
instance.dataInPublicFolderObserver = Observer {
NotallyDatabase.instance?.value?.dataInPublicFolderObserver?.let {
instance.externalDataFolderObserver = Observer {
NotallyDatabase.instance?.value?.externalDataFolderObserver?.let {
preferences.dataInPublicFolder.removeObserver(it)
}
val newInstance = createInstance(context, preferences, true)
NotallyDatabase.instance?.postValue(newInstance)
preferences.dataInPublicFolder.observeForeverSkipFirst(
newInstance.dataInPublicFolderObserver!!
newInstance.externalDataFolderObserver!!
)
}
preferences.dataInPublicFolder.observeForeverSkipFirst(
instance.dataInPublicFolderObserver!!
instance.externalDataFolderObserver!!
)
}
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 com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Color
import com.philkes.notallyx.data.model.FileAttachment
import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.data.model.LabelsInBaseNote
@ -73,8 +74,6 @@ interface BaseNoteDao {
@Query("SELECT id, reminders FROM BaseNote WHERE reminders IS NOT NULL AND reminders != '[]'")
suspend fun getAllReminders(): List<NoteIdReminder>
@Query("SELECT color FROM BaseNote WHERE id = :id ") fun getColorOfNote(id: Long): String?
@Query(
"SELECT id, title, type, reminders FROM BaseNote WHERE reminders IS NOT NULL AND reminders != '[]'"
)
@ -95,15 +94,8 @@ interface BaseNoteDao {
@Query("UPDATE BaseNote SET folder = :folder WHERE id IN (:ids)")
suspend fun move(ids: LongArray, folder: Folder)
@Query("SELECT DISTINCT color FROM BaseNote") fun getAllColorsAsync(): LiveData<List<String>>
@Query("SELECT DISTINCT color FROM BaseNote") suspend fun getAllColors(): List<String>
@Query("UPDATE BaseNote SET color = :color WHERE id IN (:ids)")
suspend fun updateColor(ids: LongArray, color: String)
@Query("UPDATE BaseNote SET color = :newColor WHERE color = :oldColor")
suspend fun updateColor(oldColor: String, newColor: String)
suspend fun updateColor(ids: LongArray, color: Color)
@Query("UPDATE BaseNote SET pinned = :pinned WHERE id IN (:ids)")
suspend fun updatePinned(ids: LongArray, pinned: Boolean)
@ -163,19 +155,14 @@ interface BaseNoteDao {
* directly on the LiveData to filter the results accordingly.
*/
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) } }
}
@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>>
@Query(
"SELECT * FROM BaseNote WHERE folder = :folder AND labels == '[]' ORDER BY pinned DESC, timestamp DESC"
)
fun getBaseNotesWithoutLabel(folder: Folder): LiveData<List<BaseNote>>
fun getBaseNotesByLabel(label: String, folder: Folder): LiveData<List<BaseNote>>
suspend fun getListOfBaseNotesByLabel(label: String): List<BaseNote> {
val result = getListOfBaseNotesByLabelImpl(label)
@ -185,42 +172,16 @@ interface BaseNoteDao {
@Query("SELECT * FROM BaseNote WHERE labels LIKE '%' || :label || '%'")
suspend fun getListOfBaseNotesByLabelImpl(label: String): List<BaseNote>
fun getBaseNotesByKeyword(
keyword: String,
folder: Folder,
label: String?,
): LiveData<List<BaseNote>> {
val result =
when (label) {
null -> getBaseNotesByKeywordUnlabeledImpl(keyword, folder)
"" -> getBaseNotesByKeywordImpl(keyword, folder)
else -> getBaseNotesByKeywordImpl(keyword, folder, label)
}
fun getBaseNotesByKeyword(keyword: String, folder: Folder): LiveData<List<BaseNote>> {
val result = getBaseNotesByKeywordImpl(keyword, folder)
return result.map { list -> list.filter { baseNote -> matchesKeyword(baseNote, keyword) } }
}
@Query(
"SELECT * FROM BaseNote WHERE folder = :folder AND labels LIKE '%' || :label || '%' AND (title LIKE '%' || :keyword || '%' OR body LIKE '%' || :keyword || '%' OR items LIKE '%' || :keyword || '%') ORDER BY pinned DESC, timestamp DESC"
)
fun getBaseNotesByKeywordImpl(
keyword: String,
folder: Folder,
label: String,
): LiveData<List<BaseNote>>
@Query(
"SELECT * FROM BaseNote WHERE folder = :folder AND (title LIKE '%' || :keyword || '%' OR body LIKE '%' || :keyword || '%' OR items LIKE '%' || :keyword || '%' OR labels LIKE '%' || :keyword || '%') ORDER BY pinned DESC, timestamp DESC"
)
fun getBaseNotesByKeywordImpl(keyword: String, folder: Folder): LiveData<List<BaseNote>>
@Query(
"SELECT * FROM BaseNote WHERE folder = :folder AND labels == '[]' AND (title LIKE '%' || :keyword || '%' OR body LIKE '%' || :keyword || '%' OR items LIKE '%' || :keyword || '%') ORDER BY pinned DESC, timestamp DESC"
)
fun getBaseNotesByKeywordUnlabeledImpl(
keyword: String,
folder: Folder,
): LiveData<List<BaseNote>>
private fun matchesKeyword(baseNote: BaseNote, keyword: String): Boolean {
if (baseNote.title.contains(keyword, true)) {
return true

View file

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

View file

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

View file

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

View file

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

View file

@ -4,15 +4,12 @@ import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/** Format: `#RRGGBB` or `#AARRGGBB` or [BaseNote.COLOR_DEFAULT] */
typealias ColorString = String
@Entity(indices = [Index(value = ["id", "folder", "pinned", "timestamp", "labels"])])
data class BaseNote(
@PrimaryKey(autoGenerate = true) val id: Long,
val type: Type,
val folder: Folder,
val color: ColorString,
val color: Color,
val title: String,
val pinned: Boolean,
val timestamp: Long,
@ -25,60 +22,7 @@ data class BaseNote(
val files: List<FileAttachment>,
val audios: List<Audio>,
val reminders: List<Reminder>,
val viewMode: NoteViewMode,
) : Item {
companion object {
const val COLOR_DEFAULT = "DEFAULT"
const val COLOR_NEW = "NEW"
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as BaseNote
if (id != other.id) return false
if (type != other.type) return false
if (folder != other.folder) return false
if (color != other.color) return false
if (title != other.title) return false
if (pinned != other.pinned) return false
if (timestamp != other.timestamp) return false
if (labels != other.labels) return false
if (body != other.body) return false
if (spans != other.spans) return false
if (items != other.items) return false
if (images != other.images) return false
if (files != other.files) return false
if (audios != other.audios) return false
if (reminders != other.reminders) return false
if (viewMode != other.viewMode) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + type.hashCode()
result = 31 * result + folder.hashCode()
result = 31 * result + color.hashCode()
result = 31 * result + title.hashCode()
result = 31 * result + pinned.hashCode()
result = 31 * result + timestamp.hashCode()
result = 31 * result + labels.hashCode()
result = 31 * result + body.hashCode()
result = 31 * result + spans.hashCode()
result = 31 * result + items.hashCode()
result = 31 * result + images.hashCode()
result = 31 * result + files.hashCode()
result = 31 * result + audios.hashCode()
result = 31 * result + reminders.hashCode()
result = 31 * result + viewMode.hashCode()
return result
}
}
) : Item
fun BaseNote.deepCopy(): BaseNote {
return copy(

View file

@ -12,45 +12,5 @@ enum class Color {
DUSK,
FLOWER,
BLOSSOM,
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
}
CLAY,
}

View file

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

View file

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

View file

@ -35,7 +35,6 @@ data class ListItem(
return false
}
return (this.body == other.body &&
this.order == other.order &&
this.checked == other.checked &&
this.isChild == other.isChild)
}

View file

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

View file

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

View file

@ -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()
}
fun fetch(keyword: String, folder: Folder, label: String?) {
fun fetch(keyword: String, folder: Folder) {
job?.cancel()
liveData?.removeObserver(observer)
job =
scope.launch {
liveData = baseNoteDao.getBaseNotesByKeyword(keyword, folder, label)
// if (keyword.isNotEmpty())
// baseNoteDao.getBaseNotesByKeyword(keyword, folder, label)
// else baseNoteDao.getFrom(folder)
liveData =
if (keyword.isNotEmpty()) baseNoteDao.getBaseNotesByKeyword(keyword, folder)
else baseNoteDao.getFrom(folder)
liveData?.observeForever(observer)
}
}

View file

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

View file

@ -8,8 +8,6 @@ import android.content.DialogInterface
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.ColorStateList
import android.content.res.Resources
import android.graphics.PorterDuff
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.net.Uri
@ -23,7 +21,6 @@ import android.text.TextWatcher
import android.text.style.CharacterStyle
import android.text.style.StrikethroughSpan
import android.text.style.StyleSpan
import android.text.style.SuggestionSpan
import android.text.style.TypefaceSpan
import android.text.style.URLSpan
import android.util.TypedValue
@ -39,7 +36,6 @@ import android.view.WindowManager
import android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.Button
import android.widget.CompoundButton
import android.widget.EditText
import android.widget.ImageButton
@ -53,29 +49,22 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.text.getSpans
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.marginBottom
import androidx.core.view.marginTop
import androidx.core.view.setPadding
import androidx.core.view.updatePadding
import androidx.core.widget.TextViewCompat
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton
import com.google.android.material.card.MaterialCardView
import com.google.android.material.checkbox.MaterialCheckBox
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
@ -84,23 +73,21 @@ import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.RelativeCornerSize
import com.google.android.material.shape.RoundedCornerTreatment
import com.google.android.material.shape.ShapeAppearanceModel
import com.google.android.material.textfield.MaterialAutoCompleteTextView
import com.philkes.notallyx.R
import com.philkes.notallyx.data.imports.ImportProgress
import com.philkes.notallyx.data.imports.ImportStage
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Color
import com.philkes.notallyx.data.model.Folder
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.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.Progress
import com.philkes.notallyx.presentation.view.misc.StylableEditTextWithHistory
import com.philkes.notallyx.presentation.view.note.listitem.ListManager
import com.philkes.notallyx.presentation.view.note.listitem.adapter.ListItemVH
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
import com.philkes.notallyx.presentation.viewmodel.preference.DateFormat
import com.philkes.notallyx.presentation.viewmodel.preference.TextSize
import com.philkes.notallyx.utils.changehistory.ChangeHistory
@ -122,7 +109,7 @@ fun String.applySpans(representations: List<SpanRepresentation>): Editable {
->
try {
if (bold) {
editable.setSpan(createBoldSpan(), start, end)
editable.setSpan(StyleSpan(Typeface.BOLD), start, end)
}
if (italic) {
editable.setSpan(StyleSpan(Typeface.ITALIC), start, end)
@ -144,13 +131,6 @@ fun String.applySpans(representations: List<SpanRepresentation>): Editable {
return editable
}
fun createBoldSpan() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
StyleSpan(Typeface.BOLD, 700)
} else {
StyleSpan(Typeface.BOLD)
}
/**
* Adjusts or removes spans based on the selection range.
*
@ -241,23 +221,14 @@ fun ViewGroup.addIconButton(
title: Int,
drawable: Int,
marginStart: Int = 10,
onLongClick: View.OnLongClickListener? = null,
onClick: View.OnClickListener? = null,
): ImageButton {
onClick: ((item: View) -> Unit)? = null,
): View {
val view =
ImageButton(ContextThemeWrapper(context, R.style.AppTheme)).apply {
setImageResource(drawable)
val titleText = context.getString(title)
contentDescription = titleText
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
tooltipText = titleText
}
val outValue = TypedValue()
context.theme.resolveAttribute(android.R.attr.actionBarItemBackground, outValue, true)
setBackgroundResource(outValue.resourceId)
setOnLongClickListener(onLongClick)
contentDescription = context.getString(title)
setBackgroundResource(R.color.Transparent)
setOnClickListener(onClick)
scaleType = ImageView.ScaleType.FIT_CENTER
adjustViewBounds = true
layoutParams =
@ -265,8 +236,8 @@ fun ViewGroup.addIconButton(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.MATCH_PARENT,
)
.apply { setMargins(marginStart.dp, marginTop, 0, marginBottom) }
setPadding(8.dp)
.apply { setMargins(marginStart.dp(context), marginTop, 0, marginBottom) }
setPadding(8.dp(context))
}
addView(view)
return view
@ -284,11 +255,13 @@ fun TextView.displayFormattedTimestamp(
} else visibility = View.GONE
}
val Int.dp: Int
get() = (this * Resources.getSystem().displayMetrics.density + 0.5f).toInt()
val Float.dp: Int
get() = (this * Resources.getSystem().displayMetrics.density + 0.5f).toInt()
fun Int.dp(context: Context): Int =
TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
this.toFloat(),
context.resources.displayMetrics,
)
.toInt()
/**
* 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,
) =
object : TextWatcher {
private lateinit var stateBefore: EditTextState
private var ignoreOriginalChange: Boolean = false
private lateinit var textBefore: Editable
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
textBefore = text.clone()
stateBefore = EditTextState(getText()!!.clone(), selectionStart)
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
@ -317,14 +290,13 @@ fun EditText.createListTextWatcherWithHistory(
}
override fun afterTextChanged(s: Editable?) {
val textAfter = s!!.clone()
if (textAfter.hasNotChanged(textBefore)) {
return
}
if (!ignoreOriginalChange) {
listManager.changeText(
this@createListTextWatcherWithHistory,
this,
positionGetter.invoke(),
EditTextState(textAfter, selectionStart),
EditTextState(getText()!!.clone(), selectionStart),
before = stateBefore,
)
}
}
@ -348,9 +320,6 @@ fun StylableEditTextWithHistory.createTextWatcherWithHistory(
override fun afterTextChanged(s: Editable?) {
val textAfter = requireNotNull(s).clone()
if (textAfter.hasNotChanged(stateBefore.text)) {
return
}
updateModel.invoke(textAfter)
changeHistory.push(
EditTextWithHistoryChange(
@ -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 View.getString(id: Int, vararg formatArgs: String): String {
@ -389,16 +354,11 @@ fun RadioGroup.checkedTag(): Any {
return this.findViewById<RadioButton?>(this.checkedRadioButtonId).tag
}
fun Context.showKeyboard(view: View) {
fun Activity.showKeyboard(view: View) {
ContextCompat.getSystemService(this, InputMethodManager::class.java)
?.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT)
}
fun Context.hideKeyboard(view: View) {
ContextCompat.getSystemService(this, InputMethodManager::class.java)
?.hideSoftInputFromWindow(view.windowToken, 0)
}
fun MutableLiveData<out Progress>.setupProgressDialog(activity: Activity, titleId: Int) {
setupProgressDialog(activity, activity.layoutInflater, activity as LifecycleOwner, titleId)
}
@ -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(
context: Context,
layoutInflater: LayoutInflater,
@ -538,64 +491,6 @@ fun Activity.checkAlarmPermission(
} else onSuccess()
}
fun Activity.setEnabledSecureFlag(enabled: Boolean) {
if (enabled) {
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE,
)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
fun Fragment.displayEditLabelDialog(
oldValue: String,
model: BaseNoteModel,
onUpdateLabel: ((oldLabel: String, newLabel: String) -> Unit)? = null,
) {
requireContext().displayEditLabelDialog(oldValue, model, layoutInflater, onUpdateLabel)
}
fun Activity.displayEditLabelDialog(
oldValue: String,
model: BaseNoteModel,
onUpdateLabel: ((oldLabel: String, newLabel: String) -> Unit)? = null,
) {
displayEditLabelDialog(oldValue, model, layoutInflater, onUpdateLabel)
}
fun Context.displayEditLabelDialog(
oldValue: String,
model: BaseNoteModel,
layoutInflater: LayoutInflater,
onUpdateLabel: ((oldLabel: String, newLabel: String) -> Unit)? = null,
) {
val dialogBinding = DialogInputBinding.inflate(layoutInflater)
dialogBinding.EditText.setText(oldValue)
MaterialAlertDialogBuilder(this)
.setView(dialogBinding.root)
.setTitle(R.string.edit_label)
.setCancelButton()
.setPositiveButton(R.string.save) { dialog, _ ->
val value = dialogBinding.EditText.text.toString().trim()
if (value.isNotEmpty()) {
model.updateLabel(oldValue, value) { success ->
if (success) {
onUpdateLabel?.invoke(oldValue, value)
dialog.dismiss()
} else showToast(R.string.label_exists)
}
}
}
.showAndFocus(dialogBinding.EditText, allowFullSize = true) { positiveButton ->
dialogBinding.EditText.doAfterTextChanged { text ->
positiveButton.isEnabled = !text.isNullOrEmpty()
}
positiveButton.isEnabled = oldValue.isNotEmpty()
}
}
private fun formatTimestamp(timestamp: Long, dateFormat: DateFormat): String {
val date = Date(timestamp)
return when (dateFormat) {
@ -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(
viewToFocus: View? = null,
selectAll: Boolean = false,
allowFullSize: Boolean = false,
onShowListener: DialogInterface.OnShowListener? = null,
applyToPositiveButton: ((positiveButton: Button) -> Unit)? = null,
): AlertDialog {
if (allowFullSize) {
setBackgroundInsetEnd(0)
@ -631,11 +550,7 @@ fun MaterialAlertDialogBuilder.showAndFocus(
WindowManager.LayoutParams.WRAP_CONTENT,
)
}
onShowListener?.let { setOnShowListener(it) }
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 {
val typedValue = TypedValue()
val resolved = theme.resolveAttribute(attr, typedValue, true)
if (!resolved) {
throw IllegalArgumentException("Attribute not found in current theme")
}
return if (typedValue.resourceId != 0) {
// It's a reference (@color/something), resolve it properly
ContextCompat.getColor(this, typedValue.resourceId)
if (resolved) {
return typedValue.data // Returns the color as an Int
} else {
// It's a direct color value
typedValue.data
throw IllegalArgumentException("Attribute not found in current theme")
}
}
@ -692,15 +602,6 @@ fun View.setControlsColorForAllViews(
overwriteBackground,
) // Recursive call for nested layouts
}
if (this is MaterialCardView) {
checkedIconTint = ColorStateList.valueOf(controlsColor)
val colorStateList =
ColorStateList(
arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()),
intArrayOf(controlsColor, controlsColor.withAlpha(0.3f)),
)
setStrokeColor(colorStateList)
}
} else {
val controlsStateList =
ColorStateList(
@ -726,7 +627,15 @@ fun View.setControlsColorForAllViews(
val highlight = controlsColor.withAlpha(0.4f)
setHintTextColor(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) {
@ -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) {
compoundDrawablesRelative.forEach { drawable ->
drawable?.let { DrawableCompat.setTint(DrawableCompat.wrap(it), color) }
@ -830,12 +675,21 @@ fun Drawable.withTint(@ColorInt color: Int): Drawable {
}
@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)
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) =
setNegativeButton(R.string.cancel, listener)
@ -872,38 +726,34 @@ fun Context.showToast(message: CharSequence) =
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
}
fun Context.restartApplication(
fragmentIdToOpen: Int? = null,
extra: Pair<String, Boolean>? = null,
) {
val intent = packageManager.getLaunchIntentForPackage(packageName)
val componentName = intent!!.component
val mainIntent =
Intent.makeRestartActivityTask(componentName).apply {
fragmentIdToOpen?.let { putExtra(MainActivity.EXTRA_FRAGMENT_TO_OPEN, it) }
extra?.let { (key, value) -> putExtra(key, value) }
}
mainIntent.setPackage(packageName)
startActivity(mainIntent)
Runtime.getRuntime().exit(0)
}
@ColorInt
fun Context.extractColor(color: String): Int {
return when (color) {
BaseNote.COLOR_DEFAULT -> return getColorFromAttr(R.attr.colorSurface)
else -> android.graphics.Color.parseColor(color)
}
}
fun ViewGroup.addFastScroll(context: Context) {
FastScrollerBuilder(this)
.useMd2Style()
.setTrackDrawable(ContextCompat.getDrawable(context, R.drawable.scroll_track)!!)
.setPadding(0, 0, 2.dp, 0)
.setPadding(0, 0, 2.dp(context), 0)
.build()
}
@ColorInt
fun Context.extractColor(color: Color): Int {
val id =
when (color) {
Color.DEFAULT -> return getColorFromAttr(R.attr.colorSurface)
Color.CORAL -> R.color.Coral
Color.ORANGE -> R.color.Orange
Color.SAND -> R.color.Sand
Color.STORM -> R.color.Storm
Color.FOG -> R.color.Fog
Color.SAGE -> R.color.Sage
Color.MINT -> R.color.Mint
Color.DUSK -> R.color.Dusk
Color.FLOWER -> R.color.Flower
Color.BLOSSOM -> R.color.Blossom
Color.CLAY -> R.color.Clay
}
return ContextCompat.getColor(this, id)
}
fun Window.setLightStatusAndNavBar(value: Boolean, view: View = decorView) {
val windowInsetsControllerCompat = WindowInsetsControllerCompat(this, view)
windowInsetsControllerCompat.isAppearanceLightStatusBars = value
@ -915,8 +765,6 @@ fun ChipGroup.bindLabels(
textSize: TextSize,
paddingTop: Boolean,
color: Int? = null,
onClick: ((label: String) -> Unit)? = null,
onLongClick: ((label: String) -> Unit)? = null,
) {
if (labels.isEmpty()) {
visibility = View.GONE
@ -924,7 +772,7 @@ fun ChipGroup.bindLabels(
apply {
visibility = View.VISIBLE
removeAllViews()
updatePadding(top = if (paddingTop) 8.dp else 0)
updatePadding(top = if (paddingTop) 8.dp(context) else 0)
}
val inflater = LayoutInflater.from(context)
val labelSize = textSize.displayBodySize
@ -934,13 +782,6 @@ fun ChipGroup.bindLabels(
setTextSize(TypedValue.COMPLEX_UNIT_SP, labelSize)
text = label
color?.let { setControlsContrastColorForAllViews(it) }
onClick?.let { setOnClickListener { it(label) } }
onLongClick?.let {
setOnLongClickListener {
it(label)
true
}
}
}
}
}
@ -966,43 +807,3 @@ fun RecyclerView.initListView(context: Context) {
addItemDecoration(DividerItemDecoration(context, RecyclerView.VERTICAL))
setPadding(0, 0, 0, 0)
}
val RecyclerView.focusedViewHolder
get() =
focusedChild?.let { view ->
val position = getChildAdapterPosition(view)
if (position == RecyclerView.NO_POSITION) {
null
} else {
findViewHolderForAdapterPosition(position)
}
}
fun RecyclerView.showKeyboardOnFocusedItem() {
(focusedViewHolder as? ListItemVH)?.let {
it.binding.root.context?.showKeyboard(it.binding.EditText)
}
}
fun RecyclerView.hideKeyboardOnFocusedItem() {
(focusedViewHolder as? ListItemVH)?.let {
it.binding.root.context?.hideKeyboard(it.binding.EditText)
}
}
fun MaterialAutoCompleteTextView.select(value: CharSequence) {
setText(value, false)
}
fun Context.createTextView(textResId: Int, padding: Int = 16.dp): TextView {
return AppCompatTextView(this).apply {
setText(textResId)
TextViewCompat.setTextAppearance(
this,
android.R.style.TextAppearance_Material_DialogWindowTitle,
)
updatePadding(padding, padding, padding, padding)
maxLines = Integer.MAX_VALUE
ellipsize = null
}
}

View file

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

View file

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

View file

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

View file

@ -9,11 +9,9 @@ import android.view.ViewGroup
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.LiveData
import androidx.navigation.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
@ -25,8 +23,6 @@ import com.philkes.notallyx.data.model.Item
import com.philkes.notallyx.data.model.Type
import com.philkes.notallyx.databinding.FragmentNotesBinding
import com.philkes.notallyx.presentation.activity.main.MainActivity
import com.philkes.notallyx.presentation.activity.main.fragment.SearchFragment.Companion.EXTRA_INITIAL_FOLDER
import com.philkes.notallyx.presentation.activity.main.fragment.SearchFragment.Companion.EXTRA_INITIAL_LABEL
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_FOLDER_FROM
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_FOLDER_TO
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_NOTE_ID
@ -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.EditNoteActivity
import com.philkes.notallyx.presentation.getQuantityString
import com.philkes.notallyx.presentation.hideKeyboard
import com.philkes.notallyx.presentation.movedToResId
import com.philkes.notallyx.presentation.showKeyboard
import com.philkes.notallyx.presentation.view.main.BaseNoteAdapter
import com.philkes.notallyx.presentation.view.main.BaseNoteVHPreferences
import com.philkes.notallyx.presentation.view.misc.ItemListener
@ -61,7 +55,7 @@ abstract class NotallyFragment : Fragment(), ItemListener {
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
val layoutManager = binding?.MainListView?.layoutManager as? LinearLayoutManager
val layoutManager = binding?.RecyclerView?.layoutManager as? LinearLayoutManager
if (layoutManager != null) {
val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()
if (firstVisiblePosition != RecyclerView.NO_POSITION) {
@ -79,7 +73,6 @@ abstract class NotallyFragment : Fragment(), ItemListener {
setupAdapter()
setupRecyclerView()
setupObserver()
setupSearch()
setupActivityResultLaunchers()
@ -87,8 +80,8 @@ abstract class NotallyFragment : Fragment(), ItemListener {
val scrollPosition = bundle.getInt(EXTRA_SCROLL_POS, -1)
val scrollOffset = bundle.getInt(EXTRA_SCROLL_OFFSET, 0)
if (scrollPosition > -1) {
binding?.MainListView?.post {
val layoutManager = binding?.MainListView?.layoutManager as? LinearLayoutManager
binding?.RecyclerView?.post {
val layoutManager = binding?.RecyclerView?.layoutManager as? LinearLayoutManager
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) {
if (model.actionMode.selectedNotes.contains(id)) {
model.actionMode.remove(id)
@ -236,8 +192,7 @@ abstract class NotallyFragment : Fragment(), ItemListener {
maxItems.value,
maxLines.value,
maxTitle.value,
labelTagsHiddenInOverview.value,
imagesHiddenInOverview.value,
labelsHiddenInOverview.value,
),
model.imageRoot,
this@NotallyFragment,
@ -248,12 +203,12 @@ abstract class NotallyFragment : Fragment(), ItemListener {
object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (itemCount > 0) {
binding?.MainListView?.scrollToPosition(positionStart)
binding?.RecyclerView?.scrollToPosition(positionStart)
}
}
}
)
binding?.MainListView?.apply {
binding?.RecyclerView?.apply {
adapter = notesAdapter
setHasFixedSize(false)
}
@ -287,7 +242,7 @@ abstract class NotallyFragment : Fragment(), ItemListener {
}
private fun setupRecyclerView() {
binding?.MainListView?.layoutManager =
binding?.RecyclerView?.layoutManager =
if (model.preferences.notesView.value == NotesView.GRID) {
StaggeredGridLayoutManager(2, RecyclerView.VERTICAL)
} else LinearLayoutManager(requireContext())

View file

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

View file

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

View file

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

View file

@ -31,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.databinding.DialogTextInputBinding
import com.philkes.notallyx.databinding.FragmentSettingsBinding
import com.philkes.notallyx.presentation.activity.main.MainActivity
import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.setEnabledSecureFlag
import com.philkes.notallyx.presentation.setupImportProgressDialog
import com.philkes.notallyx.presentation.setupProgressDialog
import com.philkes.notallyx.presentation.showAndFocus
@ -41,23 +39,24 @@ import com.philkes.notallyx.presentation.showDialog
import com.philkes.notallyx.presentation.showToast
import com.philkes.notallyx.presentation.view.misc.TextWithIconAdapter
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock
import com.philkes.notallyx.presentation.viewmodel.preference.Constants.PASSWORD_EMPTY
import com.philkes.notallyx.presentation.viewmodel.preference.LongPreference
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.EMPTY_PATH
import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackup
import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackup.Companion.BACKUP_MAX_MIN
import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackup.Companion.BACKUP_PERIOD_DAYS_MIN
import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackupsPreference
import com.philkes.notallyx.utils.MIME_TYPE_JSON
import com.philkes.notallyx.utils.MIME_TYPE_ZIP
import com.philkes.notallyx.utils.backup.exportPreferences
import com.philkes.notallyx.utils.catchNoBrowserInstalled
import com.philkes.notallyx.utils.getExtraBooleanFromBundleOrIntent
import com.philkes.notallyx.utils.getLastExceptionLog
import com.philkes.notallyx.utils.getLogFile
import com.philkes.notallyx.utils.getUriForFile
import com.philkes.notallyx.utils.reportBug
import com.philkes.notallyx.utils.security.disableBiometricLock
import com.philkes.notallyx.utils.security.encryptDatabase
import com.philkes.notallyx.utils.security.showBiometricOrPinPrompt
import com.philkes.notallyx.utils.wrapWithChooser
import java.util.Date
@ -98,27 +97,6 @@ class SettingsFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupActivityResultLaunchers()
val showImportBackupsFolder =
getExtraBooleanFromBundleOrIntent(
savedInstanceState,
EXTRA_SHOW_IMPORT_BACKUPS_FOLDER,
false,
)
showImportBackupsFolder.let {
if (it) {
model.refreshBackupsFolder(
requireContext(),
askForUriPermissions = ::askForUriPermissions,
)
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
if (model.showRefreshBackupsFolderAfterThemeChange) {
outState.putBoolean(EXTRA_SHOW_IMPORT_BACKUPS_FOLDER, true)
}
}
private fun setupActivityResultLaunchers() {
@ -193,12 +171,11 @@ class SettingsFragment : Fragment() {
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri ->
model.importPreferences(
requireContext(),
uri,
::askForUriPermissions,
{ showToast(R.string.import_settings_success) },
if (
model.importPreferences(requireContext(), uri, ::askForUriPermissions)
) {
showToast(R.string.import_settings_success)
} else {
showToast(R.string.import_settings_failure)
}
}
@ -247,27 +224,9 @@ class SettingsFragment : Fragment() {
}
}
theme.merge(useDynamicColors).observe(viewLifecycleOwner) {
(themeValue, useDynamicColorsValue) ->
binding.Theme.setup(
theme,
themeValue,
useDynamicColorsValue,
requireContext(),
layoutInflater,
) { newThemeValue, newUseDynamicColorsValue ->
model.savePreference(theme, newThemeValue)
model.savePreference(useDynamicColors, newUseDynamicColorsValue)
val packageManager = requireContext().packageManager
val intent = packageManager.getLaunchIntentForPackage(requireContext().packageName)
val componentName = intent!!.component
val mainIntent =
Intent.makeRestartActivityTask(componentName).apply {
putExtra(MainActivity.EXTRA_FRAGMENT_TO_OPEN, R.id.Settings)
}
mainIntent.setPackage(requireContext().packageName)
requireContext().startActivity(mainIntent)
Runtime.getRuntime().exit(0)
theme.observe(viewLifecycleOwner) { value ->
binding.Theme.setup(theme, value, requireContext()) { newValue ->
model.savePreference(theme, newValue)
}
}
@ -300,29 +259,14 @@ class SettingsFragment : Fragment() {
model,
)
}
listItemSorting.observe(viewLifecycleOwner) { value ->
binding.CheckedListItemSorting.setup(listItemSorting, value, requireContext()) {
newValue ->
model.savePreference(listItemSorting, newValue)
}
}
// TODO: Hide for now until checked auto-sort is working reliably
// listItemSorting.observe(viewLifecycleOwner) { value ->
// binding.CheckedListItemSorting.setup(ListItemSorting, value)
// }
binding.MaxLabels.setup(maxLabels, requireContext()) { newValue ->
model.savePreference(maxLabels, newValue)
}
startView.merge(model.labels).observe(viewLifecycleOwner) { (startViewValue, labelsValue) ->
binding.StartView.setupStartView(
startView,
startViewValue,
labelsValue,
requireContext(),
layoutInflater,
) { newValue ->
model.savePreference(startView, newValue)
}
}
}
private fun NotallyXPreferences.setupContentDensity(binding: FragmentSettingsBinding) {
@ -337,29 +281,15 @@ class SettingsFragment : Fragment() {
MaxLines.setup(maxLines, requireContext()) { newValue ->
model.savePreference(maxLines, newValue)
}
MaxLabels.setup(maxLabels, requireContext()) { newValue ->
model.savePreference(maxLabels, newValue)
}
labelTagsHiddenInOverview.observe(viewLifecycleOwner) { value ->
labelsHiddenInOverview.observe(viewLifecycleOwner) { value ->
binding.LabelsHiddenInOverview.setup(
labelTagsHiddenInOverview,
labelsHiddenInOverview,
value,
requireContext(),
layoutInflater,
R.string.labels_hidden_in_overview,
) { enabled ->
model.savePreference(labelTagsHiddenInOverview, enabled)
}
}
imagesHiddenInOverview.observe(viewLifecycleOwner) { value ->
binding.ImagesHiddenInOverview.setup(
imagesHiddenInOverview,
value,
requireContext(),
layoutInflater,
R.string.images_hidden_in_overview,
) { enabled ->
model.savePreference(imagesHiddenInOverview, enabled)
model.savePreference(labelsHiddenInOverview, enabled)
}
}
}
@ -452,7 +382,7 @@ class SettingsFragment : Fragment() {
when (selectedImportSource.mimeType) {
FOLDER_OR_FILE_MIMETYPE ->
MaterialAlertDialogBuilder(requireContext())
.setTitle(selectedImportSource.displayNameResId)
.setTitle(R.string.plain_text_files)
.setItems(
arrayOf(
getString(R.string.folder),
@ -534,17 +464,9 @@ class SettingsFragment : Fragment() {
enabled = backupFolder != EMPTY_PATH,
) { enabled ->
if (enabled) {
val periodInDays =
preference.value.periodInDays.let {
if (it >= BACKUP_PERIOD_DAYS_MIN) it else BACKUP_PERIOD_DAYS_MIN
}
val maxBackups =
preference.value.maxBackups.let {
if (it >= BACKUP_MAX_MIN) it else BACKUP_MAX_MIN
}
model.savePreference(
preference,
preference.value.copy(periodInDays = periodInDays, maxBackups = maxBackups),
preference.value.copy(periodInDays = BACKUP_PERIOD_DAYS_MIN),
)
} else {
model.savePreference(preference, preference.value.copy(periodInDays = 0))
@ -604,14 +526,6 @@ class SettingsFragment : Fragment() {
model.savePreference(backupPassword, newValue)
}
}
secureFlag.observe(viewLifecycleOwner) { value ->
binding.SecureFlag.setup(secureFlag, value, requireContext(), layoutInflater) { newValue
->
model.savePreference(secureFlag, newValue)
activity?.setEnabledSecureFlag(newValue)
}
}
}
private fun NotallyXPreferences.setupSettings(binding: FragmentSettingsBinding) {
@ -644,7 +558,8 @@ class SettingsFragment : Fragment() {
}
ResetSettings.setOnClickListener {
showDialog(R.string.reset_settings_message, R.string.reset_settings) { _, _ ->
model.resetPreferences { _ -> showToast(R.string.reset_settings_success) }
model.resetPreferences()
showToast(R.string.reset_settings_success)
}
}
dataInPublicFolder.observe(viewLifecycleOwner) { value ->
@ -662,11 +577,6 @@ class SettingsFragment : Fragment() {
}
}
}
AutoSaveAfterIdle.setupAutoSaveIdleTime(autoSaveAfterIdleTime, requireContext()) {
newValue ->
model.savePreference(autoSaveAfterIdleTime, newValue)
}
ClearData.setOnClickListener {
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.clear_data_message)
@ -681,47 +591,13 @@ class SettingsFragment : Fragment() {
private fun setupAbout(binding: FragmentSettingsBinding) {
binding.apply {
SendFeedback.setOnClickListener {
val options =
arrayOf(
getString(R.string.report_bug),
getString(R.string.make_feature_request),
getString(R.string.send_feedback),
)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.send_feedback)
.setItems(options) { _, which ->
when (which) {
0 -> {
val app = requireContext().applicationContext as Application
val logs = app.getLastExceptionLog()
reportBug(logs)
}
1 ->
requireContext().catchNoBrowserInstalled {
startActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse(
"https://github.com/PhilKes/NotallyX/issues/new?labels=enhancement&template=feature_request.md"
),
)
.wrapWithChooser(requireContext())
)
}
2 -> {
val intent =
Intent(Intent.ACTION_SEND)
.apply {
selector =
Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:"))
putExtra(
Intent.EXTRA_EMAIL,
arrayOf("notallyx@yahoo.com"),
)
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 app = requireContext().applicationContext as Application
val log = app.getLogFile()
if (log.exists()) {
val uri = app.getUriForFile(log)
@ -735,14 +611,39 @@ class SettingsFragment : Fragment() {
showToast(R.string.install_an_email)
}
}
CreateIssue.setOnClickListener {
val options =
arrayOf(
getString(R.string.report_bug),
getString(R.string.make_feature_request),
)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.create_github_issue)
.setItems(options) { _, which ->
when (which) {
0 -> {
val app = requireContext().applicationContext as Application
val logs = app.getLastExceptionLog()
reportBug(logs)
}
else ->
requireContext().catchNoBrowserInstalled {
startActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse(
"https://github.com/PhilKes/NotallyX/issues/new?labels=enhancement&template=feature_request.md"
),
)
.wrapWithChooser(requireContext())
)
}
}
}
.setCancelButton()
.show()
}
Rate.setOnClickListener {
openLink("https://play.google.com/store/apps/details?id=com.philkes.notallyx")
}
SourceCode.setOnClickListener { openLink("https://github.com/PhilKes/NotallyX") }
Libraries.setOnClickListener {
val libraries =
@ -756,7 +657,6 @@ class SettingsFragment : Fragment() {
"SQLCipher",
"Zip4J",
"AndroidFastScroll",
"ColorPickerView",
)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.libraries)
@ -780,13 +680,11 @@ class SettingsFragment : Fragment() {
6 -> openLink("https://github.com/sqlcipher/sqlcipher")
7 -> openLink("https://github.com/srikanth-lingala/zip4j")
8 -> openLink("https://github.com/zhanghai/AndroidFastScroll")
9 -> openLink("https://github.com/skydoves/ColorPickerView")
}
}
.setCancelButton()
.show()
}
Donate.setOnClickListener { openLink("https://ko-fi.com/philkes") }
try {
val pInfo =
@ -812,7 +710,14 @@ class SettingsFragment : Fragment() {
R.string.enable_lock_description,
onSuccess = { cipher ->
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)
app.locked.value = false
@ -832,7 +737,7 @@ class SettingsFragment : Fragment() {
model.preferences.iv.value!!,
onSuccess = { cipher ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
model.disableBiometricLock(cipher)
requireContext().disableBiometricLock(model, cipher)
}
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.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.text.Editable
import android.util.Log
import android.util.TypedValue
import android.view.MenuItem
import android.view.View
import android.view.View.GONE
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import android.view.ViewGroup.VISIBLE
import android.view.inputmethod.InputMethodManager
import android.widget.ImageButton
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
@ -37,18 +32,13 @@ import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.philkes.notallyx.R
import com.philkes.notallyx.data.NotallyDatabase
import com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.data.model.FileAttachment
import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.data.model.Type
import com.philkes.notallyx.data.model.isImageMimeType
import com.philkes.notallyx.data.model.toText
import com.philkes.notallyx.databinding.ActivityEditBinding
import com.philkes.notallyx.presentation.activity.LockedActivity
import com.philkes.notallyx.presentation.activity.main.MainActivity
import com.philkes.notallyx.presentation.activity.main.MainActivity.Companion.EXTRA_FRAGMENT_TO_OPEN
import com.philkes.notallyx.presentation.activity.main.MainActivity.Companion.EXTRA_SKIP_START_VIEW_ON_BACK
import com.philkes.notallyx.presentation.activity.main.fragment.DisplayLabelFragment.Companion.EXTRA_DISPLAYED_LABEL
import com.philkes.notallyx.presentation.activity.note.SelectLabelsActivity.Companion.EXTRA_SELECTED_LABELS
import com.philkes.notallyx.presentation.activity.note.reminders.RemindersActivity
@ -56,16 +46,15 @@ import com.philkes.notallyx.presentation.add
import com.philkes.notallyx.presentation.addFastScroll
import com.philkes.notallyx.presentation.addIconButton
import com.philkes.notallyx.presentation.bindLabels
import com.philkes.notallyx.presentation.displayEditLabelDialog
import com.philkes.notallyx.presentation.displayFormattedTimestamp
import com.philkes.notallyx.presentation.extractColor
import com.philkes.notallyx.presentation.getQuantityString
import com.philkes.notallyx.presentation.hideKeyboard
import com.philkes.notallyx.presentation.isLightColor
import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews
import com.philkes.notallyx.presentation.setLightStatusAndNavBar
import com.philkes.notallyx.presentation.setupProgressDialog
import com.philkes.notallyx.presentation.showColorSelectDialog
import com.philkes.notallyx.presentation.showKeyboard
import com.philkes.notallyx.presentation.showToast
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
@ -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.preview.PreviewFileAdapter
import com.philkes.notallyx.presentation.view.note.preview.PreviewImageAdapter
import com.philkes.notallyx.presentation.viewmodel.ExportMimeType
import com.philkes.notallyx.presentation.viewmodel.NotallyModel
import com.philkes.notallyx.presentation.viewmodel.preference.DateFormat
import com.philkes.notallyx.presentation.viewmodel.preference.ListItemSort
import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortBy
import com.philkes.notallyx.presentation.widget.WidgetProvider
import com.philkes.notallyx.utils.FileError
import com.philkes.notallyx.utils.backup.exportNotes
import com.philkes.notallyx.utils.changehistory.ChangeHistory
import com.philkes.notallyx.utils.getMimeType
import com.philkes.notallyx.utils.getUriForFile
import com.philkes.notallyx.utils.log
import com.philkes.notallyx.utils.mergeSkipFirst
import com.philkes.notallyx.utils.observeSkipFirst
import com.philkes.notallyx.utils.shareNote
import com.philkes.notallyx.utils.showColorSelectDialog
import com.philkes.notallyx.utils.wrapWithChooser
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
abstract class EditActivity(private val type: Type) :
LockedActivity<ActivityEditBinding>(), AddActions, MoreActions {
@ -111,8 +93,6 @@ abstract class EditActivity(private val type: Type) :
private lateinit var selectLabelsActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var playAudioActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var attachFilesActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var exportNotesActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var exportFileActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var pinMenuItem: MenuItem
protected var search = Search()
@ -126,38 +106,17 @@ abstract class EditActivity(private val type: Type) :
protected var colorInt: Int = -1
protected var inputMethodManager: InputMethodManager? = null
protected lateinit var toggleViewMode: ImageButton
protected val canEdit
get() = notallyModel.viewMode.value == NoteViewMode.EDIT
private val autoSaveHandler = Handler(Looper.getMainLooper())
private val autoSaveRunnable = Runnable {
lifecycleScope.launch(Dispatchers.Main) {
updateModel()
if (notallyModel.isModified()) {
Log.d(TAG, "Auto-saving note...")
saveNote(checkAutoSave = false)
}
}
}
override fun finish() {
lifecycleScope.launch(Dispatchers.Main) {
if (notallyModel.isEmpty()) {
notallyModel.deleteBaseNote(checkAutoSave = false)
} else if (notallyModel.isModified()) {
saveNote()
} else {
notallyModel.checkBackupOnSave()
}
super.finish()
}
}
protected open fun updateModel() {
notallyModel.modifiedTimestamp = System.currentTimeMillis()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putLong("id", notallyModel.id)
@ -166,9 +125,9 @@ abstract class EditActivity(private val type: Type) :
}
}
open suspend fun saveNote(checkAutoSave: Boolean = true) {
updateModel()
notallyModel.saveNote(checkAutoSave)
open suspend fun saveNote() {
notallyModel.modifiedTimestamp = System.currentTimeMillis()
notallyModel.saveNote()
WidgetProvider.sendBroadcast(application, longArrayOf(notallyModel.id))
}
@ -185,20 +144,16 @@ abstract class EditActivity(private val type: Type) :
val persistedId = savedInstanceState?.getLong("id")
val selectedId = intent.getLongExtra(EXTRA_SELECTED_BASE_NOTE, 0L)
val id = persistedId ?: selectedId
if (persistedId == null || notallyModel.originalNote == null) {
if (persistedId == null) {
notallyModel.setState(id)
}
if (notallyModel.isNewNote) {
when (intent.action) {
Intent.ACTION_SEND,
Intent.ACTION_SEND_MULTIPLE -> handleSharedNote()
Intent.ACTION_VIEW -> handleViewNote()
else ->
if (notallyModel.isNewNote && intent.action == Intent.ACTION_SEND) {
handleSharedNote()
} else if (notallyModel.isNewNote) {
intent.getStringExtra(EXTRA_DISPLAYED_LABEL)?.let {
notallyModel.setLabels(listOf(it))
}
}
}
setupToolbars()
setupListeners()
@ -212,43 +167,6 @@ abstract class EditActivity(private val type: Type) :
}
setupActivityResultLaunchers()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
updateModel()
runBlocking(Dispatchers.IO) { saveNote() }
} catch (e: Exception) {
log(TAG, msg = "Saving note on Crash failed", throwable = e)
} finally {
// Let the system handle the crash
DEFAULT_EXCEPTION_HANDLER?.uncaughtException(thread, throwable)
}
}
}
open fun toggleCanEdit(mode: NoteViewMode) {
binding.EnterTitle.apply {
if (isFocused) {
when {
mode == NoteViewMode.EDIT -> showKeyboard(this)
else -> hideKeyboard(this)
}
}
setCanEdit(mode == NoteViewMode.EDIT)
}
}
override fun onDestroy() {
autoSaveHandler.removeCallbacks(autoSaveRunnable)
super.onDestroy()
}
protected fun resetIdleTimer() {
autoSaveHandler.removeCallbacks(autoSaveRunnable)
val idleTime = preferences.autoSaveAfterIdleTime.value
if (idleTime > -1) {
autoSaveHandler.postDelayed(autoSaveRunnable, idleTime.toLong() * 1000)
}
}
private fun setupActivityResultLaunchers() {
@ -292,7 +210,10 @@ abstract class EditActivity(private val type: Type) :
selectLabelsActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
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) {
notallyModel.setLabels(list)
binding.LabelGroup.bindLabels(
@ -301,7 +222,6 @@ abstract class EditActivity(private val type: Type) :
paddingTop = true,
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(
@ -376,7 +281,6 @@ abstract class EditActivity(private val type: Type) :
ChangeHistory().apply {
canUndo.observe(this@EditActivity) { canUndo -> undo?.isEnabled = canUndo }
canRedo.observe(this@EditActivity) { canRedo -> redo?.isEnabled = canRedo }
stackPointer.observe(this@EditActivity) { _ -> resetIdleTimer() }
}
}
@ -390,6 +294,26 @@ abstract class EditActivity(private val type: Type) :
pinMenuItem =
add(R.string.pin, R.drawable.pin, MenuItem.SHOW_AS_ACTION_ALWAYS) { pin() }
bindPinned()
when (notallyModel.folder) {
Folder.NOTES -> {
add(R.string.delete, R.drawable.delete, MenuItem.SHOW_AS_ACTION_ALWAYS) {
delete()
}
}
Folder.DELETED -> {
add(R.string.restore, R.drawable.restore, MenuItem.SHOW_AS_ACTION_ALWAYS) {
restore()
}
}
Folder.ARCHIVED -> {
add(R.string.unarchive, R.drawable.unarchive, MenuItem.SHOW_AS_ACTION_ALWAYS) {
restore()
}
}
}
}
search.results.mergeSkipFirst(search.resultPos).observe(this) { (amount, pos) ->
@ -505,19 +429,7 @@ abstract class EditActivity(private val type: Type) :
binding.BottomAppBarCenter.apply {
removeAllViews()
undo =
addIconButton(
R.string.undo,
R.drawable.undo,
marginStart = 2,
onLongClick = {
try {
changeHistory.undoAll()
} catch (e: ChangeHistory.ChangeHistoryException) {
application.log(TAG, throwable = e)
}
true
},
) {
addIconButton(R.string.undo, R.drawable.undo, marginStart = 2) {
try {
changeHistory.undo()
} catch (e: ChangeHistory.ChangeHistoryException) {
@ -527,19 +439,7 @@ abstract class EditActivity(private val type: Type) :
.apply { isEnabled = changeHistory.canUndo.value }
redo =
addIconButton(
R.string.redo,
R.drawable.redo,
marginStart = 2,
onLongClick = {
try {
changeHistory.redoAll()
} catch (e: ChangeHistory.ChangeHistoryException) {
application.log(TAG, throwable = e)
}
true
},
) {
addIconButton(R.string.redo, R.drawable.redo, marginStart = 2) {
try {
changeHistory.redo()
} catch (e: ChangeHistory.ChangeHistoryException) {
@ -550,132 +450,41 @@ abstract class EditActivity(private val type: Type) :
}
binding.BottomAppBarRight.apply {
removeAllViews()
addToggleViewMode()
addIconButton(R.string.tap_for_more_options, R.drawable.more_vert, marginStart = 0) {
MoreNoteBottomSheet(
this@EditActivity,
createNoteTypeActions() + createFolderActions(),
colorInt,
)
addIconButton(R.string.more, R.drawable.more_vert, marginStart = 0) {
MoreNoteBottomSheet(this@EditActivity, createFolderActions(), colorInt)
.show(supportFragmentManager, MoreNoteBottomSheet.TAG)
}
}
setBottomAppBarColor(colorInt)
}
protected fun ViewGroup.addToggleViewMode() {
toggleViewMode =
addIconButton(R.string.edit, R.drawable.visibility) {
notallyModel.viewMode.value =
when (notallyModel.viewMode.value) {
NoteViewMode.EDIT -> NoteViewMode.READ_ONLY
NoteViewMode.READ_ONLY -> NoteViewMode.EDIT
}
}
}
protected fun createFolderActions() =
when (notallyModel.folder) {
Folder.NOTES ->
listOf(
Action(R.string.archive, R.drawable.archive) { _ ->
archive()
true
},
Action(R.string.delete, R.drawable.delete) { _ ->
delete()
true
},
Action(R.string.archive, R.drawable.archive, callback = ::archive),
Action(R.string.delete, R.drawable.delete, callback = ::delete),
)
Folder.DELETED ->
listOf(
Action(R.string.delete_forever, R.drawable.delete) { _ ->
deleteForever()
true
},
Action(R.string.restore, R.drawable.restore) { _ ->
restore()
true
},
Action(R.string.delete_forever, R.drawable.delete, callback = ::deleteForever),
Action(R.string.restore, R.drawable.restore, callback = ::restore),
)
Folder.ARCHIVED ->
listOf(
Action(R.string.delete, R.drawable.delete) { _ ->
delete()
true
},
Action(R.string.unarchive, R.drawable.unarchive) { _ ->
restore()
true
},
Action(R.string.delete, R.drawable.delete, callback = ::delete),
Action(R.string.unarchive, R.drawable.unarchive, callback = ::restore),
)
}
protected fun createNoteTypeActions() =
when (notallyModel.type) {
Type.NOTE ->
listOf(
Action(R.string.convert_to_list_note, R.drawable.convert_to_text) { _ ->
convertTo(Type.LIST)
true
}
)
Type.LIST ->
listOf(
Action(R.string.convert_to_text_note, R.drawable.convert_to_text) { _ ->
convertTo(Type.NOTE)
true
}
)
}
private fun convertTo(type: Type) {
updateModel()
lifecycleScope.launch {
notallyModel.convertTo(type)
val intent =
Intent(
this@EditActivity,
when (type) {
Type.NOTE -> EditNoteActivity::class.java
Type.LIST -> EditListActivity::class.java
},
)
intent.putExtra(EXTRA_SELECTED_BASE_NOTE, notallyModel.id)
startActivity(intent)
finish()
}
}
abstract fun configureUI()
open fun setupListeners() {
binding.EnterTitle.initHistory(changeHistory) { text ->
notallyModel.title = text.trim().toString()
}
notallyModel.viewMode.observe(this) { value ->
toggleViewMode.apply {
setImageResource(
when (value) {
NoteViewMode.READ_ONLY -> R.drawable.edit
else -> R.drawable.visibility
}
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
tooltipText =
getString(
when (value) {
NoteViewMode.READ_ONLY -> R.string.edit
else -> R.string.read_only
}
)
}
}
value?.let { toggleCanEdit(it) }
}
}
open fun setStateFromModel(savedInstanceState: Bundle?) {
@ -692,90 +501,26 @@ abstract class EditActivity(private val type: Type) :
} else DateFormat.ABSOLUTE
binding.Date.displayFormattedTimestamp(date, dateFormat, datePrefixResId)
binding.EnterTitle.setText(notallyModel.title)
bindLabels()
setColor()
}
private fun bindLabels() {
binding.LabelGroup.bindLabels(
notallyModel.labels,
notallyModel.textSize,
paddingTop = true,
colorInt,
onClick = { label ->
val bundle = Bundle()
bundle.putString(EXTRA_DISPLAYED_LABEL, label)
startActivity(
Intent(this, MainActivity::class.java).apply {
putExtra(EXTRA_FRAGMENT_TO_OPEN, R.id.DisplayLabel)
putExtra(EXTRA_DISPLAYED_LABEL, label)
putExtra(EXTRA_SKIP_START_VIEW_ON_BACK, true)
}
)
},
onLongClick = { label ->
displayEditLabelDialog(label, baseModel) { oldLabel, newLabel ->
notallyModel.labels.apply {
remove(oldLabel)
add(newLabel)
}
bindLabels()
}
},
)
setColor()
}
private fun handleSharedNote() {
val title = intent.getStringExtra(Intent.EXTRA_SUBJECT)
val string = intent.getStringExtra(Intent.EXTRA_TEXT)
val files =
IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
?: IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
?.let { listOf(it) }
if (string != null) {
notallyModel.body = Editable.Factory.getInstance().newEditable(string)
}
if (title != null) {
notallyModel.title = title
}
files?.let {
val filesByType =
it.groupBy { uri ->
getMimeType(uri)?.let { mimeType ->
if (mimeType.isImageMimeType) {
NotallyModel.FileType.IMAGE
} else {
NotallyModel.FileType.ANY
}
} ?: NotallyModel.FileType.ANY
}
filesByType[NotallyModel.FileType.IMAGE]?.let { images ->
notallyModel.addImages(images.toTypedArray())
}
filesByType[NotallyModel.FileType.ANY]?.let { otherFiles ->
notallyModel.addFiles(otherFiles.toTypedArray())
}
}
}
private fun handleViewNote() {
val text =
intent.data?.let { uri ->
contentResolver.openInputStream(uri)?.use { inputStream ->
inputStream.bufferedReader().readText()
}
?: run {
showToast(R.string.cant_load_file)
null
}
} ?: intent.getStringExtra(Intent.EXTRA_TEXT)
val title = intent.getStringExtra(Intent.EXTRA_SUBJECT)
if (text != null) {
notallyModel.body = Editable.Factory.getInstance().newEditable(text)
}
if (title != null) {
notallyModel.title = title
}
}
@RequiresApi(24)
@ -844,38 +589,9 @@ abstract class EditActivity(private val type: Type) :
}
override fun changeColor() {
lifecycleScope.launch {
val colors =
withContext(Dispatchers.IO) {
NotallyDatabase.getDatabase(this@EditActivity, observePreferences = false)
.value
.getBaseNoteDao()
.getAllColors()
}
.toMutableList()
if (colors.none { it == notallyModel.color }) {
colors.add(notallyModel.color)
}
showColorSelectDialog(
colors,
notallyModel.color,
colorInt.isLightColor(),
{ selectedColor, oldColor ->
if (oldColor != null) {
baseModel.changeColor(oldColor, selectedColor)
}
showColorSelectDialog(colorInt.isLightColor()) { 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() {
this.shareNote(notallyModel.getBaseNote())
val body =
when (type) {
Type.NOTE -> notallyModel.body
Type.LIST -> notallyModel.items.toMutableList().toText()
}
override fun export(mimeType: ExportMimeType) {
exportNotes(
mimeType,
listOf(notallyModel.getBaseNote()),
exportFileActivityResultLauncher,
exportNotesActivityResultLauncher,
)
this.shareNote(notallyModel.title, body)
}
private fun delete() {
@ -1095,9 +807,7 @@ abstract class EditActivity(private val type: Type) :
colorInt = extractColor(notallyModel.color)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
window.statusBarColor = colorInt
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
window.navigationBarColor = colorInt
}
window.setLightStatusAndNavBar(colorInt.isLightColor())
}
binding.apply {
@ -1106,8 +816,7 @@ abstract class EditActivity(private val type: Type) :
setControlsContrastColorForAllViews(colorInt)
}
root.setBackgroundColor(colorInt)
MainListView.setBackgroundColor(colorInt)
CheckedListView.setBackgroundColor(colorInt)
RecyclerView.setBackgroundColor(colorInt)
Toolbar.backgroundTintList = ColorStateList.valueOf(colorInt)
Toolbar.setControlsContrastColorForAllViews(colorInt)
}
@ -1129,15 +838,10 @@ abstract class EditActivity(private val type: Type) :
when (type) {
Type.NOTE -> {
binding.AddItem.visibility = GONE
binding.MainListView.visibility = GONE
binding.CheckedListView.visibility = GONE
binding.RecyclerView.visibility = GONE
}
Type.LIST -> {
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_FOLDER_FROM = "notallyx.intent.extra.FOLDER_FROM"
const val EXTRA_FOLDER_TO = "notallyx.intent.extra.FOLDER_TO"
val DEFAULT_EXCEPTION_HANDLER = Thread.getDefaultUncaughtExceptionHandler()
}
}

View file

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

View file

@ -21,12 +21,10 @@ import android.widget.LinearLayout
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.data.model.Type
import com.philkes.notallyx.data.model.createNoteUrl
import com.philkes.notallyx.data.model.getNoteIdFromUrl
@ -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.add
import com.philkes.notallyx.presentation.addIconButton
import com.philkes.notallyx.presentation.createBoldSpan
import com.philkes.notallyx.presentation.dp
import com.philkes.notallyx.presentation.hideKeyboard
import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews
import com.philkes.notallyx.presentation.setOnNextAction
import com.philkes.notallyx.presentation.showKeyboard
@ -69,6 +65,8 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
override fun configureUI() {
binding.EnterTitle.setOnNextAction { binding.EnterBody.requestFocus() }
setupEditor()
if (notallyModel.isNewNote) {
binding.EnterBody.requestFocus()
}
@ -80,17 +78,6 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
setupActivityResultLaunchers()
}
override fun toggleCanEdit(mode: NoteViewMode) {
super.toggleCanEdit(mode)
textFormatMenu.isVisible = mode == NoteViewMode.EDIT
when {
mode == NoteViewMode.EDIT -> showKeyboard(binding.EnterBody)
binding.EnterBody.isFocused -> hideKeyboard(binding.EnterBody)
}
binding.EnterBody.setCanEdit(mode == NoteViewMode.EDIT)
setupEditor()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.apply {
@ -185,8 +172,8 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
private fun setupEditor() {
setupMovementMethod()
binding.EnterBody.customSelectionActionModeCallback =
if (canEdit) {
object : ActionMode.Callback {
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?) = false
@ -198,29 +185,17 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
// ActionMode implementation
try {
menu?.apply {
add(
R.string.link,
0,
showAsAction = MenuItem.SHOW_AS_ACTION_NEVER,
) {
add(R.string.link, 0, showAsAction = MenuItem.SHOW_AS_ACTION_NEVER) {
binding.EnterBody.showAddLinkDialog(
this@EditNoteActivity,
mode = mode,
)
}
add(
R.string.bold,
0,
showAsAction = MenuItem.SHOW_AS_ACTION_NEVER,
) {
binding.EnterBody.applySpan(createBoldSpan())
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,
) {
add(R.string.italic, 0, showAsAction = MenuItem.SHOW_AS_ACTION_NEVER) {
binding.EnterBody.applySpan(StyleSpan(Typeface.ITALIC))
mode?.finish()
}
@ -259,11 +234,16 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
binding.EnterBody.isActionModeOn = false
}
}
} else null
binding.ContentLayout.setOnClickListener {
binding.EnterBody.apply {
requestFocus()
setSelection(length())
showKeyboard(this)
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding.EnterBody.customInsertionActionModeCallback =
if (canEdit) {
object : ActionMode.Callback {
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?) = false
@ -271,16 +251,11 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
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
// 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,
) {
add(R.string.link_note, 0, order = Menu.CATEGORY_CONTAINER + 1) {
linkNote(pickNoteNewActivityResultLauncher)
mode?.finish()
}
@ -295,9 +270,7 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
binding.EnterBody.isActionModeOn = false
}
}
} else null
}
if (canEdit) {
binding.EnterBody.setOnSelectionChange { selStart, selEnd ->
if (selEnd - selStart > 0) {
if (!textFormatMenu.isEnabled) {
@ -312,19 +285,14 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
textFormatMenu.isEnabled = false
}
}
} else {
binding.EnterBody.setOnSelectionChange { _, _ -> }
}
binding.ContentLayout.setOnClickListener {
binding.EnterBody.apply {
requestFocus()
if (canEdit) {
setSelection(length())
showKeyboard(this)
}
}
}
}
override fun initBottomMenu() {
super.initBottomMenu()
@ -358,7 +326,7 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
updateLayoutParams<LinearLayout.LayoutParams> {
marginEnd = 0
marginStart = 10.dp
marginStart = 10.dp(context)
}
setControlsContrastColorForAllViews(extractColor)
setBackgroundColor(0)
@ -372,7 +340,7 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
}
requestLayout()
val layout = BottomTextFormattingMenuBinding.inflate(layoutInflater, this, false)
layout.MainListView.apply {
layout.RecyclerView.apply {
textFormattingAdapter =
TextFormattingAdapter(this@EditNoteActivity, binding.EnterBody, colorInt)
adapter = textFormattingAdapter
@ -398,23 +366,19 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
val movementMethod = LinkMovementMethod { span ->
val items =
if (span.url.isNoteUrl()) {
if (canEdit) {
arrayOf(
getString(R.string.open_note),
getString(R.string.remove_link),
getString(R.string.change_note),
getString(R.string.edit),
getString(R.string.open_note),
)
} else arrayOf(getString(R.string.open_note))
} else {
if (canEdit) {
arrayOf(
getString(R.string.open_link),
getString(R.string.copy),
getString(R.string.remove_link),
getString(R.string.copy),
getString(R.string.edit),
getString(R.string.open_link),
)
} else arrayOf(getString(R.string.open_link), getString(R.string.copy))
}
MaterialAlertDialogBuilder(this)
.setTitle(
@ -426,24 +390,27 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
)
.setItems(items) { _, which ->
when (which) {
0 -> openLink(span)
0 -> {
binding.EnterBody.removeSpanWithHistory(
span,
span.url.isNoteUrl() ||
span.url == binding.EnterBody.getSpanText(span),
)
}
1 ->
if (span.url.isNoteUrl()) {
removeLink(span)
} else copyLink(span)
2 ->
if (span.url.isNoteUrl()) {
changeNoteLink(span)
} else removeLink(span)
3 -> editLink(span)
}
}
.show()
}
binding.EnterBody.movementMethod = movementMethod
selectedSpan = span
linkNote(pickNoteUpdateActivityResultLauncher)
} else {
copyToClipBoard(span.url)
showToast(R.string.copied_link)
}
private fun openLink(span: URLSpan) {
2 -> {
binding.EnterBody.showEditDialog(span)
}
3 -> {
span.url?.let {
if (it.isNoteUrl()) {
span.navigateToNote()
@ -452,26 +419,11 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
}
}
}
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)
.show()
}
private fun removeLink(span: URLSpan) {
binding.EnterBody.removeSpanWithHistory(
span,
span.url.isNoteUrl() || span.url == binding.EnterBody.getSpanText(span),
)
binding.EnterBody.movementMethod = movementMethod
}
private fun openLink(url: String) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,15 +1,9 @@
package com.philkes.notallyx.presentation.view.main
import android.content.res.ColorStateList
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Color
import com.philkes.notallyx.databinding.RecyclerColorBinding
import com.philkes.notallyx.presentation.dp
import com.philkes.notallyx.presentation.extractColor
import com.philkes.notallyx.presentation.getColorFromAttr
import com.philkes.notallyx.presentation.getContrastFontColor
import com.philkes.notallyx.presentation.view.misc.ItemListener
class ColorVH(private val binding: RecyclerColorBinding, listener: ItemListener) :
@ -17,40 +11,11 @@ class ColorVH(private val binding: RecyclerColorBinding, listener: ItemListener)
init {
binding.CardView.setOnClickListener { listener.onClick(absoluteAdapterPosition) }
binding.CardView.setOnLongClickListener {
listener.onLongClick(absoluteAdapterPosition)
true
}
}
fun bind(color: String, isSelected: Boolean) {
val showAddIcon = color == BaseNote.COLOR_NEW
val context = binding.root.context
val value =
if (showAddIcon) context.getColorFromAttr(R.attr.colorOnSurface)
else context.extractColor(color)
val controlsColor = context.getContrastFontColor(value)
binding.apply {
CardView.apply {
setCardBackgroundColor(value)
contentDescription = color
if (isSelected) {
strokeWidth = 4.dp
strokeColor = controlsColor
} else {
strokeWidth = 1.dp
strokeColor = controlsColor
}
}
CardIcon.apply {
if (showAddIcon) {
setImageResource(R.drawable.add)
} else if (isSelected) {
setImageResource(R.drawable.checked_circle)
}
imageTintList = ColorStateList.valueOf(controlsColor)
isVisible = showAddIcon || isSelected
}
}
fun bind(color: Color) {
val value = binding.root.context.extractColor(color)
binding.CardView.setCardBackgroundColor(value)
binding.CardView.contentDescription = color.name
}
}

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
class BaseNoteCreationDateSort(adapter: RecyclerView.Adapter<*>?, sortDirection: SortDirection) :
ItemSort(adapter, sortDirection) {
BaseNoteSort(adapter, sortDirection) {
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
}
}
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
class BaseNoteModifiedDateSort(adapter: RecyclerView.Adapter<*>?, sortDirection: SortDirection) :
ItemSort(adapter, sortDirection) {
BaseNoteSort(adapter, sortDirection) {
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
}
}
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.presentation.viewmodel.preference.SortDirection
abstract class ItemSort(
abstract class BaseNoteSort(
adapter: RecyclerView.Adapter<*>?,
private val sortDirection: SortDirection,
) : SortedListAdapterCallback<Item>(adapter) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,8 +2,6 @@ package com.philkes.notallyx.presentation.view.note.action
import androidx.annotation.ColorInt
import com.philkes.notallyx.R
import com.philkes.notallyx.databinding.BottomSheetActionBinding
import com.philkes.notallyx.presentation.viewmodel.ExportMimeType
/** BottomSheet inside list-note for all common note actions. */
class MoreNoteBottomSheet(
@ -17,38 +15,12 @@ class MoreNoteBottomSheet(
internal fun createActions(callbacks: MoreActions, additionalActions: Collection<Action>) =
listOf(
Action(R.string.share, R.drawable.share) { _ ->
callbacks.share()
true
},
Action(R.string.export, R.drawable.export) { fragment ->
fragment.layout.removeAllViews()
ExportMimeType.entries.forEach { mimeType ->
BottomSheetActionBinding.inflate(fragment.inflater, fragment.layout, true)
.root
.apply {
text = mimeType.name
setCompoundDrawablesWithIntrinsicBounds(null, null, null, null)
setOnClickListener {
callbacks.export(mimeType)
fragment.dismiss()
}
}
}
false
},
Action(R.string.change_color, R.drawable.change_color) { _ ->
callbacks.changeColor()
true
},
Action(R.string.reminders, R.drawable.notifications) { _ ->
Action(R.string.share, R.drawable.share) { callbacks.share() },
Action(R.string.change_color, R.drawable.change_color) { callbacks.changeColor() },
Action(R.string.reminders, R.drawable.notifications) {
callbacks.changeReminders()
true
},
Action(R.string.labels, R.drawable.label) { _ ->
callbacks.changeLabels()
true
},
Action(R.string.labels, R.drawable.label) { callbacks.changeLabels() },
) + additionalActions
}
}
@ -56,8 +28,6 @@ class MoreNoteBottomSheet(
interface MoreActions {
fun share()
fun export(mimeType: ExportMimeType)
fun changeColor()
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.ViewGroup
import androidx.annotation.ColorInt
import androidx.core.widget.NestedScrollView
import androidx.recyclerview.widget.NestedScrollViewItemTouchHelper
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.databinding.RecyclerListItemBinding
import com.philkes.notallyx.presentation.view.note.listitem.ListItemDragCallback
import com.philkes.notallyx.presentation.view.note.listitem.ListManager
import com.philkes.notallyx.presentation.view.note.listitem.sorting.ListItemSortedList
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.TextSize
data class ListItemHighlight(
val itemPos: Int,
val resultPos: Int,
val startIdx: Int,
val endIdx: Int,
var selected: Boolean,
)
abstract class ListItemAdapterBase(
private val adapter: RecyclerView.Adapter<*>,
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,
) {
) : RecyclerView.Adapter<ListItemVH>() {
private lateinit var list: ListItemSortedList
private val callback = ListItemDragCallback(elevation, listManager)
private val touchHelper = NestedScrollViewItemTouchHelper(callback, scrollView)
private val touchHelper = ItemTouchHelper(callback)
private val highlights = mutableMapOf<Int, MutableList<ListItemHighlight>>()
fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
touchHelper.attachToRecyclerView(recyclerView)
}
fun onBindViewHolder(holder: ListItemVH, position: Int, viewMode: NoteViewMode) {
val item = getItem(position)
override fun getItemCount() = list.size()
override fun onBindViewHolder(holder: ListItemVH, position: Int) {
val item = list[position]
holder.bind(
backgroundColor,
item,
position,
highlights[position],
highlights.get(position),
preferences.listItemSorting.value,
viewMode,
)
}
abstract fun getItem(position: Int): ListItem
fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListItemVH {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListItemVH {
val inflater = LayoutInflater.from(parent.context)
val binding = RecyclerListItemBinding.inflate(inflater, parent, false)
binding.root.background = parent.background
return ListItemVH(binding, listManager, touchHelper, textSize, isCheckedListAdapter)
return ListItemVH(binding, listManager, touchHelper, textSize)
}
internal fun setBackgroundColor(@ColorInt color: Int) {
backgroundColor = color
adapter.notifyDataSetChanged()
notifyDataSetChanged()
}
internal fun setList(list: ListItemSortedList) {
this.list = list
}
internal fun clearHighlights(): Set<Int> {
val highlightedItemPos =
highlights.entries.flatMap { (_, value) -> value.map { it.itemPos } }.toSet()
highlights.clear()
highlightedItemPos.forEach { adapter.notifyItemChanged(it) }
return highlightedItemPos
// itemPos.forEach { notifyItemChanged(it) }
}
fun highlightText(highlight: ListItemHighlight) {
internal fun highlightText(highlight: ListItemHighlight) {
if (highlights.containsKey(highlight.itemPos)) {
highlights[highlight.itemPos]!!.add(highlight)
} else {
highlights[highlight.itemPos] = mutableListOf(highlight)
}
adapter.notifyItemChanged(highlight.itemPos)
notifyItemChanged(highlight.itemPos)
}
internal fun selectHighlight(pos: Int): Int {
@ -91,7 +81,7 @@ abstract class ListItemAdapterBase(
val isSelected = it.selected
it.selected = it.resultPos == pos
if (isSelected != it.selected) {
adapter.notifyItemChanged(it.itemPos)
notifyItemChanged(it.itemPos)
}
if (it.selected) {
selectedItemPos = it.itemPos
@ -100,4 +90,12 @@ abstract class ListItemAdapterBase(
}
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
/** 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() {
private var lastState = ItemTouchHelper.ACTION_STATE_IDLE
private var lastIsCurrentlyActive = false
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 parentBefore: ListItem? = null
private var itemCount: Int? = null
private var positionTo: Int? = null
private var newPosition: Int? = null
override fun isLongPressDragEnabled() = false
@ -42,22 +40,27 @@ class ListItemDragCallback(private val elevation: Float, internal val listManage
}
internal fun move(from: Int, to: Int): Boolean {
if (positionFrom == null) {
draggedItem = listManager.getItem(from).clone() as ListItem
itemsBefore = listManager.getItems()
}
val swapped = listManager.move(from, to, false, false, isDrag = true)
if (swapped != null) {
if (positionFrom == null) {
positionFrom = from
stateBefore = listManager.getState(selectedPos = from)
val item = listManager.getItem(from)
parentBefore = if (item.isChild) listManager.findParent(item)?.second else null
}
val (positionTo, itemCount) = listManager.move(from, to)
if (positionTo != -1) {
this.itemCount = itemCount
this.positionTo = positionTo
positionTo = to
newPosition = swapped
}
return positionTo != -1
return swapped != null
}
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()
}
lastState = actionState
@ -95,7 +98,8 @@ class ListItemDragCallback(private val elevation: Float, internal val listManage
childViewHolders.forEach { animateFadeIn(it) }
}
private fun onDragStart(viewHolder: ViewHolder, recyclerView: RecyclerView) {
internal fun onDragStart(viewHolder: ViewHolder, recyclerView: RecyclerView) {
Log.d(TAG, "onDragStart")
reset()
if (viewHolder.absoluteAdapterPosition == -1) {
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() {
positionFrom = null
positionTo = 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) {
@ -142,6 +152,6 @@ class ListItemDragCallback(private val elevation: Float, internal val listManage
}
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.view.KeyEvent
import android.view.MotionEvent
import android.view.View.GONE
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.CompoundButton.OnCheckedChangeListener
import android.widget.EditText
import android.widget.TextView.INVISIBLE
import android.widget.TextView.VISIBLE
import androidx.annotation.ColorInt
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_POSITION
import cn.leaqi.drawer.SwipeDrawer.DIRECTION_LEFT
import cn.leaqi.drawer.SwipeDrawer.STATE_CLOSE
import cn.leaqi.drawer.SwipeDrawer.STATE_OPEN
import com.philkes.notallyx.data.imports.txt.extractListItems
import com.philkes.notallyx.data.imports.txt.findListSyntaxRegex
import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.databinding.RecyclerListItemBinding
import com.philkes.notallyx.presentation.clone
import com.philkes.notallyx.presentation.createListTextWatcherWithHistory
import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews
import com.philkes.notallyx.presentation.setOnNextAction
import com.philkes.notallyx.presentation.view.misc.EditTextAutoClearFocus
import com.philkes.notallyx.presentation.view.note.listitem.ListManager
import com.philkes.notallyx.presentation.view.note.listitem.firstBodyOrEmptyString
import com.philkes.notallyx.presentation.viewmodel.preference.ListItemSort
import com.philkes.notallyx.presentation.viewmodel.preference.TextSize
import com.philkes.notallyx.utils.changehistory.EditTextState
import com.philkes.notallyx.utils.copyToClipBoard
class ListItemVH(
val binding: RecyclerListItemBinding,
val listManager: ListManager,
touchHelper: ItemTouchHelper,
textSize: TextSize,
private val inCheckedList: Boolean,
) : RecyclerView.ViewHolder(binding.root) {
private var dragHandleInitialY: Float = 0f
@ -50,6 +38,11 @@ class ListItemVH(
binding.EditText.apply {
setTextSize(TypedValue.COMPLEX_UNIT_SP, body)
setOnNextAction {
val position = absoluteAdapterPosition + 1
listManager.add(position)
}
textWatcher =
createListTextWatcherWithHistory(
listManager,
@ -61,6 +54,10 @@ class ListItemVH(
false
}
}
setOnFocusChangeListener { _, hasFocus ->
binding.Delete.visibility = if (hasFocus) VISIBLE else INVISIBLE
}
}
binding.DragHandle.setOnTouchListener { _, event ->
@ -89,23 +86,22 @@ class ListItemVH(
@ColorInt backgroundColor: Int,
item: ListItem,
position: Int,
highlights: List<ListItemHighlight>?,
highlights: List<ListItemAdapter.ListItemHighlight>?,
autoSort: ListItemSort,
viewMode: NoteViewMode,
) {
updateEditText(item, position, viewMode)
updateEditText(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 {
visibility =
when {
viewMode != NoteViewMode.EDIT -> GONE
item.checked && autoSort == ListItemSort.AUTO_SORT_BY_CHECKED -> INVISIBLE
else -> VISIBLE
if (item.checked && autoSort == ListItemSort.AUTO_SORT_BY_CHECKED) {
INVISIBLE
} else {
VISIBLE
}
contentDescription = "Drag$position"
}
@ -127,65 +123,18 @@ class ListItemVH(
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 {
visibility =
when {
viewMode != NoteViewMode.EDIT -> GONE
item.checked -> VISIBLE
else -> INVISIBLE
}
setOnClickListener {
listManager.delete(absoluteAdapterPosition, inCheckedList = inCheckedList)
}
visibility = if (item.checked) VISIBLE else INVISIBLE
setOnClickListener { listManager.delete(absoluteAdapterPosition) }
contentDescription = "Delete$position"
}
}
private fun updateEditText(item: ListItem, position: Int, viewMode: NoteViewMode) {
private fun updateEditText(item: ListItem, position: Int) {
binding.EditText.apply {
setText(item.body)
paintFlags =
if (item.checked) {
paintFlags or Paint.STRIKE_THRU_TEXT_FLAG
} else {
paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv()
}
alpha = if (item.checked) 0.5f else 1.0f
contentDescription = "EditText$position"
if (viewMode == NoteViewMode.EDIT) {
setOnFocusChangeListener { _, hasFocus ->
binding.Delete.visibility = if (hasFocus) VISIBLE else INVISIBLE
}
binding.Content.descendantFocusability = ViewGroup.FOCUS_BEFORE_DESCENDANTS
} else {
onFocusChangeListener = null
binding.Content.descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS
}
setCanEdit(viewMode == NoteViewMode.EDIT)
isFocusable = !item.checked
when (viewMode) {
NoteViewMode.EDIT -> {
setOnClickListener(null)
setOnLongClickListener(null)
}
NoteViewMode.READ_ONLY -> {
setOnClickListener {
if (absoluteAdapterPosition != NO_POSITION) {
listManager.changeChecked(
absoluteAdapterPosition,
!item.checked,
inCheckedList,
)
}
}
setOnLongClickListener {
context?.copyToClipBoard(item.body)
true
}
}
}
setOnNextAction { listManager.add(bindingAdapterPosition + 1) }
isEnabled = !item.checked
setOnKeyListener { _, keyCode, event ->
if (
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
// last
// unchecked item but always re-adds a new item
listManager.delete(
absoluteAdapterPosition,
inCheckedList = inCheckedList,
force = false,
)
listManager.delete(absoluteAdapterPosition, false) != null
} else {
false
}
}
contentDescription = "EditText$position"
}
}
@ -211,12 +157,10 @@ class ListItemVH(
private fun updateCheckBox(item: ListItem, position: Int) {
if (checkBoxListener == null) {
checkBoxListener = OnCheckedChangeListener { _, isChecked ->
binding.CheckBox.setOnCheckedChangeListener(null)
if (absoluteAdapterPosition != NO_POSITION) {
listManager.changeChecked(absoluteAdapterPosition, isChecked, inCheckedList)
}
binding.CheckBox.setOnCheckedChangeListener(checkBoxListener)
checkBoxListener = OnCheckedChangeListener { buttonView, isChecked ->
buttonView!!.setOnCheckedChangeListener(null)
listManager.changeChecked(absoluteAdapterPosition, isChecked)
buttonView.setOnCheckedChangeListener(checkBoxListener)
}
}
binding.CheckBox.apply {
@ -253,31 +197,20 @@ class ListItemVH(
.findListSyntaxRegex(checkContains = true, plainNewLineAllowed = true)
?.let { listSyntaxRegex ->
val items = changedText.extractListItems(listSyntaxRegex)
if (items.isNotEmpty()) {
listManager.startBatchChange(start)
val position = absoluteAdapterPosition
val itemHadTextBefore = text.trim().length > count
val firstPastedItemBody = items.firstBodyOrEmptyString()
val updatedText =
if (itemHadTextBefore) {
text.substring(0, start) + firstPastedItemBody
} else firstPastedItemBody
editText.changeText(position, updatedText)
items.drop(1).forEachIndexed { index, it ->
listManager.add(position + 1 + index, it, pushChange = false)
if (text.trim().length > count) {
editText.setText(text.substring(0, start) + text.substring(start + count))
} else {
listManager.delete(absoluteAdapterPosition, pushChange = false)
}
listManager.finishBatchChange(position + items.size - 1)
items.forEachIndexed { idx, it ->
listManager.add(absoluteAdapterPosition + idx + 1, it, pushChange = true)
}
}
}
return containsLines
}
private fun EditText.changeText(position: Int, after: CharSequence) {
setText(after)
val stateAfter = EditTextState(editableText.clone(), selectionStart)
listManager.changeText(position, stateAfter, pushChange = false)
fun getSelection(): Pair<Int, Int> {
return Pair(binding.EditText.selectionStart, binding.EditText.selectionEnd)
}
fun getSelection() = with(binding.EditText) { Pair(selectionStart, selectionEnd) }
}

View file

@ -1,41 +1,41 @@
package com.philkes.notallyx.presentation.view.note.listitem
import android.text.TextWatcher
import android.util.Log
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.check
import com.philkes.notallyx.data.model.findChild
import com.philkes.notallyx.data.model.areAllChecked
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.presentation.view.note.listitem.adapter.CheckedListItemAdapter
import com.philkes.notallyx.presentation.view.note.listitem.adapter.ListItemAdapter
import com.philkes.notallyx.presentation.view.note.listitem.adapter.ListItemVH
import com.philkes.notallyx.presentation.view.note.listitem.sorting.SortedItemsList
import com.philkes.notallyx.presentation.view.note.listitem.sorting.ListItemSortedList
import com.philkes.notallyx.presentation.view.note.listitem.sorting.cloneList
import com.philkes.notallyx.presentation.view.note.listitem.sorting.deleteItem
import com.philkes.notallyx.presentation.view.note.listitem.sorting.filter
import com.philkes.notallyx.presentation.view.note.listitem.sorting.findById
import com.philkes.notallyx.presentation.view.note.listitem.sorting.findParent
import com.philkes.notallyx.presentation.view.note.listitem.sorting.isNotEmpty
import com.philkes.notallyx.presentation.view.note.listitem.sorting.lastIndex
import com.philkes.notallyx.presentation.view.note.listitem.sorting.moveItemRange
import com.philkes.notallyx.presentation.view.note.listitem.sorting.reversed
import com.philkes.notallyx.presentation.view.note.listitem.sorting.setChecked
import com.philkes.notallyx.presentation.view.note.listitem.sorting.setCheckedWithChildren
import com.philkes.notallyx.presentation.view.note.listitem.sorting.setIsChild
import com.philkes.notallyx.presentation.view.note.listitem.sorting.shiftItemOrders
import com.philkes.notallyx.presentation.view.note.listitem.sorting.toReadableString
import com.philkes.notallyx.presentation.viewmodel.preference.ListItemSort
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.autoSortByCheckedEnabled
import com.philkes.notallyx.utils.changehistory.ChangeCheckedForAllChange
import com.philkes.notallyx.utils.changehistory.ChangeHistory
import com.philkes.notallyx.utils.changehistory.DeleteCheckedChange
import com.philkes.notallyx.utils.changehistory.EditTextState
import com.philkes.notallyx.utils.changehistory.ListAddChange
import com.philkes.notallyx.utils.changehistory.ListBatchChange
import com.philkes.notallyx.utils.changehistory.ListCheckedChange
import com.philkes.notallyx.utils.changehistory.ListDeleteChange
import com.philkes.notallyx.utils.changehistory.ListEditTextChange
import com.philkes.notallyx.utils.changehistory.ListIsChildChange
import com.philkes.notallyx.utils.changehistory.ListMoveChange
import com.philkes.notallyx.utils.lastIndex
data class ListState(
val items: MutableList<ListItem>,
val checkedItems: MutableList<ListItem>?,
val focusedItemPos: Int? = null,
val cursorPos: Int? = null,
)
/**
* Should be used for all changes to the items list. Notifies the [RecyclerView.Adapter] and pushes
@ -49,104 +49,33 @@ class ListManager(
private val endSearch: (() -> Unit)?,
val refreshSearch: ((refocusView: View?) -> Unit)?,
) {
lateinit var adapter: ListItemAdapter
var checkedAdapter: CheckedListItemAdapter? = null
private var nextItemId: Int = 0
private val items: MutableList<ListItem>
get() = adapter.items
private var itemsChecked: SortedItemsList? = null
private var batchChangeBeforeState: ListState? = null
fun init(
adapter: ListItemAdapter,
itemsChecked: SortedItemsList? = null,
adapterChecked: CheckedListItemAdapter? = null,
) {
this.adapter = adapter
this.itemsChecked = itemsChecked
this.checkedAdapter = adapterChecked
nextItemId = this.items.size + (this.itemsChecked?.size() ?: 0)
Log.d(TAG, "initList:\n${this.items.toReadableString()}")
this.itemsChecked?.let { Log.d(TAG, "itemsChecked:\n${it}") }
}
internal fun getState(selectedPos: Int? = null): ListState {
val (pos, cursorPos) = recyclerView.getFocusedPositionAndCursor()
return ListState(
items.cloneList(),
itemsChecked?.toMutableList()?.cloneList(),
selectedPos ?: pos,
cursorPos,
)
}
internal fun setState(state: ListState) {
adapter.submitList(state.items) {
state.focusedItemPos?.let { itemPos -> focusItem(itemPos, state.cursorPos) }
}
this.itemsChecked?.setItems(state.checkedItems!!)
}
private fun focusItem(itemPos: Int, cursorPos: Int?) {
// Focus item's EditText and set cursor position
recyclerView.post {
if (itemPos in 0..items.size) {
recyclerView.smoothScrollToPosition(itemPos)
(recyclerView.findViewHolderForAdapterPosition(itemPos) as? ListItemVH?)?.let {
viewHolder ->
inputMethodManager?.let { inputManager ->
val maxCursorPos = viewHolder.binding.EditText.length()
viewHolder.focusEditText(
selectionStart = cursorPos?.coerceIn(0, maxCursorPos) ?: maxCursorPos,
inputMethodManager = inputManager,
)
}
}
}
}
}
private lateinit var items: ListItemSortedList
internal lateinit var adapter: RecyclerView.Adapter<ListItemVH>
fun add(
position: Int = items.size,
item: ListItem = defaultNewItem(position.coerceAtMost(items.size)),
position: Int = items.size(),
item: ListItem = defaultNewItem(position),
pushChange: Boolean = true,
) {
val stateBefore = getState()
endSearch?.invoke()
(item + item.children).forEach { setIdIfUnset(it) }
val itemBeforeInsert = item.clone() as ListItem
val insertOrder =
if (position < 1) {
0
} else if (position <= items.lastIndex) {
items[position - 1].order!! + 1
} else {
items.lastOrNull()?.let { it.order!! + 1 } ?: 0
items.beginBatchedUpdates()
for ((idx, newItem) in (item + item.children).withIndex()) {
addItem(position + idx, newItem)
}
shiftItemOrdersHigher(insertOrder - 1, 1 + item.children.size)
item.order = insertOrder
item.children.forEachIndexed { index, child -> child.order = insertOrder + 1 + index }
items.endBatchedUpdates()
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.notifyPreviousFirstItem(insertPos, count)
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 {
val viewHolder = recyclerView.findViewHolderForAdapterPosition(insertPos) as ListItemVH?
val viewHolder =
recyclerView.findViewHolderForAdapterPosition(positionAfterAdd) as ListItemVH?
if (!item.checked && viewHolder != null) {
inputMethodManager?.let { viewHolder.focusEditText(inputMethodManager = it) }
}
@ -164,192 +93,228 @@ class ListManager(
*/
fun delete(
position: Int = items.lastIndex,
inCheckedList: Boolean = false,
force: Boolean = true,
childrenToDelete: List<ListItem>? = null,
pushChange: Boolean = true,
allowFocusChange: Boolean = true,
): Boolean {
// TODO
// endSearch?.invoke()
val stateBefore = getState()
val items = this.items.toMutableList()
var result = false
if (position.isValidPosition(forCheckedList = inCheckedList)) {
return false
): ListItem? {
endSearch?.invoke()
if (position < 0 || position > items.lastIndex) {
return null
}
var item: ListItem? = null
if (force || position > 0) {
val item = getItem(position, inCheckedList)
shiftItemOrdersHigher(item.order!! - 1, 1 + item.children.size, items = items)
if (inCheckedList) {
itemsChecked!!.removeFromParent(item)
itemsChecked!!.removeWithChildren(item)
} else {
val parent = items.removeFromParent(item)
parent?.updateParentChecked(items)
items.removeWithChildren(item)
}
result = true
adapter.submitList(items)
item = items.deleteItem(position, childrenToDelete)
}
if (!force && allowFocusChange) {
if (position > 0) {
this.moveFocusToNext(position - 2)
} else if (items.size > 1) {
} else if (items.size() > 1) {
this.moveFocusToNext(position)
}
}
if (pushChange && result) {
changeHistory.push(ListDeleteChange(stateBefore, getState(), this))
if (item != null && pushChange) {
changeHistory.push(ListDeleteChange(item.order!!, item, this))
}
return result
return item
}
/** @return position of the moved item afterwards and the moved item count. */
fun move(positionFrom: Int, positionTo: Int): Pair<Int, Int> {
val itemsCheckedBefore = itemsChecked?.toMutableList()?.cloneList()
val list = items.toMutableList()
val movedItem = list[positionFrom]
// Do not allow to move parent into its own children
if (
!movedItem.isChild &&
positionTo in (positionFrom..positionFrom + movedItem.children.size)
) {
return Pair(-1, -1)
fun deleteById(
itemId: Int,
force: Boolean = true,
childrenToDelete: List<ListItem>? = null,
pushChange: Boolean = true,
allowFocusChange: Boolean = true,
): ListItem? {
return delete(
items.findById(itemId)!!.first,
force,
childrenToDelete,
pushChange,
allowFocusChange,
)
}
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. */
fun finishMove(
/** @return position of the moved item afterwards */
fun move(
positionFrom: Int,
positionTo: Int,
count: Int,
parentBefore: ListItem?,
stateBefore: ListState,
pushChange: Boolean = true,
updateChildren: Boolean = true,
isDrag: Boolean = false,
): Int? {
endSearch?.invoke()
val itemTo = items[positionTo]
val itemFrom = items[positionFrom]
// val itemBeforeMove = itemFrom.clone() as ListItem
val itemsBeforeMove = getItems()
// Disallow move unchecked item under any checked item (if auto-sort enabled)
if (isAutoSortByCheckedEnabled() && itemTo.checked || itemTo.isChildOf(itemFrom)) {
return null
}
val checkChildPosition = if (positionTo < positionFrom) positionTo - 1 else positionTo
val forceIsChild =
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,
) {
val item = items[positionTo]
val itemBelow = items.getOrNull(positionTo + count)
val forceIsChild = itemBelow?.isChild == true && !item.isChild
val positionFrom = stateBefore.items.indexOfFirst { it.id == item.id }
var isChildChanged = false
if (positionTo == 0) {
item.isChild = false
items.notifyPreviousFirstItem(0, count)
isChildChanged = true
} else if (forceIsChild) {
item.isChild = true
isChildChanged = true
if (updateIsChild) {
if (newPosition.isBeforeChildItemOfOtherParent) {
items.setIsChild(newPosition, isChild = true, forceOnChildren = true)
} else if (newPosition == 0) {
items.setIsChild(newPosition, false)
}
if (positionFrom == 0) {
adapter.notifyItemChanged(0)
isChildChanged = true
}
if (item.isChild) {
items.refreshParent(positionTo)?.updateParentChecked()
}
parentBefore?.updateParentChecked()
if (isChildChanged) {
adapter.notifyItemChanged(positionTo)
val item = items[newPosition]
if (updateChildren) {
val forceValue = item.isChild
items.forceItemIsChild(item, forceValue, resetBefore = true)
items.updateItemAt(items.findById(item.id)!!.first, item)
} else if (item.isChild && newPosition > 0) {
items.removeChildFromParent(item)
items.updateChildInParent(newPosition, item)
}
if (pushChange) {
changeHistory.push(ListMoveChange(stateBefore, getState(), this))
changeHistory.push(ListMoveChange(positionFrom, itemsBeforeMove, getItems(), this))
}
}
fun changeText(position: Int, value: EditTextState, pushChange: Boolean = true) {
val stateBefore = getState()
fun setItems(items: List<ListItem>) {
this.items.init(items)
}
fun changeText(
editText: EditText,
listener: TextWatcher,
position: Int,
value: EditTextState,
before: EditTextState? = null,
pushChange: Boolean = true,
) {
// if(!pushChange) {
endSearch?.invoke()
// }
val item = items[position]
item.body = value.text.toString()
if (pushChange) {
changeHistory.push(ListEditTextChange(stateBefore, getState(), this))
changeHistory.push(
ListEditTextChange(editText, position, before!!, value, listener, this)
)
// TODO: fix focus change
// refreshSearch?.invoke(editText)
}
}
fun changeChecked(
position: Int,
checked: Boolean,
inCheckedList: Boolean = false,
pushChange: Boolean = true,
) {
val beforeState = getState()
val item = getItem(position, inCheckedList)
fun changeChecked(position: Int, checked: Boolean, pushChange: Boolean = true) {
val before = getItems()
val item = items[position]
if (item.checked == checked) {
return
}
if (item.isChild) {
changeCheckedChild(position, item, checked, inCheckedList)
} else {
changeCheckedParent(item, checked, changeChildren = true)
changeCheckedForChild(checked, item, pushChange, position, before)
return
}
items.setCheckedWithChildren(position, checked)
if (pushChange) {
changeHistory.push(ListCheckedChange(before, getItems(), this))
}
}
private fun changeCheckedForChild(
checked: Boolean,
item: ListItem,
pushChange: Boolean,
position: Int,
before: List<ListItem>,
) {
var actualPosition = position
val (parentPosition, parent) = items.findParent(item)!!
if (!checked) {
// If a child is being unchecked and the parent was checked, the parent gets unchecked
// too
if (parent.checked) {
items.setChecked(parentPosition, false, recalcChildrenPositions = true)
actualPosition = items.findById(item.id)!!.first
}
}
items.setChecked(actualPosition, checked)
if (parent.children.areAllChecked() && !parent.checked) {
items.setChecked(parentPosition, true, recalcChildrenPositions = true)
}
if (pushChange) {
changeHistory.push(ListCheckedChange(beforeState, getState(), this))
changeHistory.push(ListCheckedChange(before, getItems(), this))
}
}
fun changeCheckedForAll(checked: Boolean, pushChange: Boolean = true) {
val stateBefore = getState()
val parents =
items.findParentsByChecked(!checked) +
(itemsChecked?.findParentsByChecked(!checked) ?: listOf())
parents.forEach { parent -> changeCheckedParent(parent, checked, true) }
if (pushChange) {
changeHistory.push(ChangeCheckedForAllChange(stateBefore, getState(), this))
val parentIds = mutableListOf<Int>()
val changedIds = mutableListOf<Int>()
items
.reversed() // have to start from the bottom upwards, otherwise sort order will be wrong
.forEach { item ->
if (!item.isChild) {
parentIds.add(item.id)
}
if (item.checked != checked) {
changedIds.add(item.id)
}
}
parentIds.forEach {
val (position, _) = items.findById(it)!!
changeChecked(position, checked, pushChange = false)
}
if (pushChange) {
changeHistory.push(ChangeCheckedForAllChange(checked, changedIds, this))
}
}
fun checkByIds(
checked: Boolean,
ids: Collection<Int>,
recalcChildrenPositions: Boolean = false,
): Pair<List<Int>, List<Int>> {
return check(checked, ids.map { items.findById(it)!!.first }, recalcChildrenPositions)
}
fun changeIsChild(position: Int, isChild: Boolean, pushChange: Boolean = true) {
val stateBefore = getState()
items.findParentPosition(position)?.let { parentPos ->
val nearestParent = items[parentPos]
val item = items[position]
item.isChild = isChild
if (isChild) {
items.refreshParent(position)
} else {
nearestParent.children.apply {
val childIndex = indexOf(item)
val childrenBelow = filterIndexed { idx, _ -> idx > childIndex }
removeAll(childrenBelow)
remove(item)
item.children = childrenBelow.toMutableList()
}
}
item.updateParentChecked()
nearestParent.updateParentChecked()
}
items.setIsChild(position, isChild)
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) {
endSearch?.invoke()
val stateBefore = getState()
items.deleteCheckedItems().forEach { adapter.notifyItemRemoved(it) }
itemsChecked?.deleteCheckedItems()
val itemsToDelete =
items.filter { it.checked }.map { it.clone() as ListItem }.sortedBy { it.isChild }
items.beginBatchedUpdates()
itemsToDelete
.reversed() // delete children first so sorting works properly
.forEach { items.deleteItem(it) }
val deletedItems =
itemsToDelete.toMutableList().filter { item ->
// If a parent with its children was deleted, remove the children item
// since DeleteCheckedChange uses listManager.add, which already adds the children
// from parent.children list
!(item.isChild &&
itemsToDelete.any { parent -> parent.children.any { it.id == item.id } })
}
items.endBatchedUpdates()
if (pushChange) {
changeHistory.push(DeleteCheckedChange(stateBefore, getState(), this))
changeHistory.push(DeleteCheckedChange(deletedItems, this))
}
}
fun findParent(item: ListItem) = items.findParent(item) ?: itemsChecked?.findParent(item)
internal fun startBatchChange(cursorPos: Int? = null) {
batchChangeBeforeState = getState()
cursorPos?.let { batchChangeBeforeState = batchChangeBeforeState!!.copy(cursorPos = it) }
fun initList(items: ListItemSortedList) {
this.items = items
nextItemId = this.items.size()
Log.d(TAG, "initList:\n${this.items.toReadableString()}")
}
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): ListItem {
return items[position]
}
internal fun getItem(position: Int, fromCheckedList: Boolean = false): ListItem {
return if (fromCheckedList) itemsChecked!![position] else items[position]
}
private fun RecyclerView.getFocusedPositionAndCursor(): Pair<Int?, Int?> {
return focusedChild?.let { view ->
val position = getChildAdapterPosition(view)
if (position == RecyclerView.NO_POSITION) {
return Pair(null, null)
}
val viewHolder = recyclerView.findViewHolderForAdapterPosition(position)
val cursorPos = (viewHolder as? ListItemVH)?.binding?.EditText?.selectionStart
return Pair(position, cursorPos)
} ?: Pair(null, null)
}
internal fun getItems(): List<ListItem> = items.cloneList()
internal fun defaultNewItem(position: Int) =
ListItem(
"",
false,
items.isNotEmpty() &&
((position < items.size && items[position].isChild) ||
((position < items.size() && items[position].isChild) ||
(position > 0 && items[position - 1].isChild)),
null,
mutableListOf(),
nextItemId++,
)
private fun changeCheckedParent(
parent: ListItem,
private fun check(
checked: Boolean,
changeChildren: Boolean,
items: MutableList<ListItem> = this@ListManager.items,
) {
if (checked) {
// A parent from unchecked is checked
if (preferences.autoSortByCheckedEnabled) {
checkWithAutoSort(parent, items)
} else {
parent.check(true, checkChildren = changeChildren)
if (items == this@ListManager.items) {
adapter.notifyListItemChanged(parent.id)
}
}
} else {
if (preferences.autoSortByCheckedEnabled) {
uncheckWithAutoSort(parent, uncheckChildren = changeChildren)
} else {
parent.check(false, checkChildren = changeChildren)
if (items == this@ListManager.items) {
adapter.notifyListItemChanged(parent.id)
}
}
}
positions: Collection<Int>,
recalcChildrenPositions: Boolean = false,
): Pair<List<Int>, List<Int>> {
return items.setChecked(positions, checked, recalcChildrenPositions)
}
private fun changeCheckedChild(
position: Int,
child: ListItem,
checked: Boolean,
inCheckedList: Boolean,
) {
if (checked) {
child.checked = true
adapter.notifyItemChanged(position)
val (_, parent) = items.findParent(child)!!
parent.updateParentChecked()
} else {
if (inCheckedList) {
uncheckWithAutoSort(child)
} else {
child.checked = false
adapter.notifyItemChanged(position)
checkParent(child, false)
}
}
}
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 addItem(position: Int, newItem: ListItem) {
setIdIfUnset(newItem)
items.shiftItemOrders(position until items.size(), 1)
newItem.order = position
val forceIsChild =
when {
position == 0 -> false
(position - 1).isBeforeChildItemOfOtherParent -> true
newItem.isChild && items.findParent(newItem) == null -> true
else -> null
}
items.add(newItem, forceIsChild)
}
private fun setIdIfUnset(newItem: ListItem) {
@ -534,36 +401,32 @@ class ListManager(
}
}
/** Adds [valueToAdd] to all [ListItem.order] that are higher than [threshold] */
private fun shiftItemOrdersHigher(
threshold: Int,
valueToAdd: Int,
items: List<ListItem> = this.items,
) {
items.shiftItemOrdersHigher(threshold, valueToAdd)
itemsChecked?.shiftItemOrdersHigher(threshold, valueToAdd)
private fun isAutoSortByCheckedEnabled() =
preferences.listItemSorting.value == ListItemSort.AUTO_SORT_BY_CHECKED
private val Int.isBeforeChildItemOfOtherParent: Boolean
get() {
if (this < 0) {
return false
}
val item = items[this]
return item.isNextItemChild(this) && !items[this + item.itemCount].isChildOf(this)
}
/** Adds [valueToAdd] to all [ListItem.order] that are in [orderRange] */
private fun shiftItemOrders(
orderRange: IntRange,
valueToAdd: Int,
items: List<ListItem> = this.items,
) {
items.shiftItemOrders(orderRange, valueToAdd)
itemsChecked?.shiftItemOrders(orderRange, valueToAdd)
private val Int.isBeforeChildItem: Boolean
get() {
if (this < 0 || this > items.lastIndex - 1) {
return false
}
return items[this + 1].isChild
}
private fun MutableList<ListItem>.notifyPreviousFirstItem(position: Int, count: Int) {
if (position == 0 && size > count) {
// To trigger enabling isChild swiping for the item that was previously at pos 0
adapter.notifyItemChanged(count)
}
private fun ListItem.isNextItemChild(position: Int): Boolean {
return (position < items.size() - itemCount) && (items[position + this.itemCount].isChild)
}
private fun Int.isValidPosition(forCheckedList: Boolean = false): Boolean {
return this < 0 ||
this > (if (forCheckedList) itemsChecked!!.lastIndex else items.lastIndex)
private fun ListItem.isChildOf(otherPosition: Int): Boolean {
return isChildOf(items[otherPosition])
}
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.Intent
import android.net.Uri
import android.os.Build
import androidx.annotation.RequiresApi
import android.widget.Toast
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
import androidx.room.withTransaction
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@ -29,6 +27,7 @@ import com.philkes.notallyx.data.imports.NotesImporter
import com.philkes.notallyx.data.model.Attachment
import com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Color
import com.philkes.notallyx.data.model.Content
import com.philkes.notallyx.data.model.Converters
import com.philkes.notallyx.data.model.FileAttachment
@ -38,20 +37,14 @@ import com.philkes.notallyx.data.model.Item
import com.philkes.notallyx.data.model.Label
import com.philkes.notallyx.data.model.SearchResult
import com.philkes.notallyx.data.model.toNoteIdReminders
import com.philkes.notallyx.presentation.activity.main.fragment.settings.SettingsFragment.Companion.EXTRA_SHOW_IMPORT_BACKUPS_FOLDER
import com.philkes.notallyx.presentation.getQuantityString
import com.philkes.notallyx.presentation.restartApplication
import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.showToast
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
import com.philkes.notallyx.presentation.view.misc.Progress
import com.philkes.notallyx.presentation.viewmodel.preference.BasePreference
import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.EMPTY_PATH
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.START_VIEW_DEFAULT
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.START_VIEW_UNLABELED
import com.philkes.notallyx.presentation.viewmodel.preference.Theme
import com.philkes.notallyx.utils.ActionMode
import com.philkes.notallyx.utils.Cache
import com.philkes.notallyx.utils.MIME_TYPE_JSON
@ -70,11 +63,7 @@ import com.philkes.notallyx.utils.getBackupDir
import com.philkes.notallyx.utils.getExternalImagesDirectory
import com.philkes.notallyx.utils.log
import com.philkes.notallyx.utils.scheduleNoteReminders
import com.philkes.notallyx.utils.security.decryptDatabase
import com.philkes.notallyx.utils.security.encryptDatabase
import java.io.File
import java.util.concurrent.atomic.AtomicInteger
import javax.crypto.Cipher
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -102,13 +91,11 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
val folder = NotNullLiveData(Folder.NOTES)
var currentLabel: String? = CURRENT_LABEL_EMPTY
var keyword = String()
set(value) {
if (field != value || searchResults?.value?.isEmpty() == true) {
field = value
searchResults!!.fetch(keyword, folder.value, 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 others = Header(app.getString(R.string.others))
private val archived = Header(app.getString(R.string.archived))
val preferences = NotallyXPreferences.getInstance(app)
@ -128,14 +114,9 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
val actionMode = ActionMode()
internal var showRefreshBackupsFolderAfterThemeChange = false
private var labelsHiddenObserver: Observer<Set<String>>? = null
init {
NotallyDatabase.getDatabase(app).observeForever(::init)
folder.observeForever { newFolder ->
searchResults!!.fetch(keyword, newFolder, currentLabel)
}
folder.observeForever { newFolder -> searchResults!!.fetch(keyword, newFolder) }
}
private fun init(database: NotallyDatabase) {
@ -145,7 +126,6 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
commonDao = database.getCommonDao()
labels = labelDao.getAll()
// colors = baseNoteDao.getAllColorsAsync()
reminders = baseNoteDao.getAllRemindersAsync()
allNotes?.removeObserver(allNotesObserver!!)
@ -153,12 +133,11 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
allNotes = baseNoteDao.getAllAsync()
allNotes!!.observeForever(allNotesObserver!!)
labelsHiddenObserver?.let { preferences.labelsHidden.removeObserver(it) }
labelsHiddenObserver = Observer { labelsHidden ->
baseNotes = null
initBaseNotes(labelsHidden)
if (baseNotes == null) {
baseNotes = Content(baseNoteDao.getFrom(Folder.NOTES), ::transform)
} else {
baseNotes!!.setObserver(baseNoteDao.getFrom(Folder.NOTES))
}
preferences.labelsHidden.observeForever(labelsHiddenObserver!!)
if (deletedNotes == null) {
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 {
if (labelCache[label] == null) {
labelCache[label] = Content(baseNoteDao.getBaseNotesByLabel(label), ::transform)
@ -211,11 +178,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
return requireNotNull(labelCache[label])
}
fun getNotesWithoutLabel(): Content {
return Content(baseNoteDao.getBaseNotesWithoutLabel(Folder.NOTES), ::transform)
}
private fun transform(list: List<BaseNote>) = transform(list, pinned, others, archived)
private fun transform(list: List<BaseNote>) = transform(list, pinned, others)
fun disableBackups() {
val value = preferences.backupsFolder.value
@ -241,67 +204,39 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
}
savePreference(preferences.backupsFolder, newBackupsFolder)
}
showRefreshBackupsFolderAfterThemeChange = false
}
fun enableDataInPublic(callback: (() -> Unit)? = null) {
fun enableDataInPublic() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val database = NotallyDatabase.getDatabase(app, observePreferences = false).value
database.checkpoint()
val directory = NotallyDatabase.getExternalDatabaseFile(app).parentFile
NotallyDatabase.getInternalDatabaseFiles(app).forEach {
it.copyTo(File(directory, it.name), overwrite = true)
}
// database.close()
NotallyDatabase.getInternalDatabaseFile(app)
.copyTo(NotallyDatabase.getExternalDatabaseFile(app), overwrite = true)
}
savePreference(preferences.dataInPublicFolder, true)
callback?.invoke()
}
}
fun disableDataInPublic(callback: (() -> Unit)? = null) {
fun disableDataInPublic() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val database = NotallyDatabase.getDatabase(app, observePreferences = false).value
database.checkpoint()
val directory = NotallyDatabase.getInternalDatabaseFile(app).parentFile
val oldFiles = NotallyDatabase.getExternalDatabaseFiles(app)
oldFiles.forEach { it.copyTo(File(directory, it.name), overwrite = true) }
// database.close()
oldFiles.forEach {
NotallyDatabase.getExternalDatabaseFile(app)
.copyTo(NotallyDatabase.getInternalDatabaseFile(app), overwrite = true)
NotallyDatabase.getExternalDatabaseFiles(app).forEach {
if (it.exists()) {
it.delete()
}
}
}
savePreference(preferences.dataInPublicFolder, false)
callback?.invoke()
}
}
fun enableBiometricLock(cipher: Cipher) {
savePreference(preferences.iv, cipher.iv)
val passphrase = preferences.databaseEncryptionKey.init(cipher)
encryptDatabase(app, passphrase)
savePreference(preferences.fallbackDatabaseEncryptionKey, passphrase)
savePreference(preferences.biometricLock, BiometricLock.ENABLED)
}
@RequiresApi(Build.VERSION_CODES.M)
fun disableBiometricLock(cipher: Cipher? = null, callback: (() -> Unit)? = null) {
val encryptedPassphrase = preferences.databaseEncryptionKey.value
val passphrase =
cipher?.doFinal(encryptedPassphrase)
?: preferences.fallbackDatabaseEncryptionKey.value!!
database.close()
decryptDatabase(app, passphrase)
savePreference(preferences.biometricLock, BiometricLock.DISABLED)
callback?.invoke()
}
fun <T> savePreference(preference: BasePreference<T>, value: T) {
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) {
val exceptionHandler = CoroutineExceptionHandler { _, 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()
@ -347,7 +282,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
fun importXmlBackup(uri: Uri) {
val exceptionHandler = CoroutineExceptionHandler { _, 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) {
@ -364,16 +299,19 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
}
fun importFromOtherApp(uri: Uri, importSource: ImportSource) {
val database = NotallyDatabase.getDatabase(app, observePreferences = false).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 database = NotallyDatabase.getDatabase(app).value
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Toast.makeText(
app,
if (throwable is ImportException) {
throwable.textResId
} else R.string.invalid_backup,
Toast.LENGTH_LONG,
)
.show()
app.log(TAG, throwable = throwable)
}
viewModelScope.launch(exceptionHandler) {
val importedNotes =
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 ->
app.log(TAG, throwable = throwable)
actionMode.close(true)
@ -403,6 +341,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
app.showToast(R.string.something_went_wrong)
}
viewModelScope.launch(exceptionHandler) {
val notes = actionMode.selectedNotes.values
val counter = AtomicInteger(0)
for (note in notes) {
exportProgress.postValue(Progress(total = notes.size))
@ -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) {
val id = actionMode.selectedIds.toLongArray()
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()
actionMode.close(true)
viewModelScope.launch(Dispatchers.IO) { baseNoteDao.updateColor(ids, color) }
}
fun changeColor(oldColor: String, newColor: String) {
viewModelScope.launch(Dispatchers.IO) { baseNoteDao.updateColor(oldColor, newColor) }
executeAsync { baseNoteDao.updateColor(ids, color) }
}
fun moveBaseNotes(folder: Folder): LongArray {
@ -467,9 +398,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
}
fun moveBaseNotes(ids: LongArray, folder: Folder) {
viewModelScope.launch(
Dispatchers.IO
) { // Only reminders of notes in NOTES folder are active
executeAsync {
baseNoteDao.move(ids, folder)
val notes = baseNoteDao.getByIds(ids).toNoteIdReminders()
// 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) {
actionMode.close(true)
viewModelScope.launch(Dispatchers.IO) { baseNoteDao.updateLabels(id, labels) }
executeAsync { baseNoteDao.updateLabels(id, labels) }
}
fun deleteSelectedBaseNotes() {
@ -498,7 +427,6 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
app.cancelNoteReminders(noteReminders)
deleteBaseNotes(ids)
withContext(Dispatchers.IO) { labelDao.deleteAll() }
savePreference(preferences.startView, START_VIEW_DEFAULT)
app.showToast(R.string.cleared_data)
}
}
@ -549,16 +477,13 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
suspend fun getAllLabels() = withContext(Dispatchers.IO) { labelDao.getArrayOfAll() }
fun deleteLabel(value: String) {
viewModelScope.launch(Dispatchers.IO) { commonDao.deleteLabel(value) }
val labelsHiddenPreference = preferences.labelsHidden
executeAsync { commonDao.deleteLabel(value) }
val labelsHiddenPreference = preferences.labelsHiddenInNavigation
val labelsHidden = labelsHiddenPreference.value.toMutableSet()
if (labelsHidden.contains(value)) {
labelsHidden.remove(value)
savePreference(labelsHiddenPreference, labelsHidden)
}
if (preferences.startView.value == value) {
savePreference(preferences.startView, START_VIEW_DEFAULT)
}
}
fun insertLabel(label: Label, onComplete: (success: Boolean) -> Unit) =
@ -566,7 +491,7 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) {
fun updateLabel(oldValue: String, newValue: String, onComplete: (success: Boolean) -> Unit) {
executeAsyncWithCallback({ commonDao.updateLabel(oldValue, newValue) }, onComplete)
val labelsHiddenPreference = preferences.labelsHidden
val labelsHiddenPreference = preferences.labelsHiddenInNavigation
val labelsHidden = labelsHiddenPreference.value.toMutableSet()
if (labelsHidden.contains(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 publicFolder = preferences.dataInPublicFolder.value
val isThemeDefault = preferences.theme.value == Theme.FOLLOW_SYSTEM
val finishCallback = { callback(!isThemeDefault) }
if (preferences.biometricLock.value == BiometricLock.ENABLED) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
disableBiometricLock {
finishResetPreferencesAfterBiometric(
publicFolder,
backupsFolder,
finishCallback,
)
}
} else finishResetPreferencesAfterBiometric(publicFolder, backupsFolder, finishCallback)
} else finishResetPreferencesAfterBiometric(publicFolder, backupsFolder, finishCallback)
}
private fun finishResetPreferencesAfterBiometric(
publicFolder: Boolean,
backupsFolder: String,
callback: (() -> Unit),
) {
if (publicFolder) {
refreshDataInPublicFolder(false) { finishResetPreferences(backupsFolder, callback) }
} else finishResetPreferences(backupsFolder, callback)
}
private fun finishResetPreferences(backupsFolder: String, callback: () -> Unit) {
preferences.reset()
refreshDataInPublicFolder()
if (backupsFolder != EMPTY_PATH) {
clearPersistedUriPermissions(backupsFolder)
}
callback()
app.restartApplication(R.id.Settings)
}
fun importPreferences(
context: Context,
uri: Uri,
askForUriPermissions: (uri: Uri) -> Unit,
onSuccess: () -> Unit,
onFailure: () -> Unit,
) {
): Boolean {
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 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,
) {
refreshDataInPublicFolder()
val backupFolder = preferences.backupsFolder.getFreshValue()
val hasUseDynamicColorsChange =
useDynamicColorsBefore != preferences.useDynamicColors.getFreshValue()
if (oldBackupsFolder != backupFolder) {
showRefreshBackupsFolderAfterThemeChange = true
if (themeBefore == preferences.theme.getFreshValue() && !hasUseDynamicColorsChange) {
refreshBackupsFolder(context, backupFolder, askForUriPermissions)
}
} else {
showRefreshBackupsFolderAfterThemeChange = false
}
val startView = preferences.startView.getFreshValue()
if (oldStartView != startView) {
refreshStartView(startView, oldStartView)
}
preferences.theme.refresh()
callback()
if (showRefreshBackupsFolderAfterThemeChange) {
app.restartApplication(R.id.Settings, EXTRA_SHOW_IMPORT_BACKUPS_FOLDER to true)
}
return success
}
fun refreshBackupsFolder(
private fun refreshBackupsFolder(
context: Context,
backupFolder: String = preferences.backupsFolder.value,
backupFolder: String,
askForUriPermissions: (uri: Uri) -> Unit,
) {
try {
val backupFolderUri = backupFolder.toUri()
MaterialAlertDialogBuilder(context)
.setMessage(R.string.auto_backups_folder_rechoose)
.setCancelButton { _, _ -> showRefreshBackupsFolderAfterThemeChange = false }
.setOnDismissListener { showRefreshBackupsFolderAfterThemeChange = false }
.setCancelButton()
.setPositiveButton(R.string.choose_folder) { _, _ ->
askForUriPermissions(backupFolderUri)
}
.show()
} catch (e: Exception) {
showRefreshBackupsFolderAfterThemeChange = false
disableBackups()
}
}
private fun refreshDataInPublicFolder(dataInPublicFolder: Boolean, callback: () -> Unit) {
if (dataInPublicFolder) {
enableDataInPublic(callback)
private fun refreshDataInPublicFolder() {
val dataInPublicFolderBefore = preferences.dataInPublicFolder.value
val dataInPublicFolderAfter = preferences.dataInPublicFolder.getFreshValue()
if (dataInPublicFolderBefore != dataInPublicFolderAfter) {
if (dataInPublicFolderAfter) {
enableDataInPublic()
} else {
disableDataInPublic(callback)
}
}
private fun refreshStartView(startView: String, oldStartView: String) {
if (startView in setOf(START_VIEW_DEFAULT, START_VIEW_UNLABELED)) {
savePreference(preferences.startView, startView)
} else {
viewModelScope.launch {
val startViewLabelExists =
withContext(Dispatchers.IO) { labelDao.exists(startView) }
savePreference(
preferences.startView,
if (startViewLabelExists) startView else oldStartView,
)
disableDataInPublic()
}
}
preferences.dataInPublicFolder.refresh()
}
companion object {
private const val TAG = "BaseNoteModel"
const val CURRENT_LABEL_EMPTY = ""
val CURRENT_LABEL_NONE: String? = null
fun transform(
list: List<BaseNote>,
pinned: Header,
others: Header,
archived: Header,
): List<Item> {
fun transform(list: List<BaseNote>, pinned: Header, others: Header): List<Item> {
if (list.isEmpty()) {
return list
} else {
val firstPinnedNote = list.indexOfFirst { baseNote -> baseNote.pinned }
val firstUnpinnedNote =
list.indexOfFirst { baseNote ->
!baseNote.pinned && baseNote.folder != Folder.ARCHIVED
val firstNote = list[0]
return if (firstNote.pinned) {
val newList = ArrayList<Item>(list.size + 2)
newList.add(pinned)
val firstUnpinnedNote = list.indexOfFirst { baseNote -> !baseNote.pinned }
list.forEachIndexed { index, baseNote ->
if (index == firstUnpinnedNote) {
newList.add(others)
}
val mutableList: MutableList<Item> = list.toMutableList()
if (firstPinnedNote != -1) {
mutableList.add(firstPinnedNote, pinned)
if (firstUnpinnedNote != -1) {
mutableList.add(firstUnpinnedNote + 1, others)
newList.add(baseNote)
}
}
val firstArchivedNote =
mutableList.indexOfFirst { item ->
item is BaseNote && item.folder == Folder.ARCHIVED
}
if (firstArchivedNote != -1) {
mutableList.add(firstArchivedNote, archived)
}
return mutableList
newList
} else list
}
}
}

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

View file

@ -29,7 +29,6 @@ class NotallyXPreferences private constructor(private val context: Context) {
}
val theme = createEnumPreference(preferences, "theme", Theme.FOLLOW_SYSTEM, R.string.theme)
val useDynamicColors = BooleanPreference("useDynamicColors", preferences, false)
val textSize =
createEnumPreference(preferences, "textSize", TextSize.MEDIUM, R.string.text_size)
val dateFormat =
@ -39,14 +38,12 @@ class NotallyXPreferences private constructor(private val context: Context) {
val notesView = createEnumPreference(preferences, "view", NotesView.LIST, R.string.view)
val notesSorting = NotesSortPreference(preferences)
val startView =
StringPreference("startView", preferences, START_VIEW_DEFAULT, R.string.start_view)
val listItemSorting =
createEnumPreference(
preferences,
"listItemSorting",
ListItemSort.AUTO_SORT_BY_CHECKED,
R.string.list_item_auto_sort,
"checkedListItemSorting",
ListItemSort.NO_AUTO_SORT,
R.string.checked_list_item_sorting,
)
val maxItems =
@ -76,27 +73,21 @@ class NotallyXPreferences private constructor(private val context: Context) {
10,
R.string.max_lines_to_display_title,
)
val labelsHidden = StringSetPreference("labelsHiddenInNavigation", preferences, setOf())
val labelTagsHiddenInOverview =
val labelsHiddenInNavigation =
StringSetPreference("labelsHiddenInNavigation", preferences, setOf())
val labelsHiddenInOverview =
BooleanPreference(
"labelsHiddenInOverview",
preferences,
false,
R.string.labels_hidden_in_overview_title,
)
val imagesHiddenInOverview =
BooleanPreference(
"imagesHiddenInOverview",
preferences,
false,
R.string.images_hidden_in_overview_title,
)
val maxLabels =
IntPreference(
"maxLabelsInNavigation",
preferences,
5,
0,
1,
20,
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 =
createEnumPreference(
preferences,
@ -142,8 +123,6 @@ class NotallyXPreferences private constructor(private val context: Context) {
val fallbackDatabaseEncryptionKey by lazy {
ByteArrayPreference("fallback_database_encryption_key", encryptedPreferences, ByteArray(0))
}
val secureFlag =
BooleanPreference("secureFlag", preferences, false, R.string.disallow_screenshots)
val dataInPublicFolder =
BooleanPreference("dataOnExternalStorage", preferences, false, R.string.data_in_public)
@ -212,17 +191,15 @@ class NotallyXPreferences private constructor(private val context: Context) {
context.importPreferences(uri, preferences.edit()).also { reload() }
fun reset() {
preferences.edit().clear().commit()
preferences.edit().clear().apply()
encryptedPreferences.edit().clear().apply()
backupsFolder.refresh()
dataInPublicFolder.refresh()
theme.refresh()
reload()
startView.refresh()
}
private fun reload() {
setOf(
theme,
textSize,
dateFormat,
applyDateFormatInNoteView,
@ -232,15 +209,12 @@ class NotallyXPreferences private constructor(private val context: Context) {
maxItems,
maxLines,
maxTitle,
secureFlag,
labelsHidden,
labelTagsHiddenInOverview,
labelsHiddenInNavigation,
labelsHiddenInOverview,
maxLabels,
periodicBackups,
backupPassword,
backupOnSave,
autoSaveAfterIdleTime,
imagesHiddenInOverview,
biometricLock,
)
.forEach { it.refresh() }
}
@ -248,8 +222,6 @@ class NotallyXPreferences private constructor(private val context: Context) {
companion object {
private const val TAG = "NotallyXPreferences"
const val EMPTY_PATH = "emptyPath"
const val START_VIEW_DEFAULT = ""
const val START_VIEW_UNLABELED = "com.philkes.notallyx.startview.UNLABELED"
@Volatile private var instance: NotallyXPreferences? = null
@ -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) {
TITLE(R.string.title, R.drawable.sort_by_alpha, "autoSortByTitle"),
CREATION_DATE(R.string.creation_date, R.drawable.calendar_add_on, "autoSortByCreationDate"),
MODIFIED_DATE(R.string.modified_date, R.drawable.edit_calendar, "autoSortByModifiedDate"),
COLOR(R.string.color, R.drawable.change_color, "autoSortByColor");
MODIFIED_DATE(R.string.modified_date, R.drawable.edit_calendar, "autoSortByModifiedDate");
companion object {
fun fromValue(value: String): NotesSortBy? {

View file

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

View file

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

View file

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

View file

@ -7,7 +7,6 @@ import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
class ActionMode {
val enabled = NotNullLiveData(false)
val loading = NotNullLiveData(false)
val count = NotNullLiveData(0)
val selectedNotes = HashMap<Long, BaseNote>()
val selectedIds = selectedNotes.keys
@ -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
// We assume selectedNotes.size is 1
fun getFirstNote() = selectedNotes.values.first()
fun isEmpty() = selectedNotes.values.isEmpty()
}

View file

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

View file

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

View file

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

View file

@ -103,14 +103,6 @@ fun File.toRelativePathFrom(baseFolderName: String): String {
return relativePath.trimStart(File.separatorChar)
}
fun File.recreateDir(): File {
if (exists()) {
deleteRecursively()
}
mkdirs()
return this
}
fun ContextWrapper.deleteAttachments(
attachments: Collection<Attachment>,
ids: LongArray? = null,
@ -149,7 +141,7 @@ fun Context.getExportedPath() = getEmptyFolder("exported")
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 {
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
import android.content.Context
import android.content.ContextWrapper
import androidx.work.Worker
import androidx.work.WorkerParameters
@ -9,6 +8,6 @@ class AutoBackupWorker(private val context: Context, params: WorkerParameters) :
Worker(context, params) {
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.printPdf
import android.util.Log
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.core.app.NotificationCompat
import androidx.core.content.getSystemService
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.lifecycleScope
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ListenableWorker.Result
@ -36,9 +33,7 @@ import com.philkes.notallyx.data.model.FileAttachment
import com.philkes.notallyx.data.model.toHtml
import com.philkes.notallyx.data.model.toJson
import com.philkes.notallyx.data.model.toTxt
import com.philkes.notallyx.presentation.activity.LockedActivity
import com.philkes.notallyx.presentation.activity.main.MainActivity
import com.philkes.notallyx.presentation.view.misc.MenuDialog
import com.philkes.notallyx.presentation.view.misc.Progress
import com.philkes.notallyx.presentation.viewmodel.BackupFile
import com.philkes.notallyx.presentation.viewmodel.ExportMimeType
@ -56,16 +51,12 @@ import com.philkes.notallyx.utils.getExportedPath
import com.philkes.notallyx.utils.getExternalAudioDirectory
import com.philkes.notallyx.utils.getExternalFilesDirectory
import com.philkes.notallyx.utils.getExternalImagesDirectory
import com.philkes.notallyx.utils.getUriForFile
import com.philkes.notallyx.utils.listZipFiles
import com.philkes.notallyx.utils.log
import com.philkes.notallyx.utils.logToFile
import com.philkes.notallyx.utils.nameWithoutExtension
import com.philkes.notallyx.utils.recreateDir
import com.philkes.notallyx.utils.removeTrailingParentheses
import com.philkes.notallyx.utils.security.decryptDatabase
import com.philkes.notallyx.utils.security.getInitializedCipherForDecryption
import com.philkes.notallyx.utils.wrapWithChooser
import java.io.File
import java.io.FileInputStream
import java.io.IOException
@ -78,7 +69,6 @@ import java.util.Locale
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.exception.ZipException
@ -98,7 +88,7 @@ const val OUTPUT_DATA_EXCEPTION = "exception"
private const val ON_SAVE_BACKUP_FILE = "NotallyX_AutoBackup.zip"
private const val PERIODIC_BACKUP_FILE_PREFIX = "NotallyX_Backup_"
fun ContextWrapper.createBackup(): Result {
fun Context.createBackup(): Result {
val app = applicationContext as Application
val preferences = NotallyXPreferences.getInstance(app)
val (_, maxBackups) = preferences.periodicBackups.value
@ -106,11 +96,7 @@ fun ContextWrapper.createBackup(): Result {
if (path != EMPTY_PATH) {
val uri = Uri.parse(path)
val folder =
requireBackupFolder(
path,
"Periodic Backup failed, because auto-backup path '$path' is invalid",
) ?: return Result.success()
val folder = requireNotNull(DocumentFile.fromTreeUri(app, uri))
fun log(msg: String? = null, throwable: Throwable? = null, stackTrace: String? = null) {
logToFile(
TAG,
@ -121,13 +107,16 @@ fun ContextWrapper.createBackup(): Result {
stackTrace = stackTrace,
)
}
try {
if (folder.exists()) {
val formatter = SimpleDateFormat("yyyyMMdd-HHmmssSSS", Locale.ENGLISH)
val backupFilePrefix = PERIODIC_BACKUP_FILE_PREFIX
val name = "$backupFilePrefix${formatter.format(System.currentTimeMillis())}.zip"
log(msg = "Creating '$uri/$name'...")
val name = "$backupFilePrefix${formatter.format(System.currentTimeMillis())}"
log(msg = "Creating '$uri/$name.zip'...")
try {
val zipUri = requireNotNull(folder.createFile(MIME_TYPE_ZIP, name)).uri
val exportedNotes = app.exportAsZip(zipUri, password = preferences.backupPassword.value)
val exportedNotes =
app.exportAsZip(zipUri, password = preferences.backupPassword.value)
log(msg = "Exported $exportedNotes notes")
val backupFiles = folder.listZipFiles(backupFilePrefix)
log(msg = "Found ${backupFiles.size} backups")
@ -149,32 +138,48 @@ fun ContextWrapper.createBackup(): Result {
Data.Builder().putString(OUTPUT_DATA_BACKUP_URI, zipUri.path!!).build()
)
} catch (e: Exception) {
log(msg = "Failed creating backup to '$path'", throwable = e)
tryPostErrorNotification(e)
log(msg = "Failed creating backup to '$uri/$name'", throwable = e)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
) {
postErrorNotification(e)
}
} else {
postErrorNotification(e)
}
return Result.success(
Data.Builder().putString(OUTPUT_DATA_EXCEPTION, e.message).build()
)
}
} else {
log(msg = "Folder '${folder.uri}' does not exist, therefore skipping auto-backup")
}
}
return Result.success()
}
fun ContextWrapper.autoBackupOnSave(backupPath: String, password: String, savedNote: BaseNote?) {
val folder =
requireBackupFolder(
backupPath,
"Auto backup on note save (${savedNote?.let { "id: '${savedNote.id}, title: '${savedNote.title}'" }}) failed, because auto-backup path '$backupPath' is invalid",
) ?: return
fun log(msg: String? = null, throwable: Throwable? = null) {
logToFile(TAG, folder, NOTALLYX_BACKUP_LOGS_FILE, msg = msg, throwable = throwable)
val backupFolder =
try {
DocumentFile.fromTreeUri(this, backupPath.toUri())!!
} catch (e: Exception) {
log(
TAG,
msg =
"Auto backup on note save (${savedNote?.let {"id: '${savedNote.id}, title: '${savedNote.title}'" }}) failed, because auto-backup path is invalid",
throwable = e,
)
return
}
try {
var backupFile = folder.findFile(ON_SAVE_BACKUP_FILE)
var backupFile = backupFolder.findFile(ON_SAVE_BACKUP_FILE)
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)
} else {
val (_, file) = copyDatabase()
NotallyDatabase.getDatabase(this, observePreferences = false).value.checkpoint()
val files =
with(savedNote) {
images.map {
@ -192,45 +197,26 @@ fun ContextWrapper.autoBackupOnSave(backupPath: String, password: String, savedN
audios.map {
BackupFile(SUBFOLDER_AUDIOS, File(getExternalAudioDirectory(), it.name))
} +
BackupFile(null, file)
BackupFile(
null,
NotallyDatabase.getCurrentDatabaseFile(this@autoBackupOnSave),
)
}
try {
exportToZip(backupFile.uri, files, password)
} catch (e: ZipException) {
log(
}
} catch (e: Exception) {
logToFile(
TAG,
backupFolder,
NOTALLYX_BACKUP_LOGS_FILE,
msg =
"Re-creating full backup since existing auto backup ZIP is corrupt: ${e.message}"
)
backupFile.delete()
autoBackupOnSave(backupPath, password, savedNote)
}
}
} catch (e: Exception) {
log(
"Auto backup on note save (${savedNote?.let {"id: '${savedNote.id}, title: '${savedNote.title}'" }}) failed",
e,
throwable = e,
)
tryPostErrorNotification(e)
}
}
private fun ContextWrapper.requireBackupFolder(path: String, msg: String): DocumentFile? {
return try {
val folder = DocumentFile.fromTreeUri(this, path.toUri())!!
if (!folder.exists()) {
log(TAG, msg = msg)
tryPostErrorNotification(IllegalArgumentException("Folder '$path' does not exist"))
return null
}
folder
} catch (e: Exception) {
log(TAG, msg = msg, throwable = e)
tryPostErrorNotification(IllegalArgumentException("Folder '$path' does not exist", e))
return null
}
}
suspend fun ContextWrapper.checkBackupOnSave(
fun ContextWrapper.checkAutoSave(
preferences: NotallyXPreferences,
note: BaseNote? = null,
forceFullBackup: Boolean = false,
@ -241,12 +227,10 @@ suspend fun ContextWrapper.checkBackupOnSave(
if (forceFullBackup) {
deleteModifiedNoteBackup(backupPath)
}
withContext(Dispatchers.IO) {
autoBackupOnSave(backupPath, preferences.backupPassword.value, note)
}
}
}
}
fun ContextWrapper.deleteModifiedNoteBackup(backupPath: String) {
DocumentFile.fromTreeUri(this, backupPath.toUri())?.findFile(ON_SAVE_BACKUP_FILE)?.delete()
@ -265,8 +249,8 @@ fun ContextWrapper.exportAsZip(
backupProgress: MutableLiveData<Progress>? = null,
): Int {
backupProgress?.postValue(Progress(indeterminate = true))
val tempFile = File.createTempFile("export", "tmp", cacheDir)
try {
val zipFile =
ZipFile(tempFile, if (password != PASSWORD_EMPTY) password.toCharArray() else null)
val zipParameters =
@ -337,9 +321,6 @@ fun ContextWrapper.exportAsZip(
}
backupProgress?.postValue(Progress(inProgress = false))
return totalNotes
} finally {
tempFile.delete()
}
}
fun Context.exportToZip(
@ -347,8 +328,7 @@ fun Context.exportToZip(
files: List<BackupFile>,
password: String = PASSWORD_EMPTY,
): Boolean {
val tempDir = File(cacheDir, "export").recreateDir()
try {
val tempDir = File(cacheDir, "tempZip").apply { mkdirs() }
val zipInputStream = contentResolver.openInputStream(zipUri) ?: return false
extractZipToDirectory(zipInputStream, tempDir, password)
files.forEach { file ->
@ -357,21 +337,20 @@ fun Context.exportToZip(
}
val zipOutputStream = contentResolver.openOutputStream(zipUri, "w") ?: return false
createZipFromDirectory(tempDir, zipOutputStream, password)
} finally {
tempDir.deleteRecursively()
}
return true
}
private fun extractZipToDirectory(zipInputStream: InputStream, outputDir: File, password: String) {
val tempZipFile = File.createTempFile("extractedZip", null, outputDir)
try {
val tempZipFile = File.createTempFile("tempZip", ".zip", outputDir)
tempZipFile.outputStream().use { zipOutputStream -> zipInputStream.copyTo(zipOutputStream) }
val zipFile =
ZipFile(tempZipFile, if (password != PASSWORD_EMPTY) password.toCharArray() else null)
zipFile.extractAll(outputDir.absolutePath)
} finally {
tempZipFile.delete()
} catch (e: ZipException) {
e.printStackTrace()
}
}
@ -381,9 +360,10 @@ private fun createZipFromDirectory(
password: String = PASSWORD_EMPTY,
compress: Boolean = false,
) {
val tempZipFile = File.createTempFile("tempZip", ".zip")
try {
val tempZipFile = File.createTempFile("tempZip", ".zip")
tempZipFile.deleteOnExit()
val zipFile =
ZipFile(tempZipFile, if (password != PASSWORD_EMPTY) password.toCharArray() else null)
val zipParameters =
@ -397,8 +377,9 @@ private fun createZipFromDirectory(
}
zipFile.addFolder(sourceDir, zipParameters)
tempZipFile.inputStream().use { inputStream -> inputStream.copyTo(zipOutputStream) }
} finally {
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
database.checkpoint()
val preferences = NotallyXPreferences.getInstance(this)
val databaseFile = NotallyDatabase.getCurrentDatabaseFile(this)
return if (
preferences.biometricLock.value == BiometricLock.ENABLED &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
@ -414,11 +394,16 @@ fun ContextWrapper.copyDatabase(): Pair<NotallyDatabase, File> {
val cipher = getInitializedCipherForDecryption(iv = preferences.iv.value!!)
val passphrase = cipher.doFinal(preferences.databaseEncryptionKey.value)
val decryptedFile = File(cacheDir, DATABASE_NAME)
decryptDatabase(this, passphrase, databaseFile, decryptedFile)
decryptDatabase(
this,
passphrase,
decryptedFile,
NotallyDatabase.getCurrentDatabaseName(this),
)
Pair(database, decryptedFile)
} else {
val dbFile = File(cacheDir, DATABASE_NAME)
databaseFile.copyTo(dbFile, overwrite = true)
NotallyDatabase.getCurrentDatabaseFile(this).copyTo(dbFile, overwrite = true)
Pair(database, dbFile)
}
}
@ -504,7 +489,7 @@ private fun ZipParameters.copy(fileNameInZip: String? = this.fileNameInZip): Zip
}
fun exportPdfFile(
app: Context,
app: Application,
note: BaseNote,
folder: DocumentFile,
fileName: String = note.title,
@ -514,14 +499,13 @@ fun exportPdfFile(
total: Int? = null,
duplicateFileCount: Int = 1,
) {
val validFileName = fileName.ifBlank { app.getString(R.string.note) }
val filePath = "$validFileName.${ExportMimeType.PDF.fileExtension}"
val filePath = "$fileName.${ExportMimeType.PDF.fileExtension}"
if (folder.findFile(filePath)?.exists() == true) {
return exportPdfFile(
app,
note,
folder,
"${validFileName.removeTrailingParentheses()} ($duplicateFileCount)",
"${fileName.removeTrailingParentheses()} ($duplicateFileCount)",
pdfPrintListener,
progress,
counter,
@ -555,7 +539,7 @@ fun exportPdfFile(
}
suspend fun exportPlainTextFile(
app: Context,
app: Application,
note: BaseNote,
exportType: ExportMimeType,
folder: DocumentFile,
@ -579,9 +563,8 @@ suspend fun exportPlainTextFile(
)
}
return withContext(Dispatchers.IO) {
val validFileName = fileName.takeIf { it.isNotBlank() } ?: app.getString(R.string.note)
val file =
folder.createFile(exportType.mimeType, validFileName)?.let {
folder.createFile(exportType.mimeType, fileName)?.let {
app.contentResolver.openOutputStream(it.uri)?.use { stream ->
OutputStreamWriter(stream).use { writer ->
writer.write(
@ -623,8 +606,7 @@ fun Context.exportPreferences(preferences: NotallyXPreferences, uri: Uri): Boole
}
}
private fun Context.tryPostErrorNotification(e: Throwable) {
fun postErrorNotification(e: Throwable) {
private fun Context.postErrorNotification(e: Throwable) {
getSystemService<NotificationManager>()?.let { manager ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
manager.createChannelIfNotExists(NOTIFICATION_CHANNEL_ID)
@ -673,116 +655,3 @@ private fun Context.tryPostErrorNotification(e: Throwable) {
manager.notify(NOTIFICATION_ID, notification)
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
) {
postErrorNotification(e)
}
} else {
postErrorNotification(e)
}
}
fun LockedActivity<*>.exportNotes(
mimeType: ExportMimeType,
notes: Collection<BaseNote>,
saveFileResultLauncher: ActivityResultLauncher<Intent>,
exportToFolderResultLauncher: ActivityResultLauncher<Intent>,
) {
baseModel.selectedExportMimeType = mimeType
if (notes.size == 1) {
val baseNote = notes.first()
when (mimeType) {
ExportMimeType.PDF -> {
exportPdfFile(
this,
baseNote,
DocumentFile.fromFile(getExportedPath()),
pdfPrintListener =
object : PdfPrintListener {
override fun onSuccess(file: DocumentFile) {
showFileOptionsDialog(
file,
ExportMimeType.PDF.mimeType,
saveFileResultLauncher,
)
}
override fun onFailure(message: CharSequence?) {
Toast.makeText(
this@exportNotes,
R.string.something_went_wrong,
Toast.LENGTH_SHORT,
)
.show()
}
},
)
}
ExportMimeType.TXT,
ExportMimeType.JSON,
ExportMimeType.HTML ->
lifecycleScope.launch {
exportPlainTextFile(
this@exportNotes,
baseNote,
mimeType,
DocumentFile.fromFile(getExportedPath()),
)
?.let {
showFileOptionsDialog(it, mimeType.mimeType, saveFileResultLauncher)
}
}
}
} else {
lifecycleScope.launch {
val intent =
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
.apply { addCategory(Intent.CATEGORY_DEFAULT) }
.wrapWithChooser(this@exportNotes)
exportToFolderResultLauncher.launch(intent)
}
}
}
private fun LockedActivity<*>.showFileOptionsDialog(
file: DocumentFile,
mimeType: String,
resultLauncher: ActivityResultLauncher<Intent>,
) {
MenuDialog(this)
.add(R.string.view_file) { viewFile(getUriForFile(File(file.uri.path!!)), mimeType) }
.add(R.string.save_to_device) { saveFileToDevice(file, mimeType, resultLauncher) }
.show()
}
private fun LockedActivity<*>.viewFile(uri: Uri, mimeType: String) {
val intent =
Intent(Intent.ACTION_VIEW)
.apply {
setDataAndType(uri, mimeType)
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
}
.wrapWithChooser(this)
startActivity(intent)
}
private fun LockedActivity<*>.saveFileToDevice(
file: DocumentFile,
mimeType: String,
resultLauncher: ActivityResultLauncher<Intent>,
) {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT)
.apply {
type = mimeType
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_TITLE, file.nameWithoutExtension!!)
}
.wrapWithChooser(this)
baseModel.selectedExportFile = file
resultLauncher.launch(intent)
}

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