├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── buildbot.yml │ └── dependencies.yml ├── .gitignore ├── .idea ├── .name ├── codeInsightSettings.xml ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── compiler.xml ├── detekt.xml ├── encodings.xml ├── gradle.xml ├── inspectionProfiles │ └── Project_Default.xml ├── misc.xml ├── runConfigurations │ └── Run_static_analysis.xml └── vcs.xml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── app ├── build.gradle.kts ├── schemas │ └── dev.sebastiano.bundel.storage.RobertoDatabase │ │ ├── 1.json │ │ ├── 2.json │ │ └── 3.json └── src │ ├── androidTest │ ├── AndroidManifest.xml │ └── kotlin │ │ └── dev │ │ └── sebastiano │ │ └── bundel │ │ ├── FakePreferences.kt │ │ ├── RobertoFoldTest.kt │ │ └── onboarding │ │ ├── OnboardingAndroidUiTest.kt │ │ ├── PrepareDevicePermissionsRule.kt │ │ └── PrepareTestStorageRule.kt │ ├── debug │ ├── AndroidManifest.xml │ ├── java │ │ └── dev │ │ │ └── sebastiano │ │ │ └── bundel │ │ │ └── glance │ │ │ └── GlanceDebugActivity.kt │ └── res │ │ └── values │ │ └── strings.xml │ ├── main │ ├── AndroidManifest.xml │ ├── assets │ │ └── licences.json │ ├── ic_bundel_launcher-playstore.png │ ├── java │ │ └── dev │ │ │ └── sebastiano │ │ │ └── bundel │ │ │ ├── ApplicationModule.kt │ │ │ ├── BundelApplication.kt │ │ │ ├── DispatchersModule.kt │ │ │ ├── MainActivity.kt │ │ │ ├── MainScreenWithBottomNav.kt │ │ │ ├── glance │ │ │ ├── BundelAppWidgetReceiver.kt │ │ │ ├── CannoliWidget.kt │ │ │ └── TestWidgetScreen.kt │ │ │ ├── history │ │ │ └── HistoryScreen.kt │ │ │ ├── navigation │ │ │ ├── NavigationRoute.kt │ │ │ └── TopLevelNavigation.kt │ │ │ ├── notifications │ │ │ ├── ActiveNotification.kt │ │ │ ├── BundelNotificationListenerService.kt │ │ │ ├── NotificationServiceHelpers.kt │ │ │ ├── PersistableNotification.kt │ │ │ └── StatusBarNotificationExtensions.kt │ │ │ ├── notificationslist │ │ │ ├── NotificationItem.kt │ │ │ ├── NotificationsListEmptyState.kt │ │ │ └── NotificationsListScreen.kt │ │ │ ├── onboarding │ │ │ ├── DaysSchedulePage.kt │ │ │ ├── DaysSchedulePageState.kt │ │ │ ├── HoursSchedulePageState.kt │ │ │ ├── IntroPage.kt │ │ │ ├── NotificationsAccessPageState.kt │ │ │ ├── OnboardingScreen.kt │ │ │ ├── OnboardingViewModel.kt │ │ │ └── SimplePageIndicator.kt │ │ │ ├── schedule │ │ │ └── ScheduleChecker.kt │ │ │ ├── storage │ │ │ ├── Dao.kt │ │ │ ├── DataRepository.kt │ │ │ ├── DatabaseModule.kt │ │ │ ├── DiskImagesStorage.kt │ │ │ ├── ImagesStorage.kt │ │ │ ├── RobertoDatabase.kt │ │ │ ├── migrations │ │ │ │ └── Migration1to2.kt │ │ │ └── model │ │ │ │ └── DbNotification.kt │ │ │ ├── ui │ │ │ └── NavTransitions.kt │ │ │ └── util │ │ │ ├── CurrentOrientation.kt │ │ │ ├── IconExtensions.kt │ │ │ ├── KotlinExt.kt │ │ │ └── Orientation.kt │ └── res │ │ ├── drawable │ │ ├── ic_bundel_launcher_foreground.xml │ │ ├── ic_round_settings_24.xml │ │ └── misaligned_floor.xml │ │ ├── mipmap-anydpi │ │ └── ic_bundel_launcher.xml │ │ ├── values-night │ │ └── colors.xml │ │ ├── values │ │ ├── colors.xml │ │ ├── ic_bundel_launcher_background.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── app_widget_info.xml │ │ ├── data_extraction_rules.xml │ │ └── full_backup_rules.xml │ └── test │ └── kotlin │ └── dev │ └── sebastiano │ └── bundel │ ├── onboarding │ ├── DefaultsFakePreferences.kt │ └── OnboardingViewModelTest.kt │ └── schedule │ └── ScheduleCheckerTest.kt ├── art ├── CWI-logo-horizontal-512.png ├── CWI-logo-horizontal.svg ├── bundel_icon.svg ├── logo_horiz.png ├── logo_horiz@2x.png ├── logo_vert.png └── logo_vert@2x.png ├── build-config ├── detekt.yml ├── dummy-data │ └── dummy-google-services.json └── lint.xml ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── preferences ├── build.gradle.kts ├── consumer-rules.pro └── src │ ├── androidTest │ └── kotlin │ │ └── dev │ │ └── sebastiano │ │ └── bundel │ │ └── preferences │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── kotlin │ │ └── dev │ │ │ └── sebastiano │ │ │ └── bundel │ │ │ └── preferences │ │ │ ├── ActiveDaysViewModel.kt │ │ │ ├── ActiveTimeRangesViewModel.kt │ │ │ ├── AppFilterInfo.kt │ │ │ ├── AppInfo.kt │ │ │ ├── AppsListScreen.kt │ │ │ ├── BundelPreferencesSerializer.kt │ │ │ ├── DataStorePreferences.kt │ │ │ ├── DebugPreferencesViewModel.kt │ │ │ ├── DependenciesModel.kt │ │ │ ├── ExcludedAppsViewModel.kt │ │ │ ├── Lce.kt │ │ │ ├── LicensesPreferencesViewModel.kt │ │ │ ├── LicensesScreen.kt │ │ │ ├── Preferences.kt │ │ │ ├── PreferencesModule.kt │ │ │ ├── PreferencesScreen.kt │ │ │ ├── SelectDaysDialog.kt │ │ │ ├── SelectTimeRangesDialog.kt │ │ │ ├── SharedPrefsMigration.kt │ │ │ ├── WinteryEasterEggViewModel.kt │ │ │ └── schedule │ │ │ ├── DaysScheduleSerializer.kt │ │ │ ├── HoursScheduleSerializer.kt │ │ │ └── TimeRangesSchedule.kt │ ├── proto │ │ └── dev │ │ │ └── sebastiano │ │ │ └── bundel │ │ │ └── protos │ │ │ └── BundelPreferences.proto │ └── res │ │ ├── drawable │ │ └── ic_back_24.xml │ │ └── values │ │ └── strings.xml │ └── test │ └── kotlin │ └── dev │ └── sebastiano │ └── bundel │ └── preferences │ └── schedule │ ├── DaysScheduleSerializerTest.kt │ ├── TimePickerModelTest.kt │ ├── TimeRangeTest.kt │ └── TimeRangesScheduleTest.kt ├── settings.gradle.kts ├── shared-ui ├── build.gradle.kts ├── consumer-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── kotlin │ └── dev │ │ └── sebastiano │ │ └── bundel │ │ └── ui │ │ ├── Colors.kt │ │ ├── Dimens.kt │ │ ├── System.kt │ │ ├── Theme.kt │ │ ├── Typography.kt │ │ ├── composables │ │ ├── DaysPicker.kt │ │ ├── MaterialChip.kt │ │ ├── TimePickerModel.kt │ │ ├── TimeRange.kt │ │ ├── TimeRangeRow.kt │ │ └── WeekDay.kt │ │ ├── modifiers │ │ ├── Modifiers.kt │ │ ├── overlay │ │ │ ├── AnimatedOverlay.kt │ │ │ ├── OverlayModifier.kt │ │ │ └── StrikethroughOverlay.kt │ │ └── snowfall │ │ │ └── SnowfallModifier.kt │ │ └── resources │ │ └── Resources.kt │ └── res │ ├── drawable-xhdpi │ └── outline_interests_black_48dp.png │ ├── drawable │ ├── ic_android_24.xml │ ├── ic_bundel_icon.xml │ ├── ic_default_icon.xml │ ├── snowflake01.xml │ ├── snowflake02.xml │ ├── snowflake03.xml │ └── snowflake04.xml │ ├── font │ ├── inter_bold.ttf │ ├── inter_medium.ttf │ ├── inter_regular.ttf │ ├── podkova_bold.ttf │ ├── podkova_extrabold.ttf │ ├── podkova_medium.ttf │ ├── podkova_regular.ttf │ └── podkova_semibold.ttf │ └── values │ └── strings.xml └── third-party-notices.md /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | max_line_length = 150 11 | tab_width = 4 12 | 13 | [{*.html,*.js,*.css}] 14 | indent_size = 2 15 | 16 | [*.yml] 17 | indent_size = 2 18 | 19 | [{*.kt,*.kts}] 20 | ktlint_function_signature_body_expression_wrapping = multiline 21 | ktlint_ignore_back_ticked_identifier = true 22 | 23 | [gradlew.bat] 24 | end_of_line = crlf 25 | 26 | [{*.bash,*.sh,*.zsh}] 27 | indent_size = 2 28 | tab_width = 2 29 | 30 | [*.md] 31 | indent_size = 2 32 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [rock3r, hamen] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/buildbot.yml: -------------------------------------------------------------------------------- 1 | name: buildbot 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | 8 | jobs: 9 | check: 10 | name: Static analysis 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v3 16 | 17 | - name: Set up JDK 17 18 | uses: actions/setup-java@v3 19 | with: 20 | java-version: 17 21 | distribution: corretto 22 | 23 | - uses: android-actions/setup-android@v2 24 | name: Set up Android SDK 25 | 26 | - name: Check for dependency updates 27 | uses: gradle/gradle-command-action@v2 28 | with: 29 | arguments: app:staticAnalysis app:collectSarifReports --continue 30 | 31 | - name: Upload SARIF to Github using the upload-sarif action 32 | uses: github/codeql-action/upload-sarif@v2 33 | if: ${{ always() }} 34 | with: 35 | sarif_file: app/build/reports/sarif/ 36 | 37 | test: 38 | name: Run unit tests 39 | runs-on: ubuntu-latest 40 | 41 | steps: 42 | - name: Checkout code 43 | uses: actions/checkout@v3 44 | 45 | - name: Set up JDK 17 46 | uses: actions/setup-java@v3 47 | with: 48 | java-version: 17 49 | distribution: corretto 50 | 51 | - uses: android-actions/setup-android@v2 52 | name: Set up Android SDK 53 | 54 | - name: Run tests with Gradle 55 | uses: gradle/gradle-command-action@v2 56 | with: 57 | arguments: test --continue 58 | -------------------------------------------------------------------------------- /.github/workflows/dependencies.yml: -------------------------------------------------------------------------------- 1 | name: dependency updates 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | # Every Thursday at 4:32 (note scheduled jobs might be delayed or skipped) 7 | - cron: '32 4 * * 4' 8 | 9 | jobs: 10 | check: 11 | name: Check for updated dependencies 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v2 17 | with: 18 | ref: main 19 | 20 | - name: Set up JDK 17 21 | uses: actions/setup-java@v3 22 | with: 23 | java-version: 17 24 | distribution: corretto 25 | 26 | - uses: android-actions/setup-android@v2 27 | name: Set up Android SDK 28 | 29 | - name: Check for dependency updates 30 | uses: gradle/gradle-command-action@v2 31 | with: 32 | arguments: versionCatalogUpdate 33 | 34 | # Prevent the change to gradlew to be included in the PR 35 | - name: Revert gradlew change 36 | run: git checkout gradlew 37 | 38 | - name: Create Pull Request 39 | uses: peter-evans/create-pull-request@v3 40 | with: 41 | commit-message: Dependency updates 42 | delete-branch: true 43 | branch: catalog-dependency-updates 44 | title: Dependency updates 45 | body: Here are some suggested updates to dependencies :) 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Seb's .gitignore template 2 | # You can find the most up-to-date version at https://go.sebastiano.dev/gitignore 3 | # Partly based on templates by https://plugins.jetbrains.com/plugin/7495--ignore 4 | 5 | ### Windows template 6 | # Windows thumbnail cache files 7 | Thumbs.db 8 | Thumbs.db:encryptable 9 | ehthumbs.db 10 | ehthumbs_vista.db 11 | 12 | # Dump file 13 | *.stackdump 14 | 15 | # Folder config file 16 | [Dd]esktop.ini 17 | 18 | # Recycle Bin used on file shares 19 | $RECYCLE.BIN/ 20 | 21 | # Windows Installer files 22 | *.cab 23 | *.msi 24 | *.msix 25 | *.msm 26 | *.msp 27 | 28 | # Windows shortcuts 29 | *.lnk 30 | 31 | ### macOS template 32 | # General 33 | .DS_Store 34 | .AppleDouble 35 | .LSOverride 36 | 37 | # Icon must end with two \r 38 | Icon 39 | 40 | # Thumbnails 41 | ._* 42 | 43 | # Files that might appear in the root of a volume 44 | .DocumentRevisions-V100 45 | .fseventsd 46 | .Spotlight-V100 47 | .TemporaryItems 48 | .Trashes 49 | .VolumeIcon.icns 50 | .com.apple.timemachine.donotpresent 51 | 52 | # Directories potentially created on remote AFP share 53 | .AppleDB 54 | .AppleDesktop 55 | Network Trash Folder 56 | Temporary Items 57 | .apdisk 58 | 59 | ### Linux template 60 | *~ 61 | 62 | # temporary files which can be created if a process still has a handle open of a deleted file 63 | .fuse_hidden* 64 | 65 | # KDE directory preferences 66 | .directory 67 | 68 | # Linux trash folder which might appear on any partition or disk 69 | .Trash-* 70 | 71 | # .nfs files are created when an open file is removed but is still being accessed 72 | .nfs* 73 | 74 | ### JetBrains template 75 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 76 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 77 | 78 | *.iml 79 | *.ipr 80 | *.iws 81 | /.idea/* 82 | 83 | # Exclude non-user-specific stuff 84 | !.idea/.name 85 | !.idea/codeInsightSettings.xml 86 | !.idea/codeStyles/ 87 | !.idea/copyright/ 88 | !.idea/dataSources.xml 89 | !.idea/detekt.xml 90 | !.idea/encodings.xml 91 | !.idea/externalDependencies.xml 92 | !.idea/file.template.settings.xml 93 | !.idea/fileTemplates/ 94 | !.idea/icon.svg 95 | !.idea/inspectionProfiles/ 96 | !.idea/runConfigurations/ 97 | !.idea/scopes/ 98 | !.idea/vcs.xml 99 | 100 | ### Kotlin template 101 | # Compiled class file 102 | *.class 103 | 104 | # Log file 105 | *.log 106 | 107 | # Package Files # 108 | *.jar 109 | *.war 110 | *.nar 111 | *.ear 112 | *.zip 113 | *.tar.gz 114 | *.rar 115 | 116 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 117 | hs_err_pid* 118 | 119 | ### Gradle template 120 | .gradle 121 | build/ 122 | 123 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 124 | !gradle/wrapper/gradle-wrapper.jar 125 | 126 | # Cache of project 127 | .gradletasknamecache 128 | 129 | ### Android-specific stuff 130 | # Built application files 131 | *.apk 132 | *.ap_ 133 | *.aab 134 | *.apks 135 | 136 | # Files for the Dalvik VM 137 | *.dex 138 | 139 | # Generated files 140 | bin/ 141 | gen/ 142 | 143 | # Local configuration file (sdk path, etc) 144 | local.properties 145 | 146 | # Google/Firebase secrets 147 | google-services.json 148 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | Bundel -------------------------------------------------------------------------------- /.idea/codeInsightSettings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | android.app.LauncherActivity.ListItem 6 | android.graphics.Color 7 | android.graphics.drawable.Icon 8 | android.graphics.fonts.FontFamily 9 | android.inputmethodservice.Keyboard.Row 10 | android.text.layout.Alignment 11 | android.view.RoundedCorner 12 | android.view.Surface 13 | android.widget.GridLayout.Alignment 14 | java.lang.reflect.Modifier 15 | java.nio.file.WatchEvent.Modifier 16 | java.time.format.TextStyle 17 | java.util.Observable 18 | org.w3c.dom.Text 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/detekt.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 14 | 17 | 18 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 20 | 21 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 11 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Run_static_analysis.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 16 | 18 | true 19 | true 20 | false 21 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | This code is provided as-is. We'll fix issues as quickly as possible, but no 6 | warranty of any kind is provided that the project is problem-free. Please do 7 | not use this software in security-critical situations without a proper 8 | analysis and vetting of its sources first from a security standpoint — we 9 | have not done it. 10 | 11 | ## Reporting a Vulnerability 12 | 13 | If you find a vulnerability, please report it as an issue. We'll address it 14 | as soon as we can. 15 | -------------------------------------------------------------------------------- /app/schemas/dev.sebastiano.bundel.storage.RobertoDatabase/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 1, 5 | "identityHash": "a6b8eccdab8a0770ff18215822747ac5", 6 | "entities": [ 7 | { 8 | "tableName": "notifications", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notification_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `showTimestamp` INTEGER NOT NULL, `isGroup` INTEGER NOT NULL, `text` TEXT, `title` TEXT, `subText` TEXT, `titleBig` TEXT, `app_package` TEXT NOT NULL, PRIMARY KEY(`notification_id`))", 10 | "fields": [ 11 | { 12 | "fieldPath": "id", 13 | "columnName": "notification_id", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "uniqueId", 19 | "columnName": "uid", 20 | "affinity": "TEXT", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "timestamp", 25 | "columnName": "timestamp", 26 | "affinity": "INTEGER", 27 | "notNull": true 28 | }, 29 | { 30 | "fieldPath": "showTimestamp", 31 | "columnName": "showTimestamp", 32 | "affinity": "INTEGER", 33 | "notNull": true 34 | }, 35 | { 36 | "fieldPath": "isGroup", 37 | "columnName": "isGroup", 38 | "affinity": "INTEGER", 39 | "notNull": true 40 | }, 41 | { 42 | "fieldPath": "text", 43 | "columnName": "text", 44 | "affinity": "TEXT", 45 | "notNull": false 46 | }, 47 | { 48 | "fieldPath": "title", 49 | "columnName": "title", 50 | "affinity": "TEXT", 51 | "notNull": false 52 | }, 53 | { 54 | "fieldPath": "subText", 55 | "columnName": "subText", 56 | "affinity": "TEXT", 57 | "notNull": false 58 | }, 59 | { 60 | "fieldPath": "titleBig", 61 | "columnName": "titleBig", 62 | "affinity": "TEXT", 63 | "notNull": false 64 | }, 65 | { 66 | "fieldPath": "appPackageName", 67 | "columnName": "app_package", 68 | "affinity": "TEXT", 69 | "notNull": true 70 | } 71 | ], 72 | "primaryKey": { 73 | "columnNames": [ 74 | "notification_id" 75 | ], 76 | "autoGenerate": false 77 | }, 78 | "indices": [], 79 | "foreignKeys": [] 80 | } 81 | ], 82 | "views": [], 83 | "setupQueries": [ 84 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 85 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a6b8eccdab8a0770ff18215822747ac5')" 86 | ] 87 | } 88 | } -------------------------------------------------------------------------------- /app/schemas/dev.sebastiano.bundel.storage.RobertoDatabase/2.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 2, 5 | "identityHash": "c450576f05a6681559dbb9e34f6f5b90", 6 | "entities": [ 7 | { 8 | "tableName": "notifications", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notification_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `notification_key` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `showTimestamp` INTEGER NOT NULL, `isGroup` INTEGER NOT NULL, `text` TEXT, `title` TEXT, `subText` TEXT, `titleBig` TEXT, `app_package` TEXT NOT NULL, PRIMARY KEY(`notification_id`))", 10 | "fields": [ 11 | { 12 | "fieldPath": "id", 13 | "columnName": "notification_id", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "uniqueId", 19 | "columnName": "uid", 20 | "affinity": "TEXT", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "key", 25 | "columnName": "notification_key", 26 | "affinity": "TEXT", 27 | "notNull": true 28 | }, 29 | { 30 | "fieldPath": "timestamp", 31 | "columnName": "timestamp", 32 | "affinity": "INTEGER", 33 | "notNull": true 34 | }, 35 | { 36 | "fieldPath": "showTimestamp", 37 | "columnName": "showTimestamp", 38 | "affinity": "INTEGER", 39 | "notNull": true 40 | }, 41 | { 42 | "fieldPath": "isGroup", 43 | "columnName": "isGroup", 44 | "affinity": "INTEGER", 45 | "notNull": true 46 | }, 47 | { 48 | "fieldPath": "text", 49 | "columnName": "text", 50 | "affinity": "TEXT", 51 | "notNull": false 52 | }, 53 | { 54 | "fieldPath": "title", 55 | "columnName": "title", 56 | "affinity": "TEXT", 57 | "notNull": false 58 | }, 59 | { 60 | "fieldPath": "subText", 61 | "columnName": "subText", 62 | "affinity": "TEXT", 63 | "notNull": false 64 | }, 65 | { 66 | "fieldPath": "titleBig", 67 | "columnName": "titleBig", 68 | "affinity": "TEXT", 69 | "notNull": false 70 | }, 71 | { 72 | "fieldPath": "appPackageName", 73 | "columnName": "app_package", 74 | "affinity": "TEXT", 75 | "notNull": true 76 | } 77 | ], 78 | "primaryKey": { 79 | "columnNames": [ 80 | "notification_id" 81 | ], 82 | "autoGenerate": false 83 | }, 84 | "indices": [], 85 | "foreignKeys": [] 86 | } 87 | ], 88 | "views": [], 89 | "setupQueries": [ 90 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 91 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c450576f05a6681559dbb9e34f6f5b90')" 92 | ] 93 | } 94 | } -------------------------------------------------------------------------------- /app/schemas/dev.sebastiano.bundel.storage.RobertoDatabase/3.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 3, 5 | "identityHash": "11e54e451d1270196f5836e37d94cc3d", 6 | "entities": [ 7 | { 8 | "tableName": "notifications", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notification_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `notification_key` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `showTimestamp` INTEGER NOT NULL, `isGroup` INTEGER NOT NULL, `text` TEXT, `title` TEXT, `subText` TEXT, `titleBig` TEXT, `app_package` TEXT NOT NULL, PRIMARY KEY(`notification_id`))", 10 | "fields": [ 11 | { 12 | "fieldPath": "id", 13 | "columnName": "notification_id", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "uniqueId", 19 | "columnName": "uid", 20 | "affinity": "TEXT", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "key", 25 | "columnName": "notification_key", 26 | "affinity": "TEXT", 27 | "notNull": true 28 | }, 29 | { 30 | "fieldPath": "timestamp", 31 | "columnName": "timestamp", 32 | "affinity": "INTEGER", 33 | "notNull": true 34 | }, 35 | { 36 | "fieldPath": "showTimestamp", 37 | "columnName": "showTimestamp", 38 | "affinity": "INTEGER", 39 | "notNull": true 40 | }, 41 | { 42 | "fieldPath": "isGroup", 43 | "columnName": "isGroup", 44 | "affinity": "INTEGER", 45 | "notNull": true 46 | }, 47 | { 48 | "fieldPath": "text", 49 | "columnName": "text", 50 | "affinity": "TEXT", 51 | "notNull": false 52 | }, 53 | { 54 | "fieldPath": "title", 55 | "columnName": "title", 56 | "affinity": "TEXT", 57 | "notNull": false 58 | }, 59 | { 60 | "fieldPath": "subText", 61 | "columnName": "subText", 62 | "affinity": "TEXT", 63 | "notNull": false 64 | }, 65 | { 66 | "fieldPath": "titleBig", 67 | "columnName": "titleBig", 68 | "affinity": "TEXT", 69 | "notNull": false 70 | }, 71 | { 72 | "fieldPath": "appPackageName", 73 | "columnName": "app_package", 74 | "affinity": "TEXT", 75 | "notNull": true 76 | } 77 | ], 78 | "primaryKey": { 79 | "autoGenerate": false, 80 | "columnNames": [ 81 | "notification_id" 82 | ] 83 | }, 84 | "indices": [], 85 | "foreignKeys": [] 86 | }, 87 | { 88 | "tableName": "apps", 89 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `name` TEXT, PRIMARY KEY(`package_name`))", 90 | "fields": [ 91 | { 92 | "fieldPath": "packageName", 93 | "columnName": "package_name", 94 | "affinity": "TEXT", 95 | "notNull": true 96 | }, 97 | { 98 | "fieldPath": "name", 99 | "columnName": "name", 100 | "affinity": "TEXT", 101 | "notNull": false 102 | } 103 | ], 104 | "primaryKey": { 105 | "autoGenerate": false, 106 | "columnNames": [ 107 | "package_name" 108 | ] 109 | }, 110 | "indices": [], 111 | "foreignKeys": [] 112 | } 113 | ], 114 | "views": [], 115 | "setupQueries": [ 116 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 117 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '11e54e451d1270196f5836e37d94cc3d')" 118 | ] 119 | } 120 | } -------------------------------------------------------------------------------- /app/src/androidTest/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/dev/sebastiano/bundel/FakePreferences.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel 2 | 3 | import dev.sebastiano.bundel.preferences.Preferences 4 | import dev.sebastiano.bundel.preferences.schedule.TimeRangesSchedule 5 | import dev.sebastiano.bundel.ui.composables.WeekDay 6 | import kotlinx.coroutines.flow.Flow 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | 9 | @Suppress("NotImplementedDeclaration") 10 | internal class FakePreferences : Preferences { 11 | 12 | val crashlyticsEnabled = MutableStateFlow(false) 13 | 14 | override fun isCrashlyticsEnabled() = crashlyticsEnabled 15 | 16 | override suspend fun setIsCrashlyticsEnabled(enabled: Boolean) { 17 | crashlyticsEnabled.emit(enabled) 18 | } 19 | 20 | override fun isWinteryEasterEggEnabled() = MutableStateFlow(false) 21 | 22 | override suspend fun setWinteryEasterEggEnabled(enabled: Boolean) { 23 | TODO("Not yet implemented") 24 | } 25 | 26 | override fun getExcludedPackages(): Flow> { 27 | TODO("Not yet implemented") 28 | } 29 | 30 | override suspend fun setExcludedPackages(excludedPackages: Set) { 31 | TODO("Not yet implemented") 32 | } 33 | 34 | override suspend fun isOnboardingSeen() = false 35 | 36 | override suspend fun setIsOnboardingSeen(onboardingSeen: Boolean) { 37 | TODO("Not yet implemented") 38 | } 39 | 40 | override fun getDaysSchedule() = MutableStateFlow(emptyMap()) 41 | 42 | override suspend fun setDaysSchedule(daysSchedule: Map) { 43 | TODO("Not yet implemented") 44 | } 45 | 46 | override fun getTimeRangesSchedule() = MutableStateFlow(TimeRangesSchedule()) 47 | 48 | override suspend fun setTimeRangesSchedule(timeRangesSchedule: TimeRangesSchedule) { 49 | TODO("Not yet implemented") 50 | } 51 | 52 | override fun getSnoozeWindowDurationSeconds() = MutableStateFlow(21) 53 | 54 | override suspend fun setSnoozeWindowDurationSeconds(duration: Int) { 55 | TODO("Not yet implemented") 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/dev/sebastiano/bundel/RobertoFoldTest.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel 2 | 3 | import android.content.res.Resources 4 | import androidx.activity.ComponentActivity 5 | import androidx.compose.ui.test.junit4.createAndroidComposeRule 6 | import androidx.compose.ui.test.onNodeWithText 7 | import androidx.compose.ui.test.performClick 8 | import androidx.test.espresso.device.EspressoDevice.Companion.onDevice 9 | import androidx.test.espresso.device.action.ScreenOrientation 10 | import androidx.test.espresso.device.rules.ScreenOrientationRule 11 | import androidx.test.espresso.device.setScreenOrientation 12 | import dev.sebastiano.bundel.onboarding.OnboardingScreen 13 | import dev.sebastiano.bundel.onboarding.OnboardingViewModel 14 | import dev.sebastiano.bundel.ui.BundelYouTheme 15 | import org.junit.Rule 16 | import org.junit.Test 17 | 18 | class RobertoFoldTest { 19 | 20 | @get:Rule 21 | val composeTestRule = createAndroidComposeRule() 22 | 23 | @get:Rule 24 | val robRulez = ScreenOrientationRule(ScreenOrientation.PORTRAIT) 25 | 26 | private val resources: Resources 27 | get() = composeTestRule.activity.resources 28 | 29 | @Test 30 | fun run_the_onboarding_yey() { 31 | composeTestRule.setContent { 32 | BundelYouTheme { 33 | OnboardingScreen( 34 | viewModel = OnboardingViewModel(FakePreferences()), 35 | needsPermission = true, 36 | onOpenNotificationPreferencesClick = { /*TODO*/ }, 37 | ) { } 38 | } 39 | } 40 | 41 | composeTestRule.onNodeWithText(resources.getString(R.string.next).uppercase()) 42 | .assertExists("Next button is missing") 43 | .performClick() 44 | 45 | composeTestRule.onNodeWithText(resources.getString(R.string.onboarding_notifications_permission_title)) 46 | .assertExists("Not on next page") 47 | 48 | onDevice().setScreenOrientation(ScreenOrientation.LANDSCAPE) 49 | 50 | composeTestRule.onNodeWithText(resources.getString(R.string.onboarding_notifications_permission_title)) 51 | .assertExists("Not on page 2 anymore") 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/dev/sebastiano/bundel/onboarding/PrepareDevicePermissionsRule.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.onboarding 2 | 3 | import android.os.Build 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import androidx.test.uiautomator.UiDevice 6 | import org.junit.rules.TestRule 7 | import org.junit.runner.Description 8 | import org.junit.runners.model.Statement 9 | 10 | internal class PrepareDevicePermissionsRule : TestRule { 11 | 12 | override fun apply(base: Statement, description: Description?): Statement = 13 | object : Statement() { 14 | @Throws(Throwable::class) 15 | override fun evaluate() { 16 | val uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) 17 | 18 | uiDevice.executeShellCommand("log -p i -t BundelTest Allowing hidden methods...") 19 | val apiVersion = Build.VERSION.SDK_INT 20 | when { 21 | apiVersion == Build.VERSION_CODES.P -> { 22 | uiDevice.executeShellCommand("log -p i -t BundelTest Detected API 28") 23 | uiDevice.executeShellCommand("settings put global hidden_api_policy_pre_p_apps 1") 24 | uiDevice.executeShellCommand("settings put global hidden_api_policy_p_apps 1") 25 | } 26 | apiVersion >= Build.VERSION_CODES.Q -> { 27 | uiDevice.executeShellCommand("log -p i -t BundelTest Detected API >= 29") 28 | uiDevice.executeShellCommand("settings put global hidden_api_policy 1") 29 | } 30 | else -> { 31 | uiDevice.executeShellCommand("log -p i -t BundelTest Nothing to do, API < 28") 32 | } 33 | } 34 | 35 | base.evaluate() 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/dev/sebastiano/bundel/onboarding/PrepareTestStorageRule.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.onboarding 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.uiautomator.UiDevice 5 | import org.junit.rules.TestRule 6 | import org.junit.runner.Description 7 | import org.junit.runners.model.Statement 8 | 9 | internal class PrepareTestStorageRule : TestRule { 10 | 11 | override fun apply(base: Statement, description: Description?): Statement = 12 | object : Statement() { 13 | @Throws(Throwable::class) 14 | override fun evaluate() { 15 | val uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) 16 | 17 | uiDevice.executeShellCommand("log -p i -t TestStorage Enabling test storage permissions...") 18 | uiDevice.executeShellCommand("appops set androidx.test.services MANAGE_EXTERNAL_STORAGE allow") 19 | 20 | base.evaluate() 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/debug/java/dev/sebastiano/bundel/glance/GlanceDebugActivity.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.glance 2 | 3 | import androidx.datastore.preferences.core.mutablePreferencesOf 4 | import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi 5 | import androidx.glance.appwidget.GlanceAppWidgetReceiver 6 | import com.google.android.glance.tools.viewer.GlanceSnapshot 7 | import com.google.android.glance.tools.viewer.GlanceViewerActivity 8 | 9 | @OptIn(ExperimentalGlanceRemoteViewsApi::class) 10 | class GlanceDebugActivity : GlanceViewerActivity() { 11 | 12 | override suspend fun getGlanceSnapshot( 13 | receiver: Class, 14 | ): GlanceSnapshot { 15 | return when (receiver) { 16 | BundelAppWidgetReceiver::class.java -> GlanceSnapshot( 17 | instance = CannoliWidget(null), 18 | state = mutablePreferencesOf(), 19 | ) 20 | else -> throw IllegalArgumentException("Unknown receiver") 21 | } 22 | } 23 | 24 | override fun getProviders() = listOf(BundelAppWidgetReceiver::class.java) 25 | } 26 | -------------------------------------------------------------------------------- /app/src/debug/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 11 | 12 | 22 | 23 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 37 | 38 | 39 | 40 | 41 | 44 | 47 | 50 | 51 | 52 | 55 | 56 | 57 | 58 | 59 | 60 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /app/src/main/ic_bundel_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-with-the-italians/bundel/51018098eeeb6d11887e4c9ecf7d3d2b9b6cf808/app/src/main/ic_bundel_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/ApplicationModule.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel 2 | 3 | import android.content.Context 4 | import android.content.pm.PackageManager 5 | import android.content.res.AssetManager 6 | import dagger.Module 7 | import dagger.Provides 8 | import dagger.hilt.InstallIn 9 | import dagger.hilt.android.qualifiers.ApplicationContext 10 | import dagger.hilt.components.SingletonComponent 11 | import kotlinx.serialization.json.Json 12 | import javax.inject.Singleton 13 | 14 | @Module 15 | @InstallIn(SingletonComponent::class) 16 | internal class ApplicationModule { 17 | 18 | @Provides 19 | @Singleton 20 | fun providePackageManager(@ApplicationContext context: Context): PackageManager = context.packageManager 21 | 22 | @Provides 23 | @Singleton 24 | fun provideAssetManager(@ApplicationContext context: Context): AssetManager = context.assets 25 | 26 | @Provides 27 | @Singleton 28 | fun provideJson(): Json = Json.Default 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/BundelApplication.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | import kotlinx.coroutines.CancellationException 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.SupervisorJob 8 | import timber.log.Timber 9 | 10 | @Suppress("unused") // It's declared in the manifest 11 | @HiltAndroidApp 12 | class BundelApplication : Application(), CoroutineScope { 13 | 14 | override val coroutineContext = SupervisorJob() 15 | 16 | override fun onCreate() { 17 | super.onCreate() 18 | 19 | Timber.plant(Timber.DebugTree()) 20 | } 21 | 22 | override fun onTerminate() { 23 | coroutineContext.cancel(CancellationException("Application being terminated")) 24 | super.onTerminate() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/DispatchersModule.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import kotlinx.coroutines.CoroutineDispatcher 8 | import kotlinx.coroutines.Dispatchers 9 | import javax.inject.Qualifier 10 | import javax.inject.Singleton 11 | 12 | @Suppress("InjectDispatcher") 13 | @Module 14 | @InstallIn(SingletonComponent::class) 15 | internal object DispatchersModule { 16 | 17 | @DefaultDispatcher 18 | @Provides 19 | @Singleton 20 | fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default 21 | 22 | @IoDispatcher 23 | @Provides 24 | @Singleton 25 | fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO 26 | 27 | @MainDispatcher 28 | @Provides 29 | @Singleton 30 | fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main 31 | } 32 | 33 | @Retention(AnnotationRetention.BINARY) 34 | @Qualifier 35 | internal annotation class DefaultDispatcher 36 | 37 | @Retention(AnnotationRetention.BINARY) 38 | @Qualifier 39 | internal annotation class IoDispatcher 40 | 41 | @Retention(AnnotationRetention.BINARY) 42 | @Qualifier 43 | internal annotation class MainDispatcher 44 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/glance/BundelAppWidgetReceiver.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.glance 2 | 3 | import android.content.Context 4 | import androidx.glance.appwidget.GlanceAppWidget 5 | import androidx.glance.appwidget.GlanceAppWidgetReceiver 6 | import androidx.glance.appwidget.updateAll 7 | import dev.sebastiano.bundel.notifications.BundelNotificationListenerService 8 | import timber.log.Timber 9 | 10 | class BundelAppWidgetReceiver : GlanceAppWidgetReceiver() { 11 | 12 | override val glanceAppWidget: GlanceAppWidget = 13 | CannoliWidget(numberOfItems = BundelNotificationListenerService.activeNotificationsFlow.value.size) 14 | 15 | companion object { 16 | 17 | internal suspend fun Context.updateWidgets(notificationsCount: Int) { 18 | Timber.i("Updating widget. Count: $notificationsCount") 19 | CannoliWidget(notificationsCount).updateAll(this) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/glance/CannoliWidget.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.glance 2 | 3 | import android.content.Context 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.ui.unit.DpSize 6 | import androidx.compose.ui.unit.dp 7 | import androidx.glance.GlanceId 8 | import androidx.glance.GlanceModifier 9 | import androidx.glance.LocalSize 10 | import androidx.glance.action.ActionParameters 11 | import androidx.glance.action.clickable 12 | import androidx.glance.appwidget.GlanceAppWidget 13 | import androidx.glance.appwidget.SizeMode 14 | import androidx.glance.appwidget.action.ActionCallback 15 | import androidx.glance.appwidget.action.actionRunCallback 16 | import androidx.glance.appwidget.provideContent 17 | import androidx.glance.background 18 | import androidx.glance.layout.Alignment 19 | import androidx.glance.layout.Box 20 | import androidx.glance.layout.Column 21 | import androidx.glance.layout.Spacer 22 | import androidx.glance.layout.fillMaxSize 23 | import androidx.glance.layout.height 24 | import androidx.glance.text.Text 25 | import androidx.glance.text.TextStyle 26 | import androidx.glance.unit.ColorProvider 27 | import dev.sebastiano.bundel.glance.BundelAppWidgetReceiver.Companion.updateWidgets 28 | import dev.sebastiano.bundel.notifications.BundelNotificationListenerService 29 | import dev.sebastiano.bundel.ui.BundelGlanceTheme 30 | import kotlinx.coroutines.flow.first 31 | import timber.log.Timber 32 | 33 | internal class CannoliWidget( 34 | val numberOfItems: Int?, 35 | ) : GlanceAppWidget() { 36 | 37 | override val sizeMode = SizeMode.Responsive( 38 | setOf(DpSize(36.dp, 36.dp), DpSize(100.dp, 96.dp)), 39 | ) 40 | 41 | override suspend fun provideGlance(context: Context, id: GlanceId) { 42 | provideContent { 43 | BundelGlanceTheme { 44 | Box( 45 | modifier = GlanceModifier.background(MaterialTheme.colorScheme.primaryContainer) 46 | .clickable(actionRunCallback()) 47 | .fillMaxSize(), 48 | contentAlignment = Alignment.Center, 49 | ) { 50 | val text = when { 51 | numberOfItems == null -> "⏳" 52 | numberOfItems >= 0 -> numberOfItems.toString() 53 | else -> "💩" 54 | } 55 | 56 | val size = LocalSize.current 57 | Timber.d("Size: $size") 58 | if (size.width >= 100.dp && size.height >= 96.dp) { 59 | Column( 60 | modifier = GlanceModifier.fillMaxSize(), 61 | horizontalAlignment = Alignment.CenterHorizontally, 62 | verticalAlignment = Alignment.CenterVertically, 63 | ) { 64 | Text(text = "I'm big whoa 🏆") 65 | Spacer(modifier = GlanceModifier.height(16.dp)) 66 | Text( 67 | text = text, 68 | style = TextStyle( 69 | color = ColorProvider(MaterialTheme.colorScheme.onPrimaryContainer), 70 | fontSize = MaterialTheme.typography.displayMedium.fontSize, 71 | ), 72 | ) 73 | } 74 | } else { 75 | Text( 76 | text = text, 77 | style = TextStyle( 78 | color = ColorProvider(MaterialTheme.colorScheme.onPrimaryContainer), 79 | fontSize = MaterialTheme.typography.displayMedium.fontSize, 80 | ), 81 | ) 82 | } 83 | } 84 | } 85 | } 86 | } 87 | } 88 | 89 | class MustBeTopLevelBecauseReasonsCallbackClassApi : ActionCallback { 90 | 91 | override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { 92 | val notificationsCount = BundelNotificationListenerService.activeNotificationsFlow.first().size 93 | context.updateWidgets(notificationsCount) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/glance/TestWidgetScreen.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.glance 2 | 3 | import androidx.compose.foundation.layout.fillMaxSize 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.tooling.preview.Preview 7 | import androidx.compose.ui.unit.DpSize 8 | import androidx.compose.ui.unit.dp 9 | import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi 10 | import com.google.android.glance.appwidget.host.glance.GlanceAppWidgetHostPreview 11 | 12 | @Preview 13 | @OptIn(ExperimentalGlanceRemoteViewsApi::class) 14 | @Composable 15 | fun TestWidgetScreen() { 16 | // The size of the widget 17 | val displaySize = DpSize(200.dp, 200.dp) 18 | // Your GlanceAppWidget instance 19 | val instance = CannoliWidget(null) 20 | // Provide a state depending on the GlanceAppWidget state definition 21 | // val state = preferencesOf(CannoliWidget.countKey to 2) 22 | 23 | GlanceAppWidgetHostPreview( 24 | modifier = Modifier.fillMaxSize(), 25 | glanceAppWidget = instance, 26 | displaySize = displaySize, 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/history/HistoryScreen.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.history 2 | 3 | import android.app.Application 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.PaddingValues 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.lazy.LazyColumn 9 | import androidx.compose.foundation.lazy.itemsIndexed 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.platform.LocalContext 13 | import androidx.compose.ui.tooling.preview.Preview 14 | import androidx.compose.ui.unit.dp 15 | import dev.sebastiano.bundel.notifications.PersistableNotification 16 | import dev.sebastiano.bundel.notificationslist.NotificationItem 17 | import dev.sebastiano.bundel.notificationslist.NotificationsListEmptyState 18 | import dev.sebastiano.bundel.storage.DiskImagesStorage 19 | import dev.sebastiano.bundel.ui.BundelYouTheme 20 | import dev.sebastiano.bundel.ui.singlePadding 21 | 22 | @Preview 23 | @Composable 24 | fun NotificationsHistoryEmptyLightPreview() { 25 | BundelYouTheme { 26 | NotificationsHistoryScreen(persistableNotifications = emptyList()) 27 | } 28 | } 29 | 30 | @Preview 31 | @Composable 32 | fun NotificationsHistoryEmptyDarkPreview() { 33 | BundelYouTheme(darkTheme = true) { 34 | NotificationsHistoryScreen(persistableNotifications = emptyList()) 35 | } 36 | } 37 | 38 | private val persistableNotification = PersistableNotification( 39 | id = 123, 40 | key = "123", 41 | timestamp = 12345678L, 42 | text = "Hello Ivan", 43 | appInfo = PersistableNotification.SenderAppInfo("com.yeah", "Yeah!"), 44 | ) 45 | 46 | @Preview 47 | @Composable 48 | fun NotificationsHistoryLightPreview() { 49 | BundelYouTheme { 50 | NotificationsHistoryScreen( 51 | persistableNotifications = listOf(persistableNotification), 52 | ) 53 | } 54 | } 55 | 56 | @Preview 57 | @Composable 58 | fun NotificationsHistoryDarkPreview() { 59 | BundelYouTheme(darkTheme = true) { 60 | NotificationsHistoryScreen( 61 | persistableNotifications = listOf(persistableNotification), 62 | ) 63 | } 64 | } 65 | 66 | @Composable 67 | internal fun NotificationsHistoryScreen( 68 | innerPadding: PaddingValues = PaddingValues(0.dp), 69 | persistableNotifications: List, 70 | ) { 71 | if (persistableNotifications.isNotEmpty()) { 72 | Column( 73 | Modifier 74 | .fillMaxSize() 75 | .padding(innerPadding), 76 | ) { 77 | NotificationsLazyColumn(persistableNotifications) 78 | } 79 | } else { 80 | NotificationsListEmptyState(Modifier.padding(innerPadding)) 81 | } 82 | } 83 | 84 | @Composable 85 | private fun NotificationsLazyColumn(persistableNotifications: List) { 86 | val imagesStorage = DiskImagesStorage(LocalContext.current.applicationContext as Application) 87 | LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(singlePadding())) { 88 | val items = persistableNotifications.filterNot { it.isGroup } 89 | itemsIndexed(items) { index, notification -> 90 | NotificationItem(notification, imagesStorage, isLastItem = index == items.lastIndex) 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/navigation/NavigationRoute.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.navigation 2 | 3 | import androidx.annotation.StringRes 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.rounded.History 6 | import androidx.compose.material.icons.rounded.NotificationsActive 7 | import androidx.compose.ui.graphics.vector.ImageVector 8 | import dev.sebastiano.bundel.R 9 | 10 | internal sealed class NavigationRoute(val route: String) { 11 | 12 | object SplashoScreenButWithAWeirdNameNotToTriggerLint : NavigationRoute("benThereDoneThat") 13 | 14 | object OnboardingGraph : NavigationRoute("onboarding") { 15 | 16 | object OnboardingScreen : NavigationRoute("onboarding.screen") 17 | } 18 | 19 | object MainScreenGraph : NavigationRoute("main_screen") { 20 | 21 | object MainScreen : NavigationRoute("main_screen.screen") 22 | 23 | object NotificationsList : NavigationRoute("main_screen.notifications_list"), BottomNavNavigationRoute { 24 | 25 | override val icon: ImageVector = Icons.Rounded.NotificationsActive 26 | override val labelId: Int = R.string.bottom_nav_active_notifications 27 | } 28 | 29 | object History : NavigationRoute("main_screen.history"), BottomNavNavigationRoute { 30 | 31 | override val icon: ImageVector = Icons.Rounded.History 32 | override val labelId: Int = R.string.bottom_nav_history 33 | } 34 | } 35 | 36 | object PreferencesGraph : NavigationRoute("preferences") { 37 | 38 | object PreferencesScreen : NavigationRoute("preferences.screen") 39 | 40 | object SelectApps : NavigationRoute("preferences.select_apps") 41 | 42 | object SelectDays : NavigationRoute("preferences.babbadibuppi") 43 | 44 | object SelectTimeRanges : NavigationRoute("preferences.time-ranges") 45 | 46 | object Licenses : NavigationRoute("preferences.absentFriends") 47 | 48 | object TestWidget : NavigationRoute("preferences.testWidget") 49 | } 50 | 51 | interface BottomNavNavigationRoute { 52 | 53 | val icon: ImageVector 54 | 55 | @get:StringRes 56 | val labelId: Int 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/notifications/ActiveNotification.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.notifications 2 | 3 | import android.app.PendingIntent 4 | import android.graphics.drawable.Icon 5 | 6 | internal data class ActiveNotification( 7 | val persistableNotification: PersistableNotification, 8 | val icons: Icons = Icons(), 9 | val interactions: Interactions = Interactions(), 10 | val isSnoozed: Boolean, 11 | ) { 12 | 13 | val isNotEmpty: Boolean = 14 | persistableNotification.isNotEmpty || icons.isNotEmpty 15 | 16 | fun isClickable() = interactions.main != null 17 | 18 | data class Icons( 19 | val appIcon: Icon? = null, 20 | val small: Icon? = null, 21 | val large: Icon? = null, 22 | val extraLarge: Icon? = null, 23 | ) { 24 | 25 | val isNotEmpty: Boolean = appIcon != null || small != null || large != null || extraLarge != null 26 | } 27 | 28 | data class Interactions( 29 | val main: PendingIntent? = null, 30 | val dismiss: PendingIntent? = null, 31 | val actions: List = emptyList(), 32 | ) { 33 | 34 | data class ActionItem( 35 | val text: CharSequence, 36 | val icon: Icon? = null, 37 | val pendingIntent: PendingIntent? = null, 38 | ) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/notifications/NotificationServiceHelpers.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("ktlint:standard:filename") 2 | 3 | package dev.sebastiano.bundel.notifications 4 | 5 | import android.content.ComponentName 6 | import android.content.Context 7 | import android.provider.Settings 8 | 9 | internal fun needsNotificationsPermission(context: Context): Boolean { 10 | val pkgName = context.packageName 11 | val enabledListeners = Settings.Secure.getString(context.contentResolver, "enabled_notification_listeners") 12 | ?.split(":") 13 | if (enabledListeners.isNullOrEmpty()) return false 14 | 15 | return enabledListeners 16 | .map { listenerPackageName -> ComponentName.unflattenFromString(listenerPackageName) } 17 | .none { pkgName == it?.packageName } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/notifications/PersistableNotification.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.notifications 2 | 3 | internal data class PersistableNotification( 4 | val id: Int, 5 | val key: String, 6 | val timestamp: Long, 7 | val showTimestamp: Boolean = false, 8 | val isGroup: Boolean = false, 9 | val text: String? = null, 10 | val title: String? = null, 11 | val subText: String? = null, 12 | val titleBig: String? = null, 13 | val appInfo: SenderAppInfo, 14 | ) { 15 | 16 | val uniqueId = "${appInfo.packageName}_${id}_$timestamp" 17 | 18 | val isNotEmpty: Boolean = 19 | timestamp >= 0 && 20 | ( 21 | text?.isNotBlank() == true || 22 | title?.isNotBlank() == true || 23 | subText?.isNotBlank() == true || 24 | titleBig?.isNotBlank() == true 25 | ) 26 | 27 | data class SenderAppInfo( 28 | val packageName: String, 29 | val name: String? = null, 30 | val iconPath: String? = null, 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/notifications/StatusBarNotificationExtensions.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.notifications 2 | 3 | import android.app.Notification 4 | import android.app.Notification.EXTRA_SHOW_WHEN 5 | import android.content.Context 6 | import android.content.pm.ApplicationInfo 7 | import android.content.pm.PackageManager 8 | import android.content.res.Resources 9 | import android.graphics.drawable.Icon 10 | import android.service.notification.StatusBarNotification 11 | import com.google.firebase.crashlytics.ktx.crashlytics 12 | import com.google.firebase.ktx.Firebase 13 | 14 | internal fun StatusBarNotification.toActiveNotificationOrNull(context: Context) = 15 | toActiveNotification(context).takeIf { it.isNotEmpty } 16 | 17 | @Suppress("DEPRECATION") 18 | internal fun StatusBarNotification.toActiveNotification(context: Context): ActiveNotification { 19 | val packageManager = context.packageManager 20 | val applicationInfo = packageManager.getApplicationInfo(packageName, 0) 21 | 22 | return ActiveNotification( 23 | persistableNotification = PersistableNotification( 24 | id = id, 25 | key = key, 26 | timestamp = notification.`when`, 27 | showTimestamp = notification.run { `when` != 0L && extras.getBoolean(EXTRA_SHOW_WHEN) }, 28 | isGroup = notification.run { groupKey != null && flags and Notification.FLAG_GROUP_SUMMARY != 0 }, 29 | text = text, 30 | title = title, 31 | subText = subText, 32 | titleBig = titleBig, 33 | appInfo = extractAppInfo(applicationInfo, packageManager), 34 | ), 35 | icons = extractIcons(applicationInfo), 36 | interactions = extractInteractions(), 37 | isSnoozed = false, 38 | ) 39 | } 40 | 41 | @Suppress("DEPRECATION") 42 | private fun StatusBarNotification.extractIcons(applicationInfo: ApplicationInfo) = ActiveNotification.Icons( 43 | appIcon = Icon.createWithResource(packageName, applicationInfo.icon), 44 | small = notification.smallIcon, 45 | large = notification.getLargeIcon(), 46 | extraLarge = notification.extras.getParcelable(Notification.EXTRA_LARGE_ICON_BIG), 47 | ) 48 | 49 | private fun StatusBarNotification.extractAppInfo( 50 | applicationInfo: ApplicationInfo, 51 | packageManager: PackageManager, 52 | ): PersistableNotification.SenderAppInfo = 53 | PersistableNotification.SenderAppInfo( 54 | packageName = packageName, 55 | name = if (applicationInfo.labelRes != Resources.ID_NULL) { 56 | packageManager.getResourcesForApplication(applicationInfo).getString(applicationInfo.labelRes) 57 | } else { 58 | Firebase.crashlytics.log("Application ${applicationInfo.packageName} has no label") 59 | applicationInfo.packageName 60 | }, 61 | iconPath = null, 62 | ) 63 | 64 | private fun StatusBarNotification.extractInteractions() = ActiveNotification.Interactions( 65 | main = notification.contentIntent, 66 | dismiss = notification.deleteIntent, 67 | actions = notification.actions 68 | ?.map { ActiveNotification.Interactions.ActionItem(it.title, it.getIcon(), it.actionIntent) } 69 | .orEmpty(), 70 | ) 71 | 72 | internal val StatusBarNotification.text: String? 73 | get() = notification.extras.getString(Notification.EXTRA_TEXT) 74 | 75 | internal val StatusBarNotification.title: String? 76 | get() = notification.extras.getString(Notification.EXTRA_TITLE) 77 | 78 | internal val StatusBarNotification.titleBig: String? 79 | get() = notification.extras.getString(Notification.EXTRA_TITLE_BIG) 80 | 81 | internal val StatusBarNotification.subText: String? 82 | get() = notification.extras.getString(Notification.EXTRA_SUB_TEXT) 83 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/notificationslist/NotificationsListEmptyState.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.notificationslist 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.height 8 | import androidx.compose.material.ContentAlpha 9 | import androidx.compose.material.LocalContentAlpha 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.CompositionLocalProvider 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.res.stringResource 17 | import androidx.compose.ui.unit.dp 18 | import androidx.compose.ui.unit.sp 19 | import dev.sebastiano.bundel.R 20 | 21 | @Composable 22 | internal fun NotificationsListEmptyState(modifier: Modifier = Modifier) { 23 | CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.disabled) { 24 | Column( 25 | Modifier 26 | .fillMaxSize() 27 | .then(modifier), 28 | horizontalAlignment = Alignment.CenterHorizontally, 29 | verticalArrangement = Arrangement.Center, 30 | ) { 31 | Text(stringResource(R.string.sad_face), fontSize = 72.sp) 32 | Spacer(modifier = Modifier.height(8.dp)) 33 | Text(text = stringResource(R.string.notifications_empty_text), style = MaterialTheme.typography.bodySmall) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/notificationslist/NotificationsListScreen.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.notificationslist 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.PaddingValues 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.lazy.LazyColumn 8 | import androidx.compose.foundation.lazy.items 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.collectAsState 11 | import androidx.compose.runtime.getValue 12 | import androidx.compose.runtime.remember 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.tooling.preview.Preview 15 | import androidx.compose.ui.unit.dp 16 | import androidx.lifecycle.Lifecycle 17 | import androidx.lifecycle.flowWithLifecycle 18 | import dev.sebastiano.bundel.notifications.ActiveNotification 19 | import dev.sebastiano.bundel.notifications.BundelNotificationListenerService 20 | import dev.sebastiano.bundel.notifications.PersistableNotification 21 | import dev.sebastiano.bundel.ui.BundelYouTheme 22 | import dev.sebastiano.bundel.ui.singlePadding 23 | 24 | @Preview 25 | @Composable 26 | fun NotificationsListEmptyLightPreview() { 27 | BundelYouTheme { 28 | NotificationsListScreen( 29 | activeNotifications = emptyList(), 30 | onNotificationClick = {}, 31 | onNotificationDismiss = {}, 32 | ) 33 | } 34 | } 35 | 36 | @Preview 37 | @Composable 38 | fun NotificationsListEmptyDarkPreview() { 39 | BundelYouTheme(darkTheme = true) { 40 | NotificationsListScreen( 41 | activeNotifications = emptyList(), 42 | onNotificationClick = {}, 43 | onNotificationDismiss = {}, 44 | ) 45 | } 46 | } 47 | 48 | private val activeNotification = ActiveNotification( 49 | persistableNotification = PersistableNotification( 50 | id = 123, 51 | key = "123", 52 | timestamp = 12345678L, 53 | text = "Hello Ivan", 54 | appInfo = PersistableNotification.SenderAppInfo("com.yeah", "Yeah!"), 55 | ), 56 | isSnoozed = false, 57 | ) 58 | 59 | @Preview 60 | @Composable 61 | fun NotificationsListLightPreview() { 62 | BundelYouTheme { 63 | NotificationsListScreen( 64 | activeNotifications = listOf(activeNotification), 65 | onNotificationClick = {}, 66 | onNotificationDismiss = {}, 67 | ) 68 | } 69 | } 70 | 71 | @Preview 72 | @Composable 73 | fun NotificationsListDarkPreview() { 74 | BundelYouTheme(darkTheme = true) { 75 | NotificationsListScreen( 76 | activeNotifications = listOf(activeNotification), 77 | onNotificationClick = {}, 78 | onNotificationDismiss = {}, 79 | ) 80 | } 81 | } 82 | 83 | @Composable 84 | internal fun NotificationsListScreen( 85 | lifecycle: Lifecycle, 86 | innerPadding: PaddingValues, 87 | onNotificationClick: (notification: ActiveNotification) -> Unit, 88 | onNotificationDismiss: (notification: ActiveNotification) -> Unit, 89 | ) { 90 | val notifications by remember(lifecycle) { BundelNotificationListenerService.activeNotificationsFlow.flowWithLifecycle(lifecycle) } 91 | .collectAsState(emptyList()) 92 | NotificationsListScreen(notifications, onNotificationClick, onNotificationDismiss, innerPadding) 93 | } 94 | 95 | @Composable 96 | private fun NotificationsListScreen( 97 | activeNotifications: List, 98 | onNotificationClick: (notification: ActiveNotification) -> Unit, 99 | onNotificationDismiss: (notification: ActiveNotification) -> Unit, 100 | innerPadding: PaddingValues = PaddingValues(0.dp), 101 | ) { 102 | if (activeNotifications.isNotEmpty()) { 103 | NotificationsLazyColumn( 104 | activeNotifications, 105 | Modifier.padding(innerPadding), 106 | onNotificationClick, 107 | onNotificationDismiss, 108 | ) 109 | } else { 110 | NotificationsListEmptyState() 111 | } 112 | } 113 | 114 | @Composable 115 | private fun NotificationsLazyColumn( 116 | activeNotifications: List, 117 | modifier: Modifier = Modifier, 118 | onNotificationClick: (ActiveNotification) -> Unit, 119 | onNotificationDismiss: (ActiveNotification) -> Unit, 120 | ) { 121 | LazyColumn( 122 | modifier = Modifier 123 | .fillMaxSize() 124 | .then(modifier), 125 | contentPadding = PaddingValues(singlePadding()), 126 | verticalArrangement = Arrangement.spacedBy(singlePadding()), 127 | ) { 128 | val items = activeNotifications.filterNot { it.persistableNotification.isGroup } 129 | items(items = items, key = { item -> item.persistableNotification.uniqueId }) { notification -> 130 | SnoozeItem(notification, onNotificationClick, onNotificationDismiss) 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/onboarding/DaysSchedulePageState.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.onboarding 2 | 3 | import dev.sebastiano.bundel.ui.composables.WeekDay 4 | 5 | internal class DaysSchedulePageState( 6 | val daysSchedule: Map, 7 | val onDayCheckedChange: (day: WeekDay, checked: Boolean) -> Unit, 8 | ) { 9 | 10 | constructor() : this(daysSchedule = WeekDay.values().associate { it to true }, onDayCheckedChange = { _, _ -> }) 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/onboarding/IntroPage.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.onboarding 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.Spacer 8 | import androidx.compose.foundation.layout.height 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.layout.width 11 | import androidx.compose.foundation.rememberScrollState 12 | import androidx.compose.foundation.verticalScroll 13 | import androidx.compose.material.Switch 14 | import androidx.compose.material.SwitchDefaults 15 | import androidx.compose.material3.MaterialTheme 16 | import androidx.compose.material3.Surface 17 | import androidx.compose.material3.Text 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.res.stringResource 22 | import androidx.compose.ui.semantics.SemanticsProperties 23 | import androidx.compose.ui.semantics.semantics 24 | import androidx.compose.ui.state.ToggleableState 25 | import androidx.compose.ui.text.style.TextAlign 26 | import androidx.compose.ui.tooling.preview.Preview 27 | import androidx.compose.ui.unit.dp 28 | import dev.sebastiano.bundel.R 29 | import dev.sebastiano.bundel.ui.BundelYouTheme 30 | import dev.sebastiano.bundel.ui.singlePadding 31 | import dev.sebastiano.bundel.util.Orientation 32 | import dev.sebastiano.bundel.util.currentOrientation 33 | 34 | @Preview(backgroundColor = 0xFF4CE062, showBackground = true) 35 | @Composable 36 | fun IntroPagePreview() { 37 | BundelYouTheme { 38 | Surface { 39 | val england = IntroPageState() 40 | IntroPage(pageState = england) 41 | } 42 | } 43 | } 44 | 45 | @Preview(backgroundColor = 0xFF4CE062, showBackground = true, widthDp = 822, heightDp = 392) 46 | @Composable 47 | fun IntroPageLandscapePreview() { 48 | BundelYouTheme { 49 | Surface { 50 | val england = IntroPageState() 51 | IntroPage(pageState = england, orientation = Orientation.Landscape) 52 | } 53 | } 54 | } 55 | 56 | internal class IntroPageState( 57 | val crashReportingEnabled: Boolean, 58 | val onCrashlyticsEnabledChanged: (Boolean) -> Unit, 59 | ) { 60 | 61 | constructor() : this(crashReportingEnabled = false, onCrashlyticsEnabledChanged = { }) 62 | } 63 | 64 | @Composable 65 | internal fun IntroPage( 66 | pageState: IntroPageState, 67 | orientation: Orientation = currentOrientation(), 68 | ) { 69 | Column( 70 | modifier = Modifier 71 | .onboardingPageModifier(orientation) 72 | .verticalScroll(rememberScrollState()), 73 | horizontalAlignment = Alignment.CenterHorizontally, 74 | verticalArrangement = Arrangement.Top, 75 | ) { 76 | if (orientation == Orientation.Portrait) { 77 | PageTitle(text = stringResource(id = R.string.onboarding_welcome_title), textAlign = TextAlign.Center) 78 | } 79 | 80 | Spacer(Modifier.height(24.dp)) 81 | 82 | Text( 83 | text = stringResource(id = R.string.onboarding_blurb), 84 | textAlign = TextAlign.Center, 85 | ) 86 | 87 | val spacerHeight = if (orientation == Orientation.Portrait) 24.dp else singlePadding() 88 | 89 | Spacer(modifier = Modifier.height(spacerHeight)) 90 | 91 | CrashlyticsSwitch( 92 | crashReportingEnabled = pageState.crashReportingEnabled, 93 | onCrashlyticsEnabledChanged = pageState.onCrashlyticsEnabledChanged, 94 | modifier = Modifier.padding(vertical = singlePadding(), horizontal = 16.dp), 95 | ) 96 | } 97 | } 98 | 99 | @Composable 100 | private fun CrashlyticsSwitch( 101 | crashReportingEnabled: Boolean, 102 | onCrashlyticsEnabledChanged: (Boolean) -> Unit, 103 | modifier: Modifier, 104 | ) { 105 | Row( 106 | modifier = Modifier 107 | .clickable { onCrashlyticsEnabledChanged(!crashReportingEnabled) } 108 | .semantics(properties = { set(SemanticsProperties.ToggleableState, ToggleableState(crashReportingEnabled)) }) 109 | .then(modifier), 110 | verticalAlignment = Alignment.CenterVertically, 111 | ) { 112 | Switch( 113 | checked = crashReportingEnabled, 114 | onCheckedChange = null, 115 | colors = SwitchDefaults.colors( 116 | uncheckedThumbColor = MaterialTheme.colorScheme.secondary, 117 | uncheckedTrackColor = MaterialTheme.colorScheme.onSecondary, 118 | checkedThumbColor = MaterialTheme.colorScheme.secondary, 119 | checkedTrackColor = MaterialTheme.colorScheme.onSecondary, 120 | ), 121 | ) 122 | 123 | Spacer(modifier = Modifier.width(singlePadding())) 124 | 125 | Text(stringResource(R.string.onboarding_enable_crashlytics)) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/onboarding/NotificationsAccessPageState.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.onboarding 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.height 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.layout.size 9 | import androidx.compose.material.icons.Icons 10 | import androidx.compose.material.icons.rounded.DoneOutline 11 | import androidx.compose.material3.Button 12 | import androidx.compose.material3.Icon 13 | import androidx.compose.material3.LocalContentColor 14 | import androidx.compose.material3.Text 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.res.stringResource 19 | import androidx.compose.ui.text.style.TextAlign 20 | import androidx.compose.ui.unit.dp 21 | import dev.sebastiano.bundel.R 22 | import dev.sebastiano.bundel.util.Orientation 23 | import dev.sebastiano.bundel.util.currentOrientation 24 | 25 | internal class NotificationsAccessPageState( 26 | val needsPermission: Boolean, 27 | val onSettingsIntentClick: () -> Unit, 28 | ) { 29 | 30 | constructor() : this(needsPermission = true, onSettingsIntentClick = {}) 31 | } 32 | 33 | @Composable 34 | internal fun NotificationsAccessPage( 35 | pageState: NotificationsAccessPageState, 36 | orientation: Orientation = currentOrientation(), 37 | ) { 38 | Column( 39 | modifier = Modifier.onboardingPageModifier(orientation), 40 | horizontalAlignment = Alignment.CenterHorizontally, 41 | verticalArrangement = Arrangement.Top, 42 | ) { 43 | if (orientation == Orientation.Portrait) { 44 | PageTitle(text = stringResource(id = R.string.onboarding_notifications_permission_title)) 45 | } 46 | 47 | Spacer(Modifier.height(24.dp)) 48 | 49 | if (pageState.needsPermission) { 50 | Text( 51 | text = stringResource(R.string.notifications_permission_explanation), 52 | textAlign = TextAlign.Center, 53 | modifier = Modifier.padding(16.dp), 54 | ) 55 | 56 | Spacer(Modifier.height(24.dp)) 57 | 58 | Button(onClick = pageState.onSettingsIntentClick) { 59 | Text(stringResource(R.string.button_notifications_access_prompt)) 60 | } 61 | } else { 62 | Icon( 63 | imageVector = Icons.Rounded.DoneOutline, 64 | contentDescription = stringResource(R.string.notifications_permission_done_image_content_description), 65 | tint = LocalContentColor.current, 66 | modifier = Modifier 67 | .size(72.dp), 68 | ) 69 | 70 | Spacer(Modifier.height(16.dp)) 71 | 72 | Text( 73 | text = stringResource(R.string.notifications_permission_all_done), 74 | textAlign = TextAlign.Center, 75 | modifier = Modifier.padding(16.dp), 76 | ) 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/onboarding/OnboardingViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.onboarding 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.google.firebase.crashlytics.ktx.crashlytics 6 | import com.google.firebase.ktx.Firebase 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import dev.sebastiano.bundel.preferences.Preferences 9 | import dev.sebastiano.bundel.ui.composables.TimeRange 10 | import dev.sebastiano.bundel.ui.composables.WeekDay 11 | import kotlinx.coroutines.flow.first 12 | import kotlinx.coroutines.launch 13 | import timber.log.Timber 14 | import javax.inject.Inject 15 | 16 | @HiltViewModel 17 | internal class OnboardingViewModel @Inject constructor( 18 | private val preferences: Preferences, 19 | ) : ViewModel() { 20 | 21 | val timeRangesScheduleFlow = preferences.getTimeRangesSchedule() 22 | val daysScheduleFlow = preferences.getDaysSchedule() 23 | val crashReportingEnabledFlowrina = preferences.isCrashlyticsEnabled() 24 | 25 | fun setCrashReportingEnabled(enabled: Boolean) { 26 | Timber.d("Crashlytics is enabled: $enabled") 27 | Firebase.crashlytics.setCrashlyticsCollectionEnabled(enabled) 28 | 29 | viewModelScope.launch { 30 | preferences.setIsCrashlyticsEnabled(enabled) 31 | } 32 | } 33 | 34 | fun onDaysScheduleChangeWeekDay(weekDay: WeekDay, active: Boolean) { 35 | Timber.d("Schedule day ${weekDay.name} active changed: $active") 36 | 37 | viewModelScope.launch { 38 | val daysScheduleValue = daysScheduleFlow.first().toMutableMap() 39 | daysScheduleValue[weekDay] = active 40 | preferences.setDaysSchedule(daysScheduleValue) 41 | } 42 | } 43 | 44 | fun onTimeRangesScheduleAddTimeRange() { 45 | Timber.d("Adding time range to schedule") 46 | 47 | viewModelScope.launch { 48 | val newSchedule = timeRangesScheduleFlow.first().appendTimeRange() 49 | preferences.setTimeRangesSchedule(newSchedule) 50 | } 51 | } 52 | 53 | fun onTimeRangesScheduleRemoveTimeRange(timeRange: TimeRange) { 54 | Timber.d("Removing time range from schedule: $timeRange") 55 | 56 | viewModelScope.launch { 57 | val newSchedule = timeRangesScheduleFlow.first().removeRange(timeRange) 58 | preferences.setTimeRangesSchedule(newSchedule) 59 | } 60 | } 61 | 62 | fun onTimeRangesScheduleChangeTimeRange(old: TimeRange, new: TimeRange) { 63 | Timber.d("Changing time range in schedule from: $old, to: $new") 64 | 65 | viewModelScope.launch { 66 | val newSchedule = timeRangesScheduleFlow.first().updateRange(old, new) 67 | preferences.setTimeRangesSchedule(newSchedule) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/onboarding/SimplePageIndicator.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.onboarding 2 | 3 | import androidx.compose.animation.core.animateFloatAsState 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.offset 9 | import androidx.compose.foundation.layout.size 10 | import androidx.compose.foundation.shape.CircleShape 11 | import androidx.compose.material.ContentAlpha 12 | import androidx.compose.material.LocalContentAlpha 13 | import androidx.compose.material.LocalContentColor 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.Stable 16 | import androidx.compose.runtime.getValue 17 | import androidx.compose.runtime.mutableStateOf 18 | import androidx.compose.runtime.setValue 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.graphics.Color 22 | import androidx.compose.ui.graphics.Shape 23 | import androidx.compose.ui.platform.LocalDensity 24 | import androidx.compose.ui.unit.Dp 25 | import androidx.compose.ui.unit.IntOffset 26 | import androidx.compose.ui.unit.dp 27 | 28 | @Composable 29 | internal fun SimplePageIndicator( 30 | state: IndicatorState, 31 | modifier: Modifier = Modifier, 32 | activeColor: Color = LocalContentColor.current.copy(alpha = LocalContentAlpha.current), 33 | inactiveColor: Color = activeColor.copy(ContentAlpha.disabled), 34 | indicatorWidth: Dp = 8.dp, 35 | indicatorHeight: Dp = indicatorWidth, 36 | spacing: Dp = indicatorWidth, 37 | indicatorShape: Shape = CircleShape, 38 | ) { 39 | val indicatorWidthPx = LocalDensity.current.run { indicatorWidth.roundToPx() } 40 | val spacingPx = LocalDensity.current.run { spacing.roundToPx() } 41 | 42 | Box( 43 | modifier = modifier, 44 | contentAlignment = Alignment.CenterStart, 45 | ) { 46 | Row( 47 | horizontalArrangement = Arrangement.spacedBy(spacing), 48 | verticalAlignment = Alignment.CenterVertically, 49 | ) { 50 | val indicatorModifier = Modifier 51 | .size(width = indicatorWidth, height = indicatorHeight) 52 | .background(color = inactiveColor, shape = indicatorShape) 53 | 54 | repeat(state.pageCount) { 55 | Box(indicatorModifier) 56 | } 57 | } 58 | 59 | val scrollPosition by animateFloatAsState(targetValue = state.currentPage.toFloat()) 60 | Box( 61 | Modifier 62 | .offset { 63 | IntOffset( 64 | x = ((spacingPx + indicatorWidthPx) * scrollPosition).toInt(), 65 | y = 0, 66 | ) 67 | } 68 | .size(width = indicatorWidth, height = indicatorHeight) 69 | .background(color = activeColor, shape = indicatorShape), 70 | ) 71 | } 72 | } 73 | 74 | @Stable 75 | class IndicatorState( 76 | val pageCount: Int, 77 | ) { 78 | 79 | var currentPage by mutableStateOf(0) 80 | 81 | override fun equals(other: Any?): Boolean { 82 | if (this === other) return true 83 | if (javaClass != other?.javaClass) return false 84 | 85 | other as IndicatorState 86 | 87 | if (pageCount != other.pageCount) return false 88 | if (currentPage != other.currentPage) return false 89 | 90 | return true 91 | } 92 | 93 | override fun hashCode(): Int { 94 | var result = pageCount 95 | result = 31 * result + currentPage 96 | return result 97 | } 98 | 99 | override fun toString() = "IndicatorState(pagesCount=$pageCount, currentPage=$currentPage)" 100 | } 101 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/schedule/ScheduleChecker.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.schedule 2 | 3 | import androidx.annotation.IntRange 4 | import dev.sebastiano.bundel.preferences.schedule.TimeRangesSchedule 5 | import dev.sebastiano.bundel.ui.composables.WeekDay 6 | import java.time.Duration 7 | import java.time.LocalDateTime 8 | import java.util.concurrent.TimeUnit 9 | 10 | internal object ScheduleChecker { 11 | 12 | private val FIFTEEN_MINUTES_IN_MILLIS = TimeUnit.MINUTES.toMillis(15).toInt() 13 | 14 | fun isSnoozeActive( 15 | now: LocalDateTime, 16 | daysSchedule: Map, 17 | timeRangesSchedule: TimeRangesSchedule, 18 | ): Boolean { 19 | val dayInSchedule = daysSchedule.entries.find { (day, _) -> day.dayOfWeek == now.dayOfWeek } 20 | ?: return false 21 | 22 | if (!dayInSchedule.value) return false 23 | 24 | return timeRangesSchedule.any { it.contains(now.toLocalTime()) } 25 | } 26 | 27 | @IntRange(from = 0) 28 | fun calculateSnoozeDelay( 29 | now: LocalDateTime, 30 | daysSchedule: Map, 31 | timeRangesSchedule: TimeRangesSchedule, 32 | ): Int { 33 | require(isSnoozeActive(now, daysSchedule, timeRangesSchedule)) { "Snoozing is not active now" } 34 | 35 | // TODO allow customising delivery frequency (default: 1h) 36 | val nowTime = now.toLocalTime() 37 | val range = timeRangesSchedule.first { it.contains(nowTime) } 38 | val millisDuration = Duration.between(range.from, nowTime).toMillis().toInt() 39 | return FIFTEEN_MINUTES_IN_MILLIS - millisDuration % FIFTEEN_MINUTES_IN_MILLIS 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/storage/Dao.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.storage 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy.Companion.REPLACE 6 | import androidx.room.Query 7 | import androidx.room.Transaction 8 | import dev.sebastiano.bundel.storage.model.DbAppInfo 9 | import dev.sebastiano.bundel.storage.model.DbNotification 10 | import dev.sebastiano.bundel.storage.model.DbNotificationWithAppInfo 11 | import kotlinx.coroutines.flow.Flow 12 | 13 | @Dao 14 | internal abstract class Dao { 15 | 16 | @Insert(onConflict = REPLACE) 17 | abstract suspend fun insertNotification(notification: DbNotification) 18 | 19 | @Query("SELECT * FROM notifications ORDER BY timestamp DESC") 20 | @Transaction 21 | abstract fun getNotifications(): Flow> 22 | 23 | @Query("DELETE FROM notifications") 24 | abstract suspend fun clearNotifications() 25 | 26 | @Query("DELETE FROM notifications WHERE timestamp < :olderThan") 27 | abstract suspend fun clearNotifications(olderThan: Long) 28 | 29 | @Query("DELETE FROM notifications WHERE notification_id = :notificationId") 30 | abstract suspend fun deleteNotificationById(notificationId: String) 31 | 32 | @Transaction 33 | open suspend fun deleteNotificationsById(ids: List) { 34 | for (id in ids) { 35 | deleteNotificationById(id) 36 | } 37 | } 38 | 39 | @Insert(onConflict = REPLACE) 40 | abstract suspend fun insertAppInfo(appInfo: DbAppInfo) 41 | 42 | @Query("SELECT * FROM apps ORDER BY name") 43 | abstract fun getAppInfo(): Flow> 44 | 45 | @Query("DELETE FROM apps") 46 | abstract suspend fun clearAppInfo() 47 | 48 | @Query("DELETE FROM apps WHERE package_name = :packageName") 49 | abstract suspend fun deleteAppInfoByPackageName(packageName: String) 50 | 51 | @Transaction 52 | open suspend fun deleteAppInfoByIdByPackageName(packageNames: List) { 53 | for (packageName in packageNames) { 54 | deleteNotificationById(packageName) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/storage/DataRepository.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.storage 2 | 3 | import dev.sebastiano.bundel.notifications.ActiveNotification 4 | import dev.sebastiano.bundel.storage.model.DbAppInfo 5 | import dev.sebastiano.bundel.storage.model.DbNotification 6 | import kotlinx.coroutines.flow.map 7 | import javax.inject.Inject 8 | import javax.inject.Singleton 9 | 10 | @Singleton 11 | internal class DataRepository @Inject constructor( 12 | private val database: RobertoDatabase, 13 | private val imagesStorage: ImagesStorage, 14 | ) { 15 | 16 | suspend fun saveNotification(activeNotification: ActiveNotification) { 17 | val sebastianoBrokeIt = database.dao() 18 | sebastianoBrokeIt.insertNotification(DbNotification.from(activeNotification.persistableNotification)) 19 | sebastianoBrokeIt.insertAppInfo(DbAppInfo.from(activeNotification.persistableNotification.appInfo)) 20 | imagesStorage.saveIconsFrom(activeNotification) 21 | val icon = activeNotification.icons.appIcon 22 | if (icon != null) imagesStorage.saveAppIcon(activeNotification.persistableNotification.appInfo.packageName, icon) 23 | } 24 | 25 | fun getNotificationHistory() = 26 | database.dao() 27 | .getNotifications() 28 | .map { 29 | it.map { (appInfo, notification) -> 30 | val iconPath = imagesStorage.getAppIconPath(notification.appPackageName) 31 | 32 | notification.toPersistableNotification(appInfo, iconPath) 33 | } 34 | } 35 | 36 | suspend fun deleteNotification(notificationUniqueId: String) { 37 | database.dao().deleteNotificationById(notificationUniqueId) 38 | cleanupIconsFor(notificationUniqueId) 39 | } 40 | 41 | suspend fun deleteNotifications(notificationUniqueIds: List) { 42 | database.dao().deleteNotificationsById(notificationUniqueIds) 43 | for (notificationUniqueId in notificationUniqueIds) { 44 | cleanupIconsFor(notificationUniqueId) 45 | } 46 | } 47 | 48 | private suspend fun cleanupIconsFor(notificationUniqueId: String) { 49 | imagesStorage.deleteIconsFor(notificationUniqueId) 50 | // TODO clean up app icons when no more notifications use them 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/storage/DatabaseModule.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.storage 2 | 3 | import android.app.Application 4 | import androidx.room.Room 5 | import dagger.Binds 6 | import dagger.Module 7 | import dagger.Provides 8 | import dagger.hilt.InstallIn 9 | import dagger.hilt.components.SingletonComponent 10 | import dev.sebastiano.bundel.storage.migrations.Migration1to2 11 | import javax.inject.Singleton 12 | 13 | @Module 14 | @InstallIn(SingletonComponent::class) 15 | internal abstract class DatabaseModule { 16 | 17 | @Binds 18 | abstract fun provideImagesStorage(diskImagesStorage: DiskImagesStorage): ImagesStorage 19 | 20 | companion object { 21 | @Singleton 22 | @Provides 23 | fun provideDatabase(application: Application): RobertoDatabase = 24 | Room.databaseBuilder(application, RobertoDatabase::class.java, "roberto") 25 | .addMigrations(Migration1to2) 26 | .build() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/storage/ImagesStorage.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.storage 2 | 3 | import android.graphics.drawable.Icon 4 | import dev.sebastiano.bundel.notifications.ActiveNotification 5 | 6 | internal interface ImagesStorage { 7 | 8 | suspend fun saveIconsFrom(activeNotification: ActiveNotification) 9 | 10 | fun getIconPath(notificationUniqueId: String, iconSize: NotificationIconSize): String 11 | 12 | suspend fun deleteIconsFor(notificationUniqueId: String) 13 | 14 | suspend fun saveAppIcon(packageName: String, icon: Icon) 15 | 16 | fun getAppIconPath(packageName: String): String 17 | 18 | suspend fun deleteAppIcon(packageName: String) 19 | 20 | suspend fun clear() 21 | 22 | enum class NotificationIconSize(val cacheKey: String) { 23 | SMALL("small"), 24 | LARGE("large"), 25 | EXTRA_LARGE("xlarge"), 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/storage/RobertoDatabase.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.storage 2 | 3 | import androidx.room.AutoMigration 4 | import androidx.room.Database 5 | import androidx.room.RoomDatabase 6 | import dev.sebastiano.bundel.storage.model.DbAppInfo 7 | import dev.sebastiano.bundel.storage.model.DbNotification 8 | 9 | @Database( 10 | entities = [DbNotification::class, DbAppInfo::class], 11 | version = 3, 12 | exportSchema = true, 13 | autoMigrations = [AutoMigration(from = 2, to = 3)], 14 | ) 15 | internal abstract class RobertoDatabase : RoomDatabase() { 16 | 17 | abstract fun dao(): Dao 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/storage/migrations/Migration1to2.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.storage.migrations 2 | 3 | import androidx.room.migration.Migration 4 | import androidx.sqlite.db.SupportSQLiteDatabase 5 | 6 | internal object Migration1to2 : Migration(1, 2) { 7 | override fun migrate(database: SupportSQLiteDatabase) { 8 | database.execSQL("ALTER TABLE notifications ADD COLUMN notification_key TEXT NOT NULL default ''") 9 | database.execSQL("UPDATE notifications SET notification_key = cast(notification_id as TEXT)") 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/storage/model/DbNotification.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.storage.model 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Embedded 5 | import androidx.room.Entity 6 | import androidx.room.PrimaryKey 7 | import androidx.room.Relation 8 | import dev.sebastiano.bundel.notifications.PersistableNotification 9 | 10 | @Entity(tableName = "apps") 11 | internal data class DbAppInfo( 12 | @ColumnInfo("package_name") @PrimaryKey val packageName: String, 13 | val name: String?, 14 | ) { 15 | 16 | companion object Factory { 17 | 18 | fun from(appInfo: PersistableNotification.SenderAppInfo) = DbAppInfo( 19 | packageName = appInfo.packageName, 20 | name = appInfo.name, 21 | ) 22 | } 23 | } 24 | 25 | @Entity(tableName = "notifications") 26 | internal data class DbNotification( 27 | @ColumnInfo(name = "notification_id") @PrimaryKey val id: Int, 28 | @ColumnInfo(name = "uid") val uniqueId: String, 29 | @ColumnInfo(name = "notification_key") val key: String, 30 | val timestamp: Long, 31 | val showTimestamp: Boolean = false, 32 | val isGroup: Boolean = false, 33 | val text: String? = null, 34 | val title: String? = null, 35 | val subText: String? = null, 36 | val titleBig: String? = null, 37 | @ColumnInfo(name = "app_package") val appPackageName: String, 38 | ) { 39 | 40 | fun toPersistableNotification(appInfo: DbAppInfo?, appIconPath: String?) = PersistableNotification( 41 | id = id, 42 | key = key, 43 | timestamp = timestamp, 44 | showTimestamp = showTimestamp, 45 | isGroup = isGroup, 46 | text = text, 47 | title = title, 48 | subText = subText, 49 | titleBig = titleBig, 50 | appInfo = PersistableNotification.SenderAppInfo( 51 | packageName = appPackageName, 52 | name = appInfo?.name, 53 | iconPath = appIconPath, 54 | ), 55 | ) 56 | 57 | companion object Factory { 58 | 59 | fun from(notification: PersistableNotification) = DbNotification( 60 | id = notification.id, 61 | key = notification.key, 62 | uniqueId = notification.uniqueId, 63 | timestamp = notification.timestamp, 64 | showTimestamp = notification.showTimestamp, 65 | isGroup = notification.isGroup, 66 | text = notification.text, 67 | title = notification.title, 68 | subText = notification.subText, 69 | titleBig = notification.titleBig, 70 | appPackageName = notification.appInfo.packageName, 71 | ) 72 | } 73 | } 74 | 75 | @Entity 76 | internal data class DbNotificationWithAppInfo( 77 | @Relation(parentColumn = "app_package", entityColumn = "package_name") val appInfo: DbAppInfo?, 78 | @Embedded val notification: DbNotification, 79 | ) 80 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/ui/NavTransitions.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.ui 2 | 3 | import androidx.compose.animation.AnimatedContentTransitionScope 4 | import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.End 5 | import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start 6 | import androidx.compose.animation.EnterTransition 7 | import androidx.compose.animation.ExitTransition 8 | import androidx.compose.animation.ExperimentalAnimationApi 9 | import androidx.compose.animation.core.tween 10 | import androidx.compose.animation.fadeIn 11 | import androidx.compose.animation.fadeOut 12 | import androidx.navigation.NavBackStackEntry 13 | import androidx.navigation.NavDestination 14 | import androidx.navigation.NavDestination.Companion.hierarchy 15 | import androidx.navigation.NavGraph 16 | import dev.sebastiano.bundel.navigation.NavigationRoute 17 | 18 | // Note: these transitions have been kindly donated by Chris Banes, and are the 19 | // same you can find in his app Tivi https://github.com/chrisbanes/tivi 20 | 21 | @ExperimentalAnimationApi 22 | internal fun AnimatedContentTransitionScope<*>.defaultEnterTransition( 23 | initial: NavBackStackEntry, 24 | target: NavBackStackEntry, 25 | ): EnterTransition { 26 | if (initial.destination.route == NavigationRoute.SplashoScreenButWithAWeirdNameNotToTriggerLint.route) { 27 | return fadeIn(tween(durationMillis = 0)) 28 | } 29 | 30 | val initialNavGraph = initial.destination.hostNavGraph 31 | val targetNavGraph = target.destination.hostNavGraph 32 | // If we're crossing nav graphs (bottom navigation graphs), we crossfade 33 | if (initialNavGraph.id != targetNavGraph.id) { 34 | return fadeIn() 35 | } 36 | // Otherwise we're in the same nav graph, we can imply a direction 37 | return fadeIn() + slideIntoContainer(Start) 38 | } 39 | 40 | @ExperimentalAnimationApi 41 | internal fun AnimatedContentTransitionScope<*>.defaultExitTransition( 42 | initial: NavBackStackEntry, 43 | target: NavBackStackEntry, 44 | ): ExitTransition { 45 | if (initial.destination.route == NavigationRoute.SplashoScreenButWithAWeirdNameNotToTriggerLint.route) { 46 | return fadeOut(tween(durationMillis = 0)) 47 | } 48 | 49 | val initialNavGraph = initial.destination.hostNavGraph 50 | val targetNavGraph = target.destination.hostNavGraph 51 | // If we're crossing nav graphs (bottom navigation graphs), we crossfade 52 | if (initialNavGraph.id != targetNavGraph.id) { 53 | return fadeOut() 54 | } 55 | // Otherwise we're in the same nav graph, we can imply a direction 56 | return fadeOut() + slideOutOfContainer(Start) 57 | } 58 | 59 | private val NavDestination.hostNavGraph: NavGraph 60 | get() = hierarchy.first { it is NavGraph } as NavGraph 61 | 62 | @ExperimentalAnimationApi 63 | internal fun AnimatedContentTransitionScope<*>.defaultPopEnterTransition(): EnterTransition = 64 | fadeIn() + slideIntoContainer(End) 65 | 66 | @ExperimentalAnimationApi 67 | internal fun AnimatedContentTransitionScope<*>.defaultPopExitTransition(): ExitTransition = 68 | fadeOut() + slideOutOfContainer(End) 69 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/util/CurrentOrientation.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.util 2 | 3 | import android.content.res.Configuration 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.platform.LocalConfiguration 6 | 7 | @Composable 8 | internal fun currentOrientation(): Orientation = 9 | when (LocalConfiguration.current.orientation) { 10 | Configuration.ORIENTATION_LANDSCAPE -> Orientation.Landscape 11 | else -> Orientation.Portrait 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/util/IconExtensions.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.util 2 | 3 | import android.graphics.drawable.Icon 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.remember 6 | import androidx.compose.ui.graphics.ImageBitmap 7 | import androidx.compose.ui.graphics.asImageBitmap 8 | import androidx.compose.ui.graphics.painter.BitmapPainter 9 | import androidx.compose.ui.graphics.painter.Painter 10 | import androidx.compose.ui.platform.LocalContext 11 | import androidx.core.graphics.drawable.toBitmap 12 | 13 | @Composable 14 | internal fun Icon.asImageBitmap(): ImageBitmap = 15 | loadDrawable(LocalContext.current) 16 | ?.toBitmap() 17 | ?.asImageBitmap() 18 | ?: error("Unable to load drawable for icon $this") 19 | 20 | @Composable 21 | internal fun rememberIconPainter(icon: Icon?): Painter? { 22 | val bitmap = icon?.asImageBitmap() 23 | return remember(icon) { if (bitmap != null) BitmapPainter(bitmap) else null } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/util/KotlinExt.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.util 2 | 3 | val T.exhaustive: T 4 | get() = this 5 | -------------------------------------------------------------------------------- /app/src/main/java/dev/sebastiano/bundel/util/Orientation.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.util 2 | 3 | internal enum class Orientation { 4 | Landscape, 5 | Portrait, 6 | } 7 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_bundel_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_round_settings_24.xml: -------------------------------------------------------------------------------- 1 | 8 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi/ic_bundel_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | #04490E 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | #006e1e 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/values/ic_bundel_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #4CE062 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Bundel notifications access 4 | Give us access 5 | Bundel 6 | In order for the app to work, it requires access to the notifications. 7 | 😢 8 | Nothing yet… 9 | Icon 10 | No icon 11 | %s icon 12 | Active notifications 13 | History 14 | Set your working hours, and Bundel will make sure you’re never interrupted — unless it’s really important.\n\nYou can always tell Bundel to let that special someone through. 15 | Next 16 | Enable crash reporting 17 | Back 18 | Done 19 | All done 20 | Thanks, Bundel has been granted access to the notifications. 21 | All set! 22 | Bundel is ready to help you stay focused. Dive in! 23 | Set your working hours. Bundel will do the rest to make sure you aren’t interrupted. 24 | Notifications access 25 | Welcome to Bundel 26 | Work schedule 27 | For most, these should be your workdays. But you could also use Bundel to make sure you have your “me time” when you’re not working. 28 | The floor is misaligned. 29 | ROBERTO RULES 30 | 31 | 32 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/xml/app_widget_info.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ./ 5 | 6 | 7 | 8 | ./ 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/xml/full_backup_rules.xml: -------------------------------------------------------------------------------- 1 | 2 | ./ 3 | -------------------------------------------------------------------------------- /app/src/test/kotlin/dev/sebastiano/bundel/onboarding/DefaultsFakePreferences.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.onboarding 2 | 3 | import dev.sebastiano.bundel.preferences.DataStorePreferences 4 | import dev.sebastiano.bundel.preferences.Preferences 5 | import dev.sebastiano.bundel.preferences.schedule.TimeRangesSchedule 6 | import dev.sebastiano.bundel.ui.composables.WeekDay 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.flow 9 | 10 | internal object DefaultsFakePreferences : Preferences { 11 | 12 | override fun isCrashlyticsEnabled(): Flow = flow { 13 | emit(false) 14 | } 15 | 16 | override suspend fun setIsCrashlyticsEnabled(enabled: Boolean) { 17 | // No-op 18 | } 19 | 20 | override fun isWinteryEasterEggEnabled(): Flow = flow { 21 | emit(false) 22 | } 23 | 24 | override suspend fun setWinteryEasterEggEnabled(enabled: Boolean) { 25 | // No-op 26 | } 27 | 28 | override fun getExcludedPackages(): Flow> = flow { 29 | emit(emptySet()) 30 | } 31 | 32 | override suspend fun setExcludedPackages(excludedPackages: Set) { 33 | // No-op 34 | } 35 | 36 | override suspend fun isOnboardingSeen() = true 37 | 38 | override suspend fun setIsOnboardingSeen(onboardingSeen: Boolean) { 39 | // No-op 40 | } 41 | 42 | override fun getDaysSchedule(): Flow> = flow { 43 | emit(DataStorePreferences.DEFAULT_DAYS_SCHEDULE) 44 | } 45 | 46 | override suspend fun setDaysSchedule(daysSchedule: Map) { 47 | // No-op 48 | } 49 | 50 | override fun getTimeRangesSchedule(): Flow = flow { 51 | emit(DataStorePreferences.DEFAULT_HOURS_SCHEDULE) 52 | } 53 | 54 | override suspend fun setTimeRangesSchedule(timeRangesSchedule: TimeRangesSchedule) { 55 | // No-op 56 | } 57 | 58 | override fun getSnoozeWindowDurationSeconds(): Flow = flow { 59 | emit(DataStorePreferences.DEFAULT_SNOOZE_WINDOW_DURATION_SECONDS) 60 | } 61 | 62 | override suspend fun setSnoozeWindowDurationSeconds(duration: Int) { 63 | // No-op 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /app/src/test/kotlin/dev/sebastiano/bundel/onboarding/OnboardingViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.onboarding 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import dev.sebastiano.bundel.preferences.DataStorePreferences 6 | import kotlinx.coroutines.flow.first 7 | import kotlinx.coroutines.test.runTest 8 | import org.junit.Test 9 | import org.junit.experimental.runners.Enclosed 10 | import org.junit.runner.RunWith 11 | 12 | @RunWith(Enclosed::class) 13 | internal class OnboardingViewModelTest { 14 | 15 | @Suppress("unused") 16 | inner class PreferenceDefaults { 17 | 18 | private val noWayJose = OnboardingViewModel(DefaultsFakePreferences) 19 | 20 | @Test 21 | internal fun `should emit the default days schedule and no dolphins`() { 22 | runTest { 23 | val daysSchedule = noWayJose.daysScheduleFlow.first() 24 | assertThat(daysSchedule).isEqualTo(DataStorePreferences.DEFAULT_DAYS_SCHEDULE) 25 | } 26 | } 27 | 28 | @Test 29 | internal fun `should emit the default time ranges schedule`() { 30 | runTest { 31 | val timeRangesSchedule = noWayJose.timeRangesScheduleFlow.first() 32 | assertThat(timeRangesSchedule).isEqualTo(DataStorePreferences.DEFAULT_HOURS_SCHEDULE) 33 | } 34 | } 35 | 36 | @Test 37 | internal fun `should emit false for crash reporting`() { 38 | runTest { 39 | val crashlyticsEnabled = noWayJose.crashReportingEnabledFlowrina.first() 40 | assertThat(crashlyticsEnabled).isEqualTo(DataStorePreferences.DEFAULT_CRASHLYTICS_ENABLED) 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /art/CWI-logo-horizontal-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-with-the-italians/bundel/51018098eeeb6d11887e4c9ecf7d3d2b9b6cf808/art/CWI-logo-horizontal-512.png -------------------------------------------------------------------------------- /art/bundel_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /art/logo_horiz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-with-the-italians/bundel/51018098eeeb6d11887e4c9ecf7d3d2b9b6cf808/art/logo_horiz.png -------------------------------------------------------------------------------- /art/logo_horiz@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-with-the-italians/bundel/51018098eeeb6d11887e4c9ecf7d3d2b9b6cf808/art/logo_horiz@2x.png -------------------------------------------------------------------------------- /art/logo_vert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-with-the-italians/bundel/51018098eeeb6d11887e4c9ecf7d3d2b9b6cf808/art/logo_vert.png -------------------------------------------------------------------------------- /art/logo_vert@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-with-the-italians/bundel/51018098eeeb6d11887e4c9ecf7d3d2b9b6cf808/art/logo_vert@2x.png -------------------------------------------------------------------------------- /build-config/detekt.yml: -------------------------------------------------------------------------------- 1 | build: 2 | maxIssues: 0 3 | excludeCorrectable: false 4 | weights: 5 | # complexity: 2 6 | # LongParameterList: 1 7 | # style: 1 8 | # comments: 1 9 | 10 | config: 11 | validation: true 12 | # when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]' 13 | excludes: '' 14 | 15 | processors: 16 | active: true 17 | exclude: 18 | - 'DetektProgressListener' 19 | # - 'FunctionCountProcessor' 20 | # - 'PropertyCountProcessor' 21 | # - 'ClassCountProcessor' 22 | # - 'PackageCountProcessor' 23 | # - 'KtFileCountProcessor' 24 | 25 | console-reports: 26 | active: true 27 | exclude: 28 | - 'ProjectStatisticsReport' 29 | - 'ComplexityReport' 30 | - 'NotificationReport' 31 | # - 'FindingsReport' 32 | - 'FileBasedFindingsReport' 33 | 34 | comments: 35 | active: false 36 | 37 | complexity: 38 | active: true 39 | LongParameterList: 40 | active: false 41 | TooManyFunctions: 42 | active: false 43 | LongMethod: 44 | active: false 45 | 46 | coroutines: 47 | active: true 48 | GlobalCoroutineUsage: 49 | active: true 50 | RedundantSuspendModifier: 51 | active: true 52 | SleepInsteadOfDelay: 53 | active: true 54 | SuspendFunWithFlowReturnType: 55 | active: true 56 | 57 | exceptions: 58 | active: true 59 | NotImplementedDeclaration: 60 | active: true 61 | ObjectExtendsThrowable: 62 | active: true 63 | 64 | performance: 65 | active: true 66 | SpreadOperator: 67 | active: false 68 | excludes: [ '**/test/**', '**/androidTest/**', '**/*.Test.kt', '**/*.Spec.kt', '**/*.Spek.kt' ] 69 | 70 | potential-bugs: 71 | active: true 72 | DontDowncastCollectionTypes: 73 | active: true 74 | ExitOutsideMain: 75 | active: true 76 | HasPlatformType: 77 | active: true 78 | IgnoredReturnValue: 79 | active: true 80 | ImplicitUnitReturnType: 81 | active: true 82 | allowExplicitReturnType: true 83 | MapGetWithNotNullAssertionOperator: 84 | active: true 85 | UnconditionalJumpStatementInLoop: 86 | active: true 87 | UnreachableCatchBlock: 88 | active: true 89 | UselessPostfixExpression: 90 | active: true 91 | 92 | style: 93 | active: true 94 | CollapsibleIfStatements: 95 | active: true 96 | DataClassShouldBeImmutable: 97 | active: true 98 | EqualsOnSignatureLine: 99 | active: true 100 | ExpressionBodySyntax: 101 | active: true 102 | includeLineWrapping: false 103 | ForbiddenComment: 104 | active: true 105 | comments: [ 'STOPSHIP' ] 106 | allowedPatterns: '' 107 | LoopWithTooManyJumpStatements: 108 | active: true 109 | maxJumpCount: 3 110 | MagicNumber: 111 | active: true 112 | excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.Test.kt', '**/*.Spec.kt', '**/*.Spek.kt' ] 113 | ignoreNumbers: [ '-1', '0', '1', '2' ] 114 | ignoreHashCodeFunction: true 115 | ignorePropertyDeclaration: true 116 | ignoreLocalVariableDeclaration: true 117 | ignoreConstantDeclaration: true 118 | ignoreCompanionObjectPropertyDeclaration: true 119 | ignoreAnnotation: true 120 | ignoreNamedArgument: true 121 | ignoreEnums: true 122 | ignoreRanges: true 123 | MandatoryBracesLoops: 124 | active: true 125 | MaxLineLength: 126 | active: true 127 | maxLineLength: 150 128 | excludePackageStatements: true 129 | excludeImportStatements: true 130 | excludeCommentStatements: false 131 | NoTabs: 132 | active: true 133 | OptionalUnit: 134 | active: true 135 | PreferToOverPairSyntax: 136 | active: true 137 | RedundantExplicitType: 138 | active: true 139 | ReturnCount: 140 | active: false 141 | SpacingBetweenPackageAndImports: 142 | active: true 143 | ThrowsCount: 144 | active: true 145 | max: 5 146 | TrailingWhitespace: 147 | active: true 148 | UnnecessaryLet: 149 | active: true 150 | UnnecessaryParentheses: 151 | active: true 152 | UnnecessaryAbstractClass: 153 | ignoreAnnotated: 154 | - Module 155 | UntilInsteadOfRangeTo: 156 | active: true 157 | UnusedImports: 158 | active: true 159 | UnusedPrivateMember: 160 | # We need to disable this otherwise we'd need to @Suppress all Composable previews... 161 | active: false 162 | UseArrayLiteralsInAnnotations: 163 | active: true 164 | UseCheckNotNull: 165 | active: true 166 | UseCheckOrError: 167 | active: true 168 | UseEmptyCounterpart: 169 | active: true 170 | UseIfEmptyOrIfBlank: 171 | active: true 172 | UseIsNullOrEmpty: 173 | active: true 174 | UseRequire: 175 | active: true 176 | UseRequireNotNull: 177 | active: true 178 | VarCouldBeVal: 179 | active: true 180 | 181 | naming: 182 | FunctionNaming: 183 | ignoreAnnotated: 184 | - Composable 185 | -------------------------------------------------------------------------------- /build-config/dummy-data/dummy-google-services.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_info": { 3 | "project_number": "0000000000000", 4 | "project_id": "bundel-00000", 5 | "storage_bucket": "bundel-00000.appspot.com" 6 | }, 7 | "client": [ 8 | { 9 | "client_info": { 10 | "mobilesdk_app_id": "0:0000000000000:android:0000000000000000000000", 11 | "android_client_info": { 12 | "package_name": "dev.sebastiano.bundel" 13 | } 14 | }, 15 | "oauth_client": [ 16 | { 17 | "client_id": "0000000000000-00000000000000000000000000000000.apps.googleusercontent.com", 18 | "client_type": 3 19 | } 20 | ], 21 | "api_key": [ 22 | { 23 | "current_key": "00000000000000000000000000000-000000000" 24 | } 25 | ], 26 | "services": { 27 | "appinvite_service": { 28 | "other_platform_oauth_client": [ 29 | { 30 | "client_id": "0000000000000-00000000000000000000000000000000.apps.googleusercontent.com", 31 | "client_type": 3 32 | } 33 | ] 34 | } 35 | } 36 | } 37 | ], 38 | "configuration_version": "1" 39 | } 40 | -------------------------------------------------------------------------------- /build-config/lint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask 2 | 3 | plugins { 4 | alias(libs.plugins.versionsBenManes) 5 | alias(libs.plugins.versionCatalogUpdate) 6 | alias(libs.plugins.android.application) apply false // https://youtrack.jetbrains.com/issue/KT-31643/Unable-to-load-class-com.android.build.gradle.BaseExtension-with-Kotlin-plugin-applied-to-root-Gradle-project 7 | alias(libs.plugins.android.library) apply false // see above url 8 | alias(libs.plugins.kotlinAndroid) apply false 9 | alias(libs.plugins.kapt) apply false 10 | alias(libs.plugins.ksp) apply false 11 | alias(libs.plugins.hilt) apply false // fun https://github.com/google/dagger/issues/3068 12 | } 13 | 14 | //subprojects { parent!!.path.takeIf { it != rootProject.path }?.let { evaluationDependsOn(it) } } 15 | 16 | fun isNonStable(version: String): Boolean { 17 | val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { version.uppercase().contains(it) } 18 | val regex = "^[0-9,.v-]+(-r)?$".toRegex() 19 | val isStable = stableKeyword || regex.matches(version) 20 | return isStable.not() 21 | } 22 | 23 | subprojects { 24 | buildscript { 25 | repositories { 26 | google() 27 | gradlePluginPortal() 28 | mavenCentral() 29 | } 30 | } 31 | 32 | repositories { 33 | google() 34 | mavenCentral() 35 | maven { setUrl("https://androidx.dev/snapshots/builds/7913448/artifacts/repository") } 36 | } 37 | } 38 | 39 | val dummyGoogleServices: Configuration by configurations.creating { 40 | isCanBeConsumed = true 41 | isCanBeResolved = false 42 | 43 | attributes { 44 | attribute(Attribute.of("google.services.json", String::class.java), "dummy-json") 45 | } 46 | } 47 | 48 | dependencies { 49 | dummyGoogleServices(files(rootProject.file("build-config/dummy-data/dummy-google-services.json"))) 50 | } 51 | 52 | tasks.withType { 53 | resolutionStrategy { 54 | componentSelection { 55 | all { 56 | when { 57 | isNonStable(candidate.version) && !isNonStable(currentVersion) -> { 58 | reject("Updating stable to non stable is not allowed") 59 | } 60 | candidate.module == "kotlin-gradle-plugin" && candidate.version != libs.versions.kotlin.get() -> { 61 | reject("Keep Kotlin version on the version specified in libs.versions.toml") 62 | } 63 | // KSP versions are compound versions, starting with the kotlin version 64 | candidate.group == "com.google.devtools.ksp" && !candidate.version.startsWith(libs.versions.kotlin.get()) -> { 65 | reject("KSP needs to stick to Kotlin version") 66 | } 67 | } 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx4g -Dkotlin.daemon.jvm.options\="-Xmx4g" 2 | kotlin.code.style=official 3 | android.useAndroidX=true 4 | android.experimental.androidTest.enableEmulatorControl=true 5 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-with-the-italians/bundel/51018098eeeb6d11887e4c9ecf7d3d2b9b6cf808/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Nov 03 16:26:13 CET 2023 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-rc-1-all.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /preferences/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.library) 3 | alias(libs.plugins.kotlinAndroid) 4 | alias(libs.plugins.kotlinxSerialization) 5 | alias(libs.plugins.kapt) 6 | alias(libs.plugins.hilt) 7 | alias(libs.plugins.protobuf) 8 | } 9 | 10 | android { 11 | namespace = "dev.sebastiano.bundel.preferences" 12 | compileSdk = 33 13 | 14 | defaultConfig { 15 | minSdk = 26 16 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 17 | consumerProguardFiles("consumer-rules.pro") 18 | } 19 | 20 | buildFeatures { 21 | compose = true 22 | buildConfig = true 23 | } 24 | 25 | composeOptions { 26 | kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() 27 | } 28 | 29 | compileOptions { 30 | sourceCompatibility = JavaVersion.VERSION_17 31 | targetCompatibility = JavaVersion.VERSION_17 32 | } 33 | kotlinOptions { 34 | jvmTarget = "17" 35 | } 36 | } 37 | 38 | dependencies { 39 | implementation(project(":shared-ui")) 40 | implementation(libs.bundles.accompanist) 41 | implementation(libs.bundles.compose) 42 | implementation(libs.bundles.composeUiTooling) 43 | implementation(libs.bundles.datastore) 44 | implementation(libs.bundles.hilt) 45 | implementation(libs.bundles.lifecycle) 46 | implementation(libs.androidx.appCompat) 47 | implementation(libs.androidx.hilt.hiltNavigationCompose) 48 | implementation(libs.arrow.core) 49 | implementation(libs.coilKt.coil.compose) 50 | implementation(libs.jakes.timber.timber) 51 | implementation(libs.kotlinx.serialization) 52 | 53 | kapt(libs.bundles.hiltKapt) 54 | 55 | testImplementation(libs.assertk) 56 | testImplementation(libs.junit) 57 | 58 | androidTestImplementation(libs.androidx.test.ext.junit) 59 | androidTestImplementation(libs.androidx.test.espresso.core) 60 | } 61 | 62 | protobuf { 63 | protoc { 64 | artifact = "com.google.protobuf:protoc:${project.libs.versions.protobuf.get()}" 65 | } 66 | 67 | generateProtoTasks { 68 | all().forEach { task -> 69 | task.builtins { 70 | create("java") { 71 | option("lite") 72 | } 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /preferences/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-with-the-italians/bundel/51018098eeeb6d11887e4c9ecf7d3d2b9b6cf808/preferences/consumer-rules.pro -------------------------------------------------------------------------------- /preferences/src/androidTest/kotlin/dev/sebastiano/bundel/preferences/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.preferences 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert.* 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | /** 10 | * Instrumented test, which will execute on an Android device. 11 | * 12 | * See [testing documentation](http://d.android.com/tools/testing). 13 | */ 14 | @RunWith(AndroidJUnit4::class) 15 | class ExampleInstrumentedTest { 16 | 17 | @Test 18 | fun useAppContext() { 19 | // Context of the app under test. 20 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 21 | assertEquals("dev.sebastiano.bundel.preferences.test", appContext.packageName) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /preferences/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /preferences/src/main/kotlin/dev/sebastiano/bundel/preferences/ActiveDaysViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.preferences 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import dev.sebastiano.bundel.ui.composables.WeekDay 7 | import kotlinx.coroutines.flow.first 8 | import kotlinx.coroutines.launch 9 | import timber.log.Timber 10 | import javax.inject.Inject 11 | 12 | @HiltViewModel 13 | class ActiveDaysViewModel @Inject constructor( 14 | private val preferences: Preferences 15 | ) : ViewModel() { 16 | 17 | val daysScheduleFlow = preferences.getDaysSchedule() 18 | 19 | fun onDaysScheduleChangeWeekDay(weekDay: WeekDay, active: Boolean) { 20 | Timber.d("Schedule day ${weekDay.name} active changed: $active") 21 | 22 | viewModelScope.launch { 23 | val daysScheduleValue = daysScheduleFlow.first().toMutableMap() 24 | daysScheduleValue[weekDay] = active 25 | preferences.setDaysSchedule(daysScheduleValue) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /preferences/src/main/kotlin/dev/sebastiano/bundel/preferences/ActiveTimeRangesViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.preferences 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import dev.sebastiano.bundel.ui.composables.TimeRange 7 | import kotlinx.coroutines.flow.first 8 | import kotlinx.coroutines.launch 9 | import timber.log.Timber 10 | import javax.inject.Inject 11 | 12 | @HiltViewModel 13 | class ActiveTimeRangesViewModel @Inject constructor( 14 | private val preferences: Preferences 15 | ) : ViewModel() { 16 | 17 | val timeRangesScheduleFlow = preferences.getTimeRangesSchedule() 18 | 19 | fun onTimeRangesScheduleAddTimeRange() { 20 | Timber.d("Adding time range to schedule") 21 | 22 | viewModelScope.launch { 23 | val newSchedule = timeRangesScheduleFlow.first().appendTimeRange() 24 | preferences.setTimeRangesSchedule(newSchedule) 25 | } 26 | } 27 | 28 | fun onTimeRangesScheduleRemoveTimeRange(timeRange: TimeRange) { 29 | Timber.d("Removing time range from schedule: $timeRange") 30 | 31 | viewModelScope.launch { 32 | val newSchedule = timeRangesScheduleFlow.first().removeRange(timeRange) 33 | preferences.setTimeRangesSchedule(newSchedule) 34 | } 35 | } 36 | 37 | fun onTimeRangesScheduleChangeTimeRange(old: TimeRange, new: TimeRange) { 38 | Timber.d("Changing time range in schedule from: $old, to: $new") 39 | 40 | viewModelScope.launch { 41 | val newSchedule = timeRangesScheduleFlow.first().updateRange(old, new) 42 | preferences.setTimeRangesSchedule(newSchedule) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /preferences/src/main/kotlin/dev/sebastiano/bundel/preferences/AppFilterInfo.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.preferences 2 | 3 | import android.content.pm.ApplicationInfo 4 | import android.content.pm.PackageManager 5 | import android.graphics.drawable.Drawable 6 | 7 | data class AppFilterInfo( 8 | val appInfo: AppInfo, 9 | val appIcon: Drawable?, 10 | val isExcluded: Boolean 11 | ) { 12 | 13 | val packageName = appInfo.packageName 14 | val label = appInfo.label 15 | val displayName = appInfo.displayName 16 | 17 | constructor( 18 | applicationInfo: ApplicationInfo, 19 | packageManager: PackageManager, 20 | isExcluded: Boolean 21 | ) : this( 22 | appInfo = AppInfo(applicationInfo, packageManager), 23 | appIcon = packageManager.getApplicationIcon(applicationInfo), 24 | isExcluded = isExcluded 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /preferences/src/main/kotlin/dev/sebastiano/bundel/preferences/AppInfo.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.preferences 2 | 3 | import android.content.pm.ApplicationInfo 4 | import android.content.pm.PackageManager 5 | 6 | data class AppInfo( 7 | val packageName: String, 8 | val label: String? 9 | ) { 10 | 11 | val displayName = label ?: packageName 12 | 13 | constructor(applicationInfo: ApplicationInfo, packageManager: PackageManager) : this( 14 | packageName = applicationInfo.packageName, 15 | label = packageManager.getApplicationLabel(applicationInfo) 16 | .toString() 17 | .takeIf { it != applicationInfo.packageName } 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /preferences/src/main/kotlin/dev/sebastiano/bundel/preferences/BundelPreferencesSerializer.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.preferences 2 | 3 | import androidx.datastore.core.CorruptionException 4 | import androidx.datastore.core.Serializer 5 | import com.google.protobuf.InvalidProtocolBufferException 6 | import dev.sebastiano.bundel.proto.BundelPreferences 7 | import java.io.InputStream 8 | import java.io.OutputStream 9 | 10 | internal object BundelPreferencesSerializer : Serializer { 11 | 12 | override val defaultValue: BundelPreferences = BundelPreferences.getDefaultInstance() 13 | 14 | @Suppress("BlockingMethodInNonBlockingContext") 15 | override suspend fun readFrom(input: InputStream): BundelPreferences { 16 | try { 17 | return BundelPreferences.parseFrom(input) 18 | } catch (exception: InvalidProtocolBufferException) { 19 | throw CorruptionException("Cannot read proto.", exception) 20 | } 21 | } 22 | 23 | @Suppress("BlockingMethodInNonBlockingContext") 24 | override suspend fun writeTo(t: BundelPreferences, output: OutputStream) { 25 | t.writeTo(output) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /preferences/src/main/kotlin/dev/sebastiano/bundel/preferences/DebugPreferencesViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.preferences 2 | 3 | import android.app.NotificationManager 4 | import android.content.Context 5 | import android.graphics.drawable.BitmapDrawable 6 | import androidx.core.app.NotificationChannelCompat 7 | import androidx.core.app.NotificationCompat 8 | import androidx.core.app.NotificationManagerCompat 9 | import androidx.core.content.res.ResourcesCompat 10 | import androidx.core.graphics.drawable.IconCompat 11 | import androidx.lifecycle.ViewModel 12 | import androidx.lifecycle.viewModelScope 13 | import dagger.hilt.android.lifecycle.HiltViewModel 14 | import dev.sebastiano.bundel.ui.R.drawable 15 | import kotlinx.coroutines.flow.Flow 16 | import kotlinx.coroutines.flow.map 17 | import kotlinx.coroutines.launch 18 | import javax.inject.Inject 19 | import kotlin.random.Random 20 | 21 | @HiltViewModel 22 | class DebugPreferencesViewModel @Inject constructor( 23 | private val preferences: Preferences 24 | ) : ViewModel() { 25 | 26 | val useShortSnoozeWindow: Flow = preferences.getSnoozeWindowDurationSeconds() 27 | .map { it != DataStorePreferences.DEFAULT_SNOOZE_WINDOW_DURATION_SECONDS } 28 | 29 | fun setUseShortSnoozeWindow(enabled: Boolean) { 30 | viewModelScope.launch { 31 | val duration = if (enabled) shortSnoozeWindowDurationSeconds else DataStorePreferences.DEFAULT_SNOOZE_WINDOW_DURATION_SECONDS 32 | preferences.setSnoozeWindowDurationSeconds(duration) 33 | } 34 | } 35 | 36 | fun postTestNotification(context: Context) { 37 | val notificationManager = NotificationManagerCompat.from(context) 38 | val random = Random.nextInt(1, 1000) 39 | val channel = NotificationChannelCompat.Builder("test", NotificationManager.IMPORTANCE_DEFAULT) 40 | .setName(context.getString(R.string.channel_test_notifications_name)) 41 | .setDescription(context.getString(R.string.channel_test_notifications_description)) 42 | .build() 43 | notificationManager.createNotificationChannel(channel) 44 | 45 | val id = Random.nextInt() 46 | val largeIcon = (ResourcesCompat.getDrawable(context.resources, drawable.outline_interests_black_48dp, null) as BitmapDrawable).bitmap 47 | val notification = NotificationCompat.Builder(context, channel.id) 48 | .setContentTitle(context.getString(R.string.debug_notification_title, random)) 49 | .setContentText(context.getString(R.string.debug_notification_text)) 50 | .setSmallIcon(IconCompat.createWithResource(context, drawable.ic_bundel_icon)) 51 | .setLargeIcon(largeIcon) 52 | .build() 53 | notificationManager.notify(id, notification) 54 | } 55 | 56 | companion object { 57 | 58 | private const val shortSnoozeWindowDurationSeconds = 15 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /preferences/src/main/kotlin/dev/sebastiano/bundel/preferences/DependenciesModel.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.preferences 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | class DependenciesModel : ArrayList() { 6 | 7 | @Serializable 8 | data class Dependency( 9 | val artifactId: String, 10 | val groupId: String, 11 | val name: String? = null, 12 | val scm: Scm? = null, 13 | val spdxLicenses: List? = null, 14 | val unknownLicenses: List? = null, 15 | val version: String 16 | ) { 17 | 18 | val coordinates: String 19 | get() = "$groupId:$artifactId" 20 | 21 | @Serializable 22 | data class Scm( 23 | val url: String 24 | ) 25 | 26 | @Serializable 27 | data class SpdxLicense( 28 | val identifier: String, 29 | val name: String, 30 | val url: String 31 | ) 32 | 33 | @Serializable 34 | data class UnknownLicense( 35 | val name: String, 36 | val url: String 37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /preferences/src/main/kotlin/dev/sebastiano/bundel/preferences/ExcludedAppsViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.preferences 2 | 3 | import android.content.pm.ApplicationInfo 4 | import android.content.pm.PackageManager 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import kotlinx.coroutines.flow.first 9 | import kotlinx.coroutines.flow.map 10 | import kotlinx.coroutines.launch 11 | import timber.log.Timber 12 | import javax.inject.Inject 13 | 14 | @Suppress("DEPRECATION") 15 | @HiltViewModel 16 | class ExcludedAppsViewModel @Inject constructor( 17 | private val preferences: Preferences, 18 | packageManager: PackageManager 19 | ) : ViewModel() { 20 | 21 | private val excludedPackagesFlow = preferences.getExcludedPackages() 22 | private val installedApps = packageManager.getInstalledApplications(0) 23 | 24 | val excludedAppsCountFlow = excludedPackagesFlow.map { it.count() } 25 | 26 | val appFilterInfoFlow = excludedPackagesFlow.map { excludedPackages -> 27 | computeApps(installedApps, excludedPackages, packageManager) 28 | .sortedBy { it.displayName } 29 | } 30 | 31 | private fun computeApps( 32 | installedApps: List, 33 | excludedPackages: Set, 34 | packageManager: PackageManager 35 | ) = 36 | installedApps.map { applicationInfo -> 37 | val isExcluded = excludedPackages.contains(applicationInfo.packageName) 38 | AppFilterInfo(applicationInfo, packageManager, isExcluded) 39 | } 40 | 41 | fun setAppNotificationsExcluded(packageName: String, excluded: Boolean) { 42 | Timber.d("Setting app '$packageName' notifications as excluded: $excluded") 43 | 44 | viewModelScope.launch { 45 | val excludedPackages = excludedPackagesFlow.first().toMutableSet() 46 | val succeeded = if (excluded) { 47 | excludedPackages.add(packageName) 48 | } else { 49 | excludedPackages.remove(packageName) 50 | } 51 | check(succeeded) { "Unable to ${if (excluded) "add" else "remove"} $packageName to/from exclusions" } 52 | 53 | preferences.setExcludedPackages(excludedPackages) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /preferences/src/main/kotlin/dev/sebastiano/bundel/preferences/Lce.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.preferences 2 | 3 | sealed class Lce { 4 | 5 | class Loading : Lce() 6 | 7 | data class Data(val value: T) : Lce() 8 | 9 | data class Error(val throwable: E) : Lce() 10 | } 11 | -------------------------------------------------------------------------------- /preferences/src/main/kotlin/dev/sebastiano/bundel/preferences/LicensesPreferencesViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.preferences 2 | 3 | import android.content.res.AssetManager 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import arrow.core.NonEmptyList 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.flow.Flow 10 | import kotlinx.coroutines.flow.MutableStateFlow 11 | import kotlinx.coroutines.launch 12 | import kotlinx.serialization.ExperimentalSerializationApi 13 | import kotlinx.serialization.json.Json 14 | import kotlinx.serialization.json.decodeFromStream 15 | import java.io.IOException 16 | import javax.inject.Inject 17 | 18 | @HiltViewModel 19 | class LicensesPreferencesViewModel @Inject constructor( 20 | private val json: Json, 21 | private val assetManager: AssetManager 22 | ) : ViewModel() { 23 | 24 | private val licenses = MutableStateFlow>>(Lce.Loading()) 25 | val licensesFlow: Flow>> = licenses 26 | 27 | @OptIn(ExperimentalSerializationApi::class) 28 | fun loadLicenses() { 29 | if (licenses.value is Lce.Data<*>) return 30 | 31 | licenses.value = Lce.Loading() 32 | 33 | @Suppress("InjectDispatcher") // Whatevs for now 34 | viewModelScope.launch(Dispatchers.IO) { 35 | assetManager.open("licences.json").use { 36 | try { 37 | val dependencyList = json.decodeFromStream(it) as List 38 | if (dependencyList.isNotEmpty()) { 39 | val map = dependencyList.groupBy { dependency -> 40 | dependency.spdxLicenses?.firstOrNull()?.identifier 41 | ?: dependency.unknownLicenses?.firstOrNull()?.url 42 | } 43 | .mapValues { (key, depsList) -> 44 | if (key == null) return@mapValues null 45 | 46 | val license = depsList.first() 47 | .let { dep -> 48 | dep.spdxLicenses?.firstOrNull() 49 | ?: dep.unknownLicenses?.firstOrNull() 50 | } 51 | 52 | val licenseInfo = when (license) { 53 | is DependenciesModel.Dependency.SpdxLicense -> { 54 | LicenseInfo(license.name, license.identifier, license.url) 55 | } 56 | 57 | is DependenciesModel.Dependency.UnknownLicense -> { 58 | LicenseInfo(license.name, null, license.url) 59 | } 60 | 61 | else -> error("This should never happen LOL CIAO CULO") 62 | } 63 | LicensesListItem(licenseInfo, NonEmptyList.fromListUnsafe(depsList)) 64 | } 65 | .values 66 | .filterNotNull() 67 | 68 | licenses.value = Lce.Data(NonEmptyList.fromListUnsafe(map)) 69 | } else { 70 | licenses.value = Lce.Error(IOException("The licenses file is empty OHNO.jpg")) 71 | } 72 | } catch (e: IllegalArgumentException) { 73 | licenses.value = Lce.Error(e) 74 | } catch (e: IOException) { 75 | licenses.value = Lce.Error(e) 76 | } 77 | } 78 | } 79 | } 80 | 81 | data class LicenseInfo(val name: String, val spdxId: String?, val url: String?) 82 | 83 | data class LicensesListItem(val licenseInfo: LicenseInfo, val dependencies: NonEmptyList) 84 | } 85 | -------------------------------------------------------------------------------- /preferences/src/main/kotlin/dev/sebastiano/bundel/preferences/Preferences.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.preferences 2 | 3 | import dev.sebastiano.bundel.preferences.schedule.TimeRangesSchedule 4 | import dev.sebastiano.bundel.ui.composables.WeekDay 5 | import kotlinx.coroutines.flow.Flow 6 | 7 | interface Preferences { 8 | 9 | fun isCrashlyticsEnabled(): Flow 10 | suspend fun setIsCrashlyticsEnabled(enabled: Boolean) 11 | 12 | fun isWinteryEasterEggEnabled(): Flow 13 | suspend fun setWinteryEasterEggEnabled(enabled: Boolean) 14 | 15 | fun getExcludedPackages(): Flow> 16 | suspend fun setExcludedPackages(excludedPackages: Set) 17 | 18 | suspend fun isOnboardingSeen(): Boolean 19 | suspend fun setIsOnboardingSeen(onboardingSeen: Boolean) 20 | 21 | fun getDaysSchedule(): Flow> 22 | suspend fun setDaysSchedule(daysSchedule: Map) 23 | 24 | fun getTimeRangesSchedule(): Flow 25 | suspend fun setTimeRangesSchedule(timeRangesSchedule: TimeRangesSchedule) 26 | 27 | fun getSnoozeWindowDurationSeconds(): Flow 28 | suspend fun setSnoozeWindowDurationSeconds(duration: Int) 29 | } 30 | -------------------------------------------------------------------------------- /preferences/src/main/kotlin/dev/sebastiano/bundel/preferences/PreferencesModule.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.preferences 2 | 3 | import android.content.Context 4 | import androidx.datastore.dataStore 5 | import dagger.Module 6 | import dagger.Provides 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.android.qualifiers.ApplicationContext 9 | import dagger.hilt.components.SingletonComponent 10 | import javax.inject.Singleton 11 | 12 | @Module 13 | @InstallIn(SingletonComponent::class) 14 | class PreferencesModule { 15 | 16 | private val Context.dataStore by dataStore( 17 | fileName = "bundelprefs.pb", 18 | serializer = BundelPreferencesSerializer, 19 | produceMigrations = { context -> 20 | listOf(sharedPrefsMigration(context)) 21 | } 22 | ) 23 | 24 | @Provides 25 | @Singleton 26 | fun providePreferences(@ApplicationContext context: Context): Preferences = DataStorePreferences(context.dataStore) 27 | } 28 | -------------------------------------------------------------------------------- /preferences/src/main/kotlin/dev/sebastiano/bundel/preferences/SelectDaysDialog.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.preferences 2 | 3 | import android.annotation.SuppressLint 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.height 8 | import androidx.compose.foundation.layout.navigationBarsPadding 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Text 12 | import androidx.compose.material3.TextButton 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.collectAsState 15 | import androidx.compose.runtime.getValue 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.res.stringResource 19 | import androidx.compose.ui.unit.dp 20 | import androidx.hilt.navigation.compose.hiltViewModel 21 | import dev.sebastiano.bundel.ui.composables.DaysPicker 22 | import dev.sebastiano.bundel.ui.composables.checkedMaterialPillAppearance 23 | import dev.sebastiano.bundel.ui.singlePadding 24 | import kotlinx.coroutines.delay 25 | import kotlinx.coroutines.flow.onStart 26 | 27 | // As of Accompanist Material Nav 0.19.0, this is needed to avoid race conditions 28 | // when showing the bottom sheets that cause them to... err... not show. 29 | // 🏁 🚗💨 30 | private const val WACKY_RACES_CONDITION = 40L 31 | 32 | @SuppressLint("FlowOperatorInvokedInComposition") // TODO fix this crap 33 | @Composable 34 | fun SelectDaysDialog( 35 | viewModel: ActiveDaysViewModel = hiltViewModel(), 36 | onDialogDismiss: () -> Unit 37 | ) { 38 | Column( 39 | modifier = Modifier 40 | .navigationBarsPadding() 41 | .padding(8.dp) 42 | ) { 43 | Text( 44 | text = stringResource(R.string.settings_active_days_blurb), 45 | modifier = Modifier 46 | .padding(top = 16.dp) 47 | .padding(horizontal = 16.dp) 48 | ) 49 | 50 | Spacer(modifier = Modifier.height(24.dp)) 51 | 52 | // TODO don't use flow operators in composition 53 | val daysSchedule by viewModel.daysScheduleFlow 54 | .onStart { delay(WACKY_RACES_CONDITION) } 55 | .collectAsState(initial = emptyMap()) 56 | 57 | DaysPicker( 58 | daysSchedule = daysSchedule, 59 | onDayCheckedChange = { weekDay, checked -> viewModel.onDaysScheduleChangeWeekDay(weekDay, checked) }, 60 | chipsSpacing = singlePadding(), 61 | modifier = Modifier 62 | .fillMaxWidth() 63 | .padding(horizontal = 48.dp), 64 | checkedAppearance = checkedMaterialPillAppearance( 65 | backgroundColor = MaterialTheme.colorScheme.primaryContainer, 66 | contentColor = MaterialTheme.colorScheme.onPrimaryContainer 67 | ), 68 | uncheckedAppearance = checkedMaterialPillAppearance( 69 | backgroundColor = MaterialTheme.colorScheme.secondaryContainer, 70 | contentColor = MaterialTheme.colorScheme.onSecondaryContainer 71 | ) 72 | ) 73 | 74 | Spacer(modifier = Modifier.height(32.dp)) 75 | 76 | TextButton(onClick = onDialogDismiss, modifier = Modifier.align(Alignment.End)) { 77 | Text(text = "DONE") 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /preferences/src/main/kotlin/dev/sebastiano/bundel/preferences/SelectTimeRangesDialog.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.preferences 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.PaddingValues 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.lazy.LazyColumn 10 | import androidx.compose.foundation.lazy.LazyListScope 11 | import androidx.compose.foundation.lazy.items 12 | import androidx.compose.material.Card 13 | import androidx.compose.material.ContentAlpha 14 | import androidx.compose.material3.MaterialTheme 15 | import androidx.compose.material3.Text 16 | import androidx.compose.material3.TextButton 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.runtime.collectAsState 19 | import androidx.compose.runtime.getValue 20 | import androidx.compose.ui.Alignment 21 | import androidx.compose.ui.Modifier 22 | import androidx.compose.ui.draw.alpha 23 | import androidx.compose.ui.unit.dp 24 | import androidx.hilt.navigation.compose.hiltViewModel 25 | import dev.sebastiano.bundel.preferences.schedule.TimeRangesSchedule 26 | import dev.sebastiano.bundel.ui.composables.TimeRangeRow 27 | import dev.sebastiano.bundel.ui.composables.checkedMaterialPillAppearance 28 | import dev.sebastiano.bundel.ui.singlePadding 29 | 30 | @Composable 31 | fun SelectTimeRangesDialog( 32 | viewModel: ActiveTimeRangesViewModel = hiltViewModel(), 33 | onDialogDismiss: () -> Unit, 34 | ) { 35 | Card { 36 | Column( 37 | modifier = Modifier.padding(16.dp), 38 | verticalArrangement = Arrangement.spacedBy(16.dp), 39 | ) { 40 | Text(text = "Choose the hours on which Bundel will be active.") 41 | 42 | val timeRangesSchedule by viewModel.timeRangesScheduleFlow.collectAsState(initial = TimeRangesSchedule()) 43 | 44 | LazyColumn( 45 | modifier = Modifier 46 | .fillMaxWidth() 47 | .weight(1f, fill = false), 48 | contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), 49 | verticalArrangement = Arrangement.spacedBy(singlePadding()), 50 | horizontalAlignment = Alignment.CenterHorizontally, 51 | ) { 52 | RangesListContent(timeRangesSchedule, viewModel) 53 | } 54 | 55 | TextButton(onClick = onDialogDismiss, modifier = Modifier.align(Alignment.End)) { 56 | Text(text = "DONE") 57 | } 58 | } 59 | } 60 | } 61 | 62 | @Suppress("FunctionName") 63 | private fun LazyListScope.RangesListContent( 64 | timeRangesSchedule: TimeRangesSchedule, 65 | viewModel: ActiveTimeRangesViewModel, 66 | ) { 67 | val items = timeRangesSchedule.timeRanges.withIndex().toList() 68 | 69 | items(items = items) { (index, timeRange) -> 70 | val minimumAllowedFrom = if (index > 0) items[index - 1].value.to else null 71 | val maximumAllowedTo = if (index < items.count() - 1) items[index + 1].value.from else null 72 | 73 | TimeRangeRow( 74 | expandedPillAppearance = checkedMaterialPillAppearance( 75 | backgroundColor = MaterialTheme.colorScheme.primaryContainer, 76 | contentColor = MaterialTheme.colorScheme.onPrimaryContainer, 77 | ), 78 | normalPillAppearance = checkedMaterialPillAppearance( 79 | backgroundColor = MaterialTheme.colorScheme.secondaryContainer, 80 | contentColor = MaterialTheme.colorScheme.onSecondaryContainer, 81 | ), 82 | timeRange = timeRange, 83 | canBeRemoved = timeRangesSchedule.canRemoveRanges, 84 | minimumAllowableFrom = minimumAllowedFrom, 85 | maximumAllowableTo = maximumAllowedTo, 86 | onRemoved = if (timeRangesSchedule.canRemoveRanges) { 87 | { viewModel.onTimeRangesScheduleRemoveTimeRange(timeRange) } 88 | } else { 89 | { } 90 | }, 91 | ) { newTimeRange -> viewModel.onTimeRangesScheduleChangeTimeRange(timeRange, newTimeRange) } 92 | } 93 | 94 | if (timeRangesSchedule.canAppendAnotherRange) { 95 | item { 96 | TimeRangeRow( 97 | modifier = Modifier 98 | .clickable { viewModel.onTimeRangesScheduleAddTimeRange() } 99 | .padding(horizontal = 8.dp) 100 | .alpha(ContentAlpha.medium), 101 | normalPillAppearance = checkedMaterialPillAppearance( 102 | backgroundColor = MaterialTheme.colorScheme.secondaryContainer, 103 | contentColor = MaterialTheme.colorScheme.onSecondaryContainer, 104 | ), 105 | timeRange = null, 106 | enabled = false, 107 | ) 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /preferences/src/main/kotlin/dev/sebastiano/bundel/preferences/SharedPrefsMigration.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnusedImports") // TODO bug in detekt 1.17.1 flags unused import incorrectly 2 | 3 | package dev.sebastiano.bundel.preferences 4 | 5 | import android.content.Context 6 | import androidx.datastore.migrations.SharedPreferencesMigration 7 | import androidx.datastore.migrations.SharedPreferencesView 8 | import dev.sebastiano.bundel.preferences.schedule.DaysScheduleSerializer 9 | import dev.sebastiano.bundel.preferences.schedule.HoursScheduleSerializer 10 | import dev.sebastiano.bundel.proto.BundelPreferences 11 | import timber.log.Timber 12 | 13 | internal fun sharedPrefsMigration(context: Context) = SharedPreferencesMigration( 14 | context = context, 15 | sharedPreferencesName = "preferences", 16 | keysToMigrate = setOf( 17 | Keys.CRASHLYTICS_ENABLED, 18 | Keys.ONBOARDING_SEEN, 19 | Keys.DAYS_SCHEDULE, 20 | Keys.HOURS_SCHEDULE 21 | ) 22 | ) { sharedPreferencesView: SharedPreferencesView, bundelPrefs: BundelPreferences -> 23 | if (bundelPrefs.isMigratedFromSharedPrefs) return@SharedPreferencesMigration bundelPrefs 24 | 25 | Timber.i("Migrating shared prefs to datastore...") 26 | 27 | val timeRanges = sharedPreferencesView.getString(Keys.HOURS_SCHEDULE) 28 | ?.let { 29 | HoursScheduleSerializer.deserializeFromString(it) 30 | .timeRanges 31 | } ?: DataStorePreferences.DEFAULT_HOURS_SCHEDULE.timeRanges 32 | 33 | val daysMap = sharedPreferencesView.getString(Keys.DAYS_SCHEDULE) 34 | ?.let { DaysScheduleSerializer.deserializeFromString(it) } 35 | ?: DataStorePreferences.DEFAULT_DAYS_SCHEDULE 36 | 37 | bundelPrefs.toBuilder() 38 | .clearTimeRanges() 39 | .addAllTimeRanges(timeRanges.toProtoTimeRanges()) 40 | .clearScheduleDays() 41 | .putAllScheduleDays(daysMap.mapKeys { it.key.name }) 42 | .setIsCrashlyticsEnabled(sharedPreferencesView.getBoolean(Keys.CRASHLYTICS_ENABLED, defValue = false)) 43 | .setIsOnboardingSeen(sharedPreferencesView.getBoolean(Keys.ONBOARDING_SEEN, defValue = false)) 44 | .setIsMigratedFromSharedPrefs(true) 45 | .build() 46 | } 47 | 48 | internal object Keys { 49 | 50 | const val CRASHLYTICS_ENABLED = "crashlytics" 51 | const val ONBOARDING_SEEN = "onboarding_seen" 52 | const val DAYS_SCHEDULE = "days_schedule" 53 | const val HOURS_SCHEDULE = "hours_schedule" 54 | } 55 | -------------------------------------------------------------------------------- /preferences/src/main/kotlin/dev/sebastiano/bundel/preferences/WinteryEasterEggViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.preferences 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import kotlinx.coroutines.flow.Flow 7 | import kotlinx.coroutines.flow.map 8 | import kotlinx.coroutines.launch 9 | import java.time.LocalDate 10 | import java.time.Month 11 | import javax.inject.Inject 12 | 13 | @HiltViewModel 14 | class WinteryEasterEggViewModel @Inject constructor( 15 | private val preferences: Preferences 16 | ) : ViewModel() { 17 | 18 | val easterEggEnabledFlow: Flow = preferences.isWinteryEasterEggEnabled() 19 | 20 | fun isWinteryEasterEggPeriod(): Boolean { 21 | val now = LocalDate.now() 22 | return now.month == Month.DECEMBER && now.dayOfMonth in 21..31 23 | } 24 | 25 | fun shouldShowWinteryEasterEgg(): Flow = 26 | preferences.isWinteryEasterEggEnabled() 27 | .map { enabled -> enabled && isWinteryEasterEggPeriod() } 28 | 29 | fun setEnabled(enabled: Boolean) { 30 | viewModelScope.launch { 31 | preferences.setWinteryEasterEggEnabled(enabled) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /preferences/src/main/kotlin/dev/sebastiano/bundel/preferences/schedule/DaysScheduleSerializer.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.preferences.schedule 2 | 3 | import dev.sebastiano.bundel.ui.composables.WeekDay 4 | 5 | internal object DaysScheduleSerializer { 6 | 7 | fun serializeToString(schedule: Map): String { 8 | require(schedule.isNotEmpty()) { "The schedule must not be empty" } 9 | 10 | return schedule.entries.joinToString(separator = ",") { "${it.key.name}=${it.value}" } 11 | } 12 | 13 | fun deserializeFromString(rawSchedule: String): Map { 14 | require(rawSchedule.isNotBlank()) { "The raw schedule must not be blank" } 15 | 16 | return rawSchedule.split(',') 17 | .map { entry -> 18 | val entryParts = entry.split('=') 19 | require(entryParts.size == 2) { "Entry with invalid number of parts: '$entry'" } 20 | 21 | entryParts 22 | } 23 | .map { entryParts -> 24 | val dayName = entryParts.first() 25 | val dayActive = entryParts.last() 26 | 27 | WeekDay.valueOf(dayName) to dayActive.toBooleanStrict() 28 | } 29 | .toMap() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /preferences/src/main/kotlin/dev/sebastiano/bundel/preferences/schedule/HoursScheduleSerializer.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.preferences.schedule 2 | 3 | import dev.sebastiano.bundel.ui.composables.TimeRange 4 | import java.time.LocalTime 5 | 6 | internal object HoursScheduleSerializer { 7 | 8 | fun serializeToString(schedule: TimeRangesSchedule): String { 9 | require(schedule.timeRanges.isNotEmpty()) { "The schedule must not be empty" } 10 | 11 | return schedule.timeRanges.joinToString(separator = ",") { "${it.from.serializeToString()}–${it.to.serializeToString()}" } 12 | } 13 | 14 | private fun LocalTime.serializeToString() = "$hour:$minute" 15 | 16 | fun deserializeFromString(rawSchedule: String): TimeRangesSchedule { 17 | require(rawSchedule.isNotBlank()) { "The raw schedule must not be blank" } 18 | 19 | val timeRanges = rawSchedule.split(',') 20 | .map { entry -> 21 | val entryParts = entry.split('–') 22 | require(entryParts.size == 2) { "Entry with invalid number of parts: '$entry'" } 23 | 24 | entryParts 25 | } 26 | .map { entryParts -> 27 | entryParts.first().deserializeToHourOfDay() to entryParts.last().deserializeToHourOfDay() 28 | } 29 | .map { (from, to) -> TimeRange(from, to) } 30 | 31 | return TimeRangesSchedule.of(*timeRanges.toTypedArray()) 32 | } 33 | 34 | private fun String.deserializeToHourOfDay(): LocalTime { 35 | val entryParts = split(':') 36 | require(entryParts.size == 2) { "HourOfDay with invalid number of parts: '$this'" } 37 | 38 | return LocalTime.of(entryParts.first().toInt(), entryParts.last().toInt()) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /preferences/src/main/kotlin/dev/sebastiano/bundel/preferences/schedule/TimeRangesSchedule.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.preferences.schedule 2 | 3 | import dev.sebastiano.bundel.ui.composables.TimeRange 4 | import java.time.LocalTime 5 | 6 | private val LAST_AVAILABLE_TIME_OF_DAY = LocalTime.of(23, 59) 7 | private const val MINIMUM_RANGE_DURATION_IN_MINUTES = 1L 8 | private val LAST_TIME_THAT_CAN_APPEND_TO = LAST_AVAILABLE_TIME_OF_DAY.minusMinutes(MINIMUM_RANGE_DURATION_IN_MINUTES) 9 | 10 | class TimeRangesSchedule private constructor( 11 | private val ranges: List 12 | ) : List by ranges { 13 | 14 | val canAppendAnotherRange: Boolean 15 | get() = last().to < LAST_TIME_THAT_CAN_APPEND_TO 16 | 17 | val canRemoveRanges: Boolean 18 | get() = size > 1 19 | 20 | val timeRanges: List = ranges 21 | 22 | fun appendTimeRange(): TimeRangesSchedule { 23 | check(canAppendAnotherRange) { "Trying to add a time range when canAppendAnotherRange is false" } 24 | 25 | val lastTo = last().to 26 | val newTo = lastTo.plusHours(1) 27 | val timeRange = TimeRange( 28 | from = lastTo.plusMinutes(1), 29 | to = if (newTo < lastTo) LAST_AVAILABLE_TIME_OF_DAY else newTo 30 | ) 31 | return of(ranges + timeRange) 32 | } 33 | 34 | fun updateRange(old: TimeRange, new: TimeRange): TimeRangesSchedule { 35 | val oldIndex = ranges.indexOf(old) 36 | require(oldIndex >= 0) { "Range not found: $old" } 37 | 38 | val newRanges = ranges.toMutableList() 39 | newRanges[oldIndex] = new 40 | 41 | return of(newRanges) 42 | } 43 | 44 | fun removeRange(range: TimeRange): TimeRangesSchedule { 45 | check(canRemoveRanges) { "Trying to remove a range when there is only one range left in the schedule" } 46 | 47 | val oldIndex = ranges.indexOf(range) 48 | require(oldIndex >= 0) { "Range not found: $range" } 49 | 50 | val newRanges = ranges.toMutableList() 51 | newRanges.removeAt(oldIndex) 52 | 53 | return of(newRanges) 54 | } 55 | 56 | override fun equals(other: Any?): Boolean { 57 | if (this === other) return true 58 | if (other !is TimeRangesSchedule) return false 59 | 60 | if (ranges != other.ranges) return false 61 | 62 | return true 63 | } 64 | 65 | override fun hashCode(): Int = ranges.hashCode() 66 | 67 | companion object Factory { 68 | 69 | private val DEFAULT_RANGES = arrayOf( 70 | TimeRange(from = LocalTime.of(9, 0), to = LocalTime.of(12, 30)), 71 | TimeRange(from = LocalTime.of(14, 0), to = LocalTime.of(18, 0)) 72 | ) 73 | 74 | fun of(ranges: List): TimeRangesSchedule = of(*ranges.toTypedArray()) 75 | 76 | fun of(vararg ranges: TimeRange): TimeRangesSchedule { 77 | require(ranges.isNotEmpty()) { "There needs to be at least one range in the schedule" } 78 | 79 | // Sort by start time 80 | val sortedRanges = ranges.sortedBy { it.from } 81 | 82 | for (index in 1 until sortedRanges.size) { 83 | val current = sortedRanges[index] 84 | val previous = sortedRanges[index - 1] 85 | 86 | require(current.from > previous.to) { "The FROM of range at position $index ($current) overlaps previous range ($previous)" } 87 | } 88 | 89 | return TimeRangesSchedule(sortedRanges.toList()) 90 | } 91 | 92 | operator fun invoke() = of(*DEFAULT_RANGES) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /preferences/src/main/proto/dev/sebastiano/bundel/protos/BundelPreferences.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option java_package = "dev.sebastiano.bundel.proto"; 4 | option java_multiple_files = true; 5 | 6 | message BundelPreferences { 7 | 8 | bool isCrashlyticsEnabled = 1; 9 | bool isOnboardingSeen = 2; 10 | 11 | map scheduleDays = 3; 12 | 13 | repeated ProtoTimeRange timeRanges = 4; 14 | 15 | bool isMigratedFromSharedPrefs = 5; 16 | 17 | repeated string excludedPackages = 6; 18 | 19 | message ProtoTimeRange { 20 | int32 from = 1; 21 | int32 to = 2; 22 | } 23 | 24 | bool isWinteryEasterEggEnabled = 7; 25 | 26 | int32 snoozeWindowDurationSeconds = 8; 27 | } 28 | -------------------------------------------------------------------------------- /preferences/src/main/res/drawable/ic_back_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /preferences/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Preferences 4 | Active days 5 | Active time ranges 6 | Exclude apps from filtering 7 | About app 8 | App version: %1$s (%2$s) 9 | "We use open source software: " 10 | acknowledgements 11 | "This app is open source software: " 12 | see the sources 13 | Choose the days on which Bundel will be active. 14 | Exclude apps 15 | Open source licenses 16 | 17 | Preferences 18 | Back 19 | 20 | Icon for %s 21 | EXCLUDED 22 | 23 | 24 | Test Notifications 25 | Dummy notifications used to test Bundel\'s functionality 26 | Post a test notification 27 | Debug settings 28 | Test Glance widget 29 | Use short (15\") snooze windows 30 | I am a test, hi #%d 31 | Just in case it wasn\'t clear 32 | 33 | 34 | %d time range 35 | %d time ranges 36 | 37 | 38 | 39 | %d excluded app 40 | %d excluded apps 41 | 42 | 43 | -------------------------------------------------------------------------------- /preferences/src/test/kotlin/dev/sebastiano/bundel/preferences/schedule/DaysScheduleSerializerTest.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.preferences.schedule 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import assertk.assertions.isFailure 6 | import assertk.assertions.isInstanceOf 7 | import dev.sebastiano.bundel.ui.composables.WeekDay 8 | import org.junit.Test 9 | import org.junit.experimental.runners.Enclosed 10 | import org.junit.runner.RunWith 11 | 12 | @RunWith(Enclosed::class) 13 | internal class DaysScheduleSerializerTest { 14 | 15 | inner class Serialize { 16 | 17 | @Test 18 | fun `should throw an error when the map is empty`() { 19 | assertThat { DaysScheduleSerializer.serializeToString(emptyMap()) }.isFailure() 20 | .isInstanceOf(IllegalArgumentException::class) 21 | } 22 | 23 | @Test 24 | fun `should create a valid string from a non-empty map`() { 25 | val schedule = mapOf(WeekDay.MONDAY to true, WeekDay.FRIDAY to false) 26 | assertThat(DaysScheduleSerializer.serializeToString(schedule)) 27 | .isEqualTo("MONDAY=true,FRIDAY=false") 28 | } 29 | } 30 | 31 | inner class Deserialize { 32 | 33 | @Test 34 | fun `should throw an error when the string is blank`() { 35 | assertThat { DaysScheduleSerializer.deserializeFromString(" ") }.isFailure() 36 | .isInstanceOf(IllegalArgumentException::class) 37 | } 38 | 39 | @Test 40 | fun `should create a valid map from a non-empty string`() { 41 | val rawSchedule = "MONDAY=true,FRIDAY=false" 42 | assertThat(DaysScheduleSerializer.deserializeFromString(rawSchedule)) 43 | .isEqualTo(mapOf(WeekDay.MONDAY to true, WeekDay.FRIDAY to false)) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | gradlePluginPortal() 5 | mavenCentral() 6 | } 7 | 8 | resolutionStrategy { 9 | eachPlugin { 10 | // Some plugins are not on the Gradle Plugins portal and require trickery to resolve 11 | // since Maven repos know nothing of plugin IDs. 12 | when (requested.id.id) { 13 | "dagger.hilt.android.plugin" -> { 14 | useModule("com.google.dagger:hilt-android-gradle-plugin:${requested.version}") 15 | } 16 | "com.google.firebase.crashlytics" -> { 17 | useModule("com.google.firebase.crashlytics:com.google.firebase.crashlytics.gradle.plugin:${requested.version}") 18 | } 19 | "shot" -> { 20 | useModule("com.karumi:shot:${requested.version}") 21 | } 22 | } 23 | } 24 | } 25 | } 26 | rootProject.name = "Bundel" 27 | 28 | include(":app") 29 | include(":shared-ui") 30 | include(":preferences") 31 | 32 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 33 | -------------------------------------------------------------------------------- /shared-ui/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.library) 3 | alias(libs.plugins.kotlinAndroid) 4 | } 5 | 6 | android { 7 | namespace = "dev.sebastiano.bundel.ui" 8 | compileSdk = 33 9 | 10 | defaultConfig { 11 | minSdk = 26 12 | consumerProguardFiles("consumer-rules.pro") 13 | } 14 | 15 | buildFeatures { 16 | compose = true 17 | } 18 | 19 | composeOptions { 20 | kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() 21 | } 22 | 23 | compileOptions { 24 | sourceCompatibility = JavaVersion.VERSION_17 25 | targetCompatibility = JavaVersion.VERSION_17 26 | } 27 | kotlinOptions { 28 | jvmTarget = "17" 29 | } 30 | } 31 | 32 | dependencies { 33 | implementation(libs.bundles.accompanist) 34 | implementation(libs.bundles.compose) 35 | implementation(libs.bundles.composeUiTooling) 36 | implementation(libs.androidx.appCompat) 37 | implementation(libs.androidx.glance) 38 | implementation(libs.androidx.navigation.navigationCompose) 39 | 40 | testImplementation(libs.junit) 41 | 42 | androidTestImplementation(libs.androidx.test.ext.junit) 43 | androidTestImplementation(libs.androidx.test.espresso.core) 44 | } 45 | -------------------------------------------------------------------------------- /shared-ui/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-with-the-italians/bundel/51018098eeeb6d11887e4c9ecf7d3d2b9b6cf808/shared-ui/consumer-rules.pro -------------------------------------------------------------------------------- /shared-ui/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /shared-ui/src/main/kotlin/dev/sebastiano/bundel/ui/Dimens.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.ui 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.unit.dp 5 | 6 | @Composable 7 | fun singlePadding() = 8.dp 8 | 9 | @Composable 10 | fun iconSize() = 48.dp 11 | -------------------------------------------------------------------------------- /shared-ui/src/main/kotlin/dev/sebastiano/bundel/ui/System.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.ui 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.SideEffect 5 | import androidx.compose.ui.graphics.Color 6 | import androidx.compose.ui.graphics.luminance 7 | import com.google.accompanist.systemuicontroller.SystemUiController 8 | import com.google.accompanist.systemuicontroller.rememberSystemUiController 9 | 10 | @Composable 11 | fun SetupTransparentSystemUi( 12 | systemUiController: SystemUiController = rememberSystemUiController(), 13 | actualBackgroundColor: Color 14 | ) { 15 | val minLuminanceForDarkIcons = .5f 16 | SideEffect { 17 | systemUiController.setStatusBarColor( 18 | color = Color.Transparent, 19 | darkIcons = actualBackgroundColor.luminance() > minLuminanceForDarkIcons 20 | ) 21 | 22 | systemUiController.setNavigationBarColor( 23 | color = Color.Transparent, 24 | darkIcons = actualBackgroundColor.luminance() > minLuminanceForDarkIcons, 25 | navigationBarContrastEnforced = false 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /shared-ui/src/main/kotlin/dev/sebastiano/bundel/ui/Theme.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.ui 2 | 3 | import android.content.Context 4 | import android.content.res.Configuration 5 | import android.os.Build 6 | import androidx.compose.foundation.isSystemInDarkTheme 7 | import androidx.compose.material.MaterialTheme 8 | import androidx.compose.material3.dynamicDarkColorScheme 9 | import androidx.compose.material3.dynamicLightColorScheme 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.platform.LocalContext 12 | import androidx.compose.material3.MaterialTheme as Material3MaterialTheme 13 | import androidx.glance.LocalContext as GlanceLocalContext 14 | 15 | @Composable 16 | fun BundelYouTheme( 17 | darkTheme: Boolean = isSystemInDarkTheme(), 18 | context: Context = LocalContext.current, 19 | content: @Composable () -> Unit 20 | ) { 21 | val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S 22 | val colorScheme = when { 23 | dynamicColor && darkTheme -> dynamicDarkColorScheme(context) 24 | dynamicColor && !darkTheme -> dynamicLightColorScheme(context) 25 | darkTheme -> DarkThemeColors 26 | else -> LightThemeColors 27 | } 28 | 29 | BundelTheme(darkTheme = darkTheme) { 30 | Material3MaterialTheme( 31 | colorScheme = colorScheme, 32 | typography = BundelYouTypography, 33 | content = content 34 | ) 35 | } 36 | } 37 | 38 | @Composable 39 | fun BundelGlanceTheme( 40 | glanceContext: Context = GlanceLocalContext.current, 41 | darkTheme: Boolean = glanceContext.isDarkTheme, 42 | content: @Composable () -> Unit 43 | ) { 44 | BundelYouTheme(darkTheme, glanceContext, content) 45 | } 46 | 47 | private val Context.isDarkTheme: Boolean 48 | get() = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES 49 | 50 | @Composable 51 | fun BundelTheme( 52 | darkTheme: Boolean? = null, 53 | content: @Composable () -> Unit 54 | ) { 55 | MaterialTheme( 56 | colors = bundelColors(darkTheme), 57 | typography = bundelTypography, 58 | // some people just want to watch the world burn 59 | shapes = MaterialTheme.shapes.copy(large = MaterialTheme.shapes.small), 60 | content = content 61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /shared-ui/src/main/kotlin/dev/sebastiano/bundel/ui/composables/DaysPicker.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.ui.composables 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import androidx.compose.material3.Text 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.res.stringResource 8 | import androidx.compose.ui.text.TextStyle 9 | import androidx.compose.ui.text.font.FontWeight 10 | import androidx.compose.ui.unit.Dp 11 | import com.google.accompanist.flowlayout.FlowRow 12 | import com.google.accompanist.flowlayout.MainAxisAlignment 13 | import java.util.Locale 14 | 15 | @Composable 16 | fun DaysPicker( 17 | daysSchedule: Map, 18 | onDayCheckedChange: (WeekDay, Boolean) -> Unit, 19 | chipsSpacing: Dp, 20 | modifier: Modifier = Modifier, 21 | checkedAppearance: MaterialPillAppearance = checkedMaterialPillAppearance(), 22 | uncheckedAppearance: MaterialPillAppearance = uncheckedMaterialPillAppearance() 23 | ) { 24 | FlowRow( 25 | modifier = modifier, 26 | mainAxisAlignment = MainAxisAlignment.Center, 27 | mainAxisSpacing = chipsSpacing, 28 | crossAxisSpacing = chipsSpacing 29 | ) { 30 | for (weekDay in daysSchedule.keys) { 31 | MaterialChip( 32 | checked = checkNotNull(daysSchedule[weekDay]) { "Checked state missing for day $weekDay" }, 33 | onCheckedChanged = { checked -> onDayCheckedChange(weekDay, checked) }, 34 | checkedAppearance = checkedAppearance, 35 | uncheckedAppearance = uncheckedAppearance 36 | ) { 37 | Text( 38 | text = stringResource(id = weekDay.displayResId).uppercase(Locale.getDefault()), 39 | style = MaterialTheme.typography.bodyLarge.plus(TextStyle(fontWeight = FontWeight.Medium)) 40 | ) 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /shared-ui/src/main/kotlin/dev/sebastiano/bundel/ui/composables/TimePickerModel.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.ui.composables 2 | 3 | import java.time.LocalTime 4 | 5 | enum class ExpandedRangeExtremity { 6 | NONE, 7 | FROM, 8 | TO 9 | } 10 | 11 | enum class PartOfHour { 12 | HOUR, 13 | MINUTE 14 | } 15 | 16 | class TimePickerModel( 17 | val timeRange: TimeRange, 18 | private val rangeExtremity: ExpandedRangeExtremity, 19 | private val partOfHour: PartOfHour, 20 | minimumAllowableFrom: LocalTime? = null, 21 | maximumAllowableTo: LocalTime? = null 22 | ) { 23 | 24 | val canIncrement = canIncrementBasedOnRange(rangeExtremity, partOfHour) && 25 | canIncrementBasedOnMaximumTo(rangeExtremity, partOfHour, maximumAllowableTo) 26 | 27 | private fun canIncrementBasedOnRange( 28 | rangeExtremity: ExpandedRangeExtremity, 29 | partOfHour: PartOfHour 30 | ) = when { 31 | rangeExtremity == ExpandedRangeExtremity.FROM && partOfHour == PartOfHour.HOUR -> timeRange.canIncrementFromHours 32 | rangeExtremity == ExpandedRangeExtremity.FROM && partOfHour == PartOfHour.MINUTE -> timeRange.canIncrementFromMinutes 33 | rangeExtremity == ExpandedRangeExtremity.TO && partOfHour == PartOfHour.HOUR -> timeRange.canIncrementToHours 34 | rangeExtremity == ExpandedRangeExtremity.TO && partOfHour == PartOfHour.MINUTE -> timeRange.canIncrementToMinutes 35 | else -> false 36 | } 37 | 38 | private fun canIncrementBasedOnMaximumTo( 39 | rangeExtremity: ExpandedRangeExtremity, 40 | partOfHour: PartOfHour, 41 | maximumAllowableTo: LocalTime? 42 | ): Boolean { 43 | if (maximumAllowableTo == null) return true 44 | if (rangeExtremity != ExpandedRangeExtremity.TO) return true 45 | return when (partOfHour) { 46 | PartOfHour.MINUTE -> timeRange.to.plusMinutes(1) < maximumAllowableTo 47 | PartOfHour.HOUR -> timeRange.to.plusHours(1) < maximumAllowableTo 48 | } 49 | } 50 | 51 | val canDecrement = canDecrementBasedOnRange(rangeExtremity, partOfHour) && 52 | canDecrementBasedOnMinimumFrom(rangeExtremity, partOfHour, minimumAllowableFrom) 53 | 54 | private fun canDecrementBasedOnRange(rangeExtremity: ExpandedRangeExtremity, partOfHour: PartOfHour) = when { 55 | rangeExtremity == ExpandedRangeExtremity.FROM && partOfHour == PartOfHour.HOUR -> timeRange.canDecrementFromHours 56 | rangeExtremity == ExpandedRangeExtremity.FROM && partOfHour == PartOfHour.MINUTE -> timeRange.canDecrementFromMinutes 57 | rangeExtremity == ExpandedRangeExtremity.TO && partOfHour == PartOfHour.HOUR -> timeRange.canDecrementToHours 58 | rangeExtremity == ExpandedRangeExtremity.TO && partOfHour == PartOfHour.MINUTE -> timeRange.canDecrementToMinutes 59 | else -> false 60 | } 61 | 62 | private fun canDecrementBasedOnMinimumFrom( 63 | rangeExtremity: ExpandedRangeExtremity, 64 | partOfHour: PartOfHour, 65 | minimumAllowableFrom: LocalTime? 66 | ): Boolean { 67 | if (minimumAllowableFrom == null) return true 68 | if (rangeExtremity != ExpandedRangeExtremity.FROM) return true 69 | return when (partOfHour) { 70 | PartOfHour.MINUTE -> timeRange.from.minusMinutes(1) > minimumAllowableFrom 71 | PartOfHour.HOUR -> timeRange.from.minusHours(1) > minimumAllowableFrom 72 | } 73 | } 74 | 75 | fun incrementTimeRangePart(): TimeRange = 76 | timeRange.copy( 77 | from = if (rangeExtremity == ExpandedRangeExtremity.FROM) { 78 | if (partOfHour == PartOfHour.HOUR) { 79 | timeRange.from.plusHours(1) 80 | } else { 81 | timeRange.from.plusMinutes(1) 82 | } 83 | } else { 84 | timeRange.from 85 | }, 86 | to = if (rangeExtremity == ExpandedRangeExtremity.TO) { 87 | if (partOfHour == PartOfHour.HOUR) { 88 | timeRange.to.plusHours(1) 89 | } else { 90 | timeRange.to.plusMinutes(1) 91 | } 92 | } else { 93 | timeRange.to 94 | } 95 | ) 96 | 97 | fun decrementTimeRangePart(): TimeRange = 98 | timeRange.copy( 99 | from = if (rangeExtremity == ExpandedRangeExtremity.FROM) { 100 | if (partOfHour == PartOfHour.HOUR) { 101 | timeRange.from.minusHours(1) 102 | } else { 103 | timeRange.from.minusMinutes(1) 104 | } 105 | } else { 106 | timeRange.from 107 | }, 108 | to = if (rangeExtremity == ExpandedRangeExtremity.TO) { 109 | if (partOfHour == PartOfHour.HOUR) { 110 | timeRange.to.minusHours(1) 111 | } else { 112 | timeRange.to.minusMinutes(1) 113 | } 114 | } else { 115 | timeRange.to 116 | } 117 | ) 118 | } 119 | -------------------------------------------------------------------------------- /shared-ui/src/main/kotlin/dev/sebastiano/bundel/ui/composables/TimeRange.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.ui.composables 2 | 3 | import java.time.LocalTime 4 | 5 | private val MINIMUM_TIME = LocalTime.of(0, 0) 6 | private val MAXIMUM_TIME = LocalTime.of(23, 59) 7 | 8 | data class TimeRange( 9 | val from: LocalTime, 10 | val to: LocalTime 11 | ) { 12 | 13 | init { 14 | require(from < to) { "'From' ($from) must be strictly smaller than 'to' ($to)" } 15 | require(from < MAXIMUM_TIME) { "'From' ($from) must be strictly smaller than $MAXIMUM_TIME" } 16 | require(to > MINIMUM_TIME) { "'To' ($from) must be strictly larger than $MINIMUM_TIME" } 17 | } 18 | 19 | val canIncrementFromMinutes: Boolean 20 | get() { 21 | val newFrom = from.plusMinutes(1) 22 | return newFrom > from && newFrom < to 23 | } 24 | 25 | val canIncrementFromHours: Boolean 26 | get() { 27 | val newFrom = from.plusHours(1) 28 | return newFrom > from && newFrom < to 29 | } 30 | 31 | val canDecrementFromMinutes: Boolean 32 | get() { 33 | val newFrom = from.minusMinutes(1) 34 | return newFrom < from 35 | } 36 | 37 | val canDecrementFromHours: Boolean 38 | get() { 39 | val newFrom = from.minusHours(1) 40 | return newFrom < from 41 | } 42 | 43 | val canIncrementToMinutes: Boolean 44 | get() { 45 | val newTo = to.plusMinutes(1) 46 | return newTo > to 47 | } 48 | 49 | val canIncrementToHours: Boolean 50 | get() { 51 | val newTo = to.plusHours(1) 52 | return newTo > to 53 | } 54 | 55 | val canDecrementToMinutes: Boolean 56 | get() { 57 | val newTo = to.minusMinutes(1) 58 | return newTo > from && to.minute > 0 59 | } 60 | 61 | val canDecrementToHours: Boolean 62 | get() { 63 | val newTo = to.minusHours(1) 64 | return newTo > from && to.hour > 0 65 | } 66 | 67 | fun contains(localTime: LocalTime): Boolean = 68 | localTime in from..to 69 | } 70 | -------------------------------------------------------------------------------- /shared-ui/src/main/kotlin/dev/sebastiano/bundel/ui/composables/WeekDay.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.ui.composables 2 | 3 | import androidx.annotation.StringRes 4 | import dev.sebastiano.bundel.ui.R 5 | import java.time.DayOfWeek 6 | 7 | enum class WeekDay( 8 | @StringRes val displayResId: Int, 9 | val dayOfWeek: DayOfWeek 10 | ) { 11 | 12 | MONDAY(displayResId = R.string.day_monday, dayOfWeek = DayOfWeek.MONDAY), 13 | TUESDAY(displayResId = R.string.day_tuesday, dayOfWeek = DayOfWeek.TUESDAY), 14 | WEDNESDAY(displayResId = R.string.day_wednesday, dayOfWeek = DayOfWeek.WEDNESDAY), 15 | THURSDAY(displayResId = R.string.day_thursday, dayOfWeek = DayOfWeek.THURSDAY), 16 | FRIDAY(displayResId = R.string.day_friday, dayOfWeek = DayOfWeek.FRIDAY), 17 | SATURDAY(displayResId = R.string.day_saturday, dayOfWeek = DayOfWeek.SATURDAY), 18 | SUNDAY(displayResId = R.string.day_sunday, dayOfWeek = DayOfWeek.SUNDAY) 19 | } 20 | -------------------------------------------------------------------------------- /shared-ui/src/main/kotlin/dev/sebastiano/bundel/ui/modifiers/Modifiers.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("ktlint:filename") 2 | 3 | package dev.sebastiano.bundel.ui.modifiers 4 | 5 | import androidx.compose.ui.Modifier 6 | 7 | fun Modifier.appendIf(condition: Boolean, transformer: Modifier.() -> Modifier): Modifier = 8 | if (!condition) this else transformer() 9 | -------------------------------------------------------------------------------- /shared-ui/src/main/kotlin/dev/sebastiano/bundel/ui/modifiers/overlay/AnimatedOverlay.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.ui.modifiers.overlay 2 | 3 | import androidx.compose.ui.graphics.drawscope.DrawScope 4 | 5 | interface AnimatedOverlay { 6 | 7 | fun drawOverlay(drawScope: DrawScope) 8 | } 9 | -------------------------------------------------------------------------------- /shared-ui/src/main/kotlin/dev/sebastiano/bundel/ui/modifiers/overlay/OverlayModifier.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("ktlint:filename") 2 | @file:JvmName("AnimatedOverlayKt") 3 | package dev.sebastiano.bundel.ui.modifiers.overlay 4 | 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.draw.drawWithContent 7 | import androidx.compose.ui.graphics.graphicsLayer 8 | 9 | @Suppress("MagicNumber") 10 | fun Modifier.animatedOverlay(animatedOverlay: AnimatedOverlay) = this.then( 11 | Modifier 12 | .graphicsLayer { 13 | // This is required to render to an offscreen buffer 14 | // The Clear blend mode will not work without it 15 | alpha = 0.99999f 16 | } 17 | .drawWithContent { 18 | drawContent() 19 | animatedOverlay.drawOverlay(this) 20 | } 21 | ) 22 | -------------------------------------------------------------------------------- /shared-ui/src/main/kotlin/dev/sebastiano/bundel/ui/modifiers/overlay/StrikethroughOverlay.kt: -------------------------------------------------------------------------------- 1 | package dev.sebastiano.bundel.ui.modifiers.overlay 2 | 3 | import androidx.compose.ui.geometry.Offset 4 | import androidx.compose.ui.geometry.center 5 | import androidx.compose.ui.graphics.BlendMode 6 | import androidx.compose.ui.graphics.Color 7 | import androidx.compose.ui.graphics.drawscope.DrawScope 8 | import androidx.compose.ui.graphics.drawscope.rotate 9 | import androidx.compose.ui.unit.Dp 10 | import androidx.compose.ui.unit.dp 11 | 12 | class StrikethroughOverlay( 13 | private val color: Color = Color.Black, 14 | private var widthDp: Dp = 4.dp, 15 | private val getProgress: () -> Float 16 | ) : AnimatedOverlay { 17 | 18 | @Suppress("MagicNumber") 19 | override fun drawOverlay(drawScope: DrawScope) { 20 | with(drawScope) { 21 | val width = density.run { widthDp.toPx() } 22 | val halfWidth = width / 2f 23 | val progressHeight = size.height * getProgress() 24 | rotate(-45f) { 25 | drawLine( 26 | color = color, 27 | start = Offset(size.center.x + halfWidth, 0f), 28 | end = Offset(size.center.x + halfWidth, progressHeight), 29 | strokeWidth = width, 30 | blendMode = BlendMode.Clear 31 | ) 32 | drawLine( 33 | color = color, 34 | start = Offset(size.center.x - halfWidth, 0f), 35 | end = Offset(size.center.x - halfWidth, progressHeight), 36 | strokeWidth = width 37 | ) 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /shared-ui/src/main/kotlin/dev/sebastiano/bundel/ui/resources/Resources.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("ktlint:filename") 2 | 3 | package dev.sebastiano.bundel.ui.resources 4 | 5 | import android.content.res.Resources 6 | import androidx.annotation.PluralsRes 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.ReadOnlyComposable 9 | import androidx.compose.ui.platform.LocalConfiguration 10 | import androidx.compose.ui.platform.LocalContext 11 | 12 | @Composable 13 | @ReadOnlyComposable 14 | fun pluralsResource(@PluralsRes id: Int, quantity: Int, vararg formatArgs: Any): String { 15 | val resources = resources() 16 | return resources.getQuantityString(id, quantity, *formatArgs) 17 | } 18 | 19 | @Composable 20 | @ReadOnlyComposable 21 | private fun resources(): Resources { 22 | // Copied from Compose itself. No idea why it does what it does. 23 | LocalConfiguration.current 24 | return LocalContext.current.resources 25 | } 26 | -------------------------------------------------------------------------------- /shared-ui/src/main/res/drawable-xhdpi/outline_interests_black_48dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-with-the-italians/bundel/51018098eeeb6d11887e4c9ecf7d3d2b9b6cf808/shared-ui/src/main/res/drawable-xhdpi/outline_interests_black_48dp.png -------------------------------------------------------------------------------- /shared-ui/src/main/res/drawable/ic_android_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | -------------------------------------------------------------------------------- /shared-ui/src/main/res/drawable/ic_bundel_icon.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /shared-ui/src/main/res/drawable/ic_default_icon.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /shared-ui/src/main/res/drawable/snowflake01.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 18 | 22 | 26 | 30 | 34 | 38 | 42 | 46 | 50 | 54 | 58 | 62 | 66 | 67 | -------------------------------------------------------------------------------- /shared-ui/src/main/res/drawable/snowflake03.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 18 | 22 | 26 | 30 | 34 | 38 | 42 | 46 | 50 | 54 | 57 | 61 | 62 | -------------------------------------------------------------------------------- /shared-ui/src/main/res/font/inter_bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-with-the-italians/bundel/51018098eeeb6d11887e4c9ecf7d3d2b9b6cf808/shared-ui/src/main/res/font/inter_bold.ttf -------------------------------------------------------------------------------- /shared-ui/src/main/res/font/inter_medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-with-the-italians/bundel/51018098eeeb6d11887e4c9ecf7d3d2b9b6cf808/shared-ui/src/main/res/font/inter_medium.ttf -------------------------------------------------------------------------------- /shared-ui/src/main/res/font/inter_regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-with-the-italians/bundel/51018098eeeb6d11887e4c9ecf7d3d2b9b6cf808/shared-ui/src/main/res/font/inter_regular.ttf -------------------------------------------------------------------------------- /shared-ui/src/main/res/font/podkova_bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-with-the-italians/bundel/51018098eeeb6d11887e4c9ecf7d3d2b9b6cf808/shared-ui/src/main/res/font/podkova_bold.ttf -------------------------------------------------------------------------------- /shared-ui/src/main/res/font/podkova_extrabold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-with-the-italians/bundel/51018098eeeb6d11887e4c9ecf7d3d2b9b6cf808/shared-ui/src/main/res/font/podkova_extrabold.ttf -------------------------------------------------------------------------------- /shared-ui/src/main/res/font/podkova_medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-with-the-italians/bundel/51018098eeeb6d11887e4c9ecf7d3d2b9b6cf808/shared-ui/src/main/res/font/podkova_medium.ttf -------------------------------------------------------------------------------- /shared-ui/src/main/res/font/podkova_regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-with-the-italians/bundel/51018098eeeb6d11887e4c9ecf7d3d2b9b6cf808/shared-ui/src/main/res/font/podkova_regular.ttf -------------------------------------------------------------------------------- /shared-ui/src/main/res/font/podkova_semibold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-with-the-italians/bundel/51018098eeeb6d11887e4c9ecf7d3d2b9b6cf808/shared-ui/src/main/res/font/podkova_semibold.ttf -------------------------------------------------------------------------------- /shared-ui/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Mon 4 | Tue 5 | Wed 6 | Thu 7 | Fri 8 | Sat 9 | Sun 10 | 11 | One more! 12 | One less! 13 | 14 | -------------------------------------------------------------------------------- /third-party-notices.md: -------------------------------------------------------------------------------- 1 | The following third party content requires notices: 2 | 3 | * [People vector](https://www.freepik.com/vectors/people) created by pch.vector - www.freepik.com 4 | --------------------------------------------------------------------------------