├── .github └── workflows │ ├── alpha.yml │ ├── beta.yml │ ├── pr.yml │ └── release.yml ├── .gitignore ├── .idea └── icon.png ├── .run ├── All Unit Tests.run.xml ├── Analyze App Size.run.xml ├── Dependency Updates.run.xml ├── Gradle Dependencies Report.run.xml ├── Jacoco Coverage Report.run.xml └── Static Analysis.run.xml ├── Jenkinsfile ├── README.md ├── api-playstore-dummy.json ├── app ├── build.gradle.kts ├── google-services.json ├── gradle.properties ├── lint.xml ├── proguard-rules.pro └── src │ ├── alpha │ ├── ic_launcher-playstore.png │ └── res │ │ ├── drawable │ │ └── ic_launcher_foreground.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ └── values │ │ └── ic_launcher_background.xml │ ├── androidTest │ └── java │ │ └── org │ │ └── jdc │ │ └── template │ │ └── ux │ │ ├── SmokeTest.kt │ │ └── individualedit │ │ └── IndividualEditScreenTest.kt │ ├── beta │ ├── ic_launcher-playstore.png │ └── res │ │ ├── drawable │ │ └── ic_launcher_foreground.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ └── values │ │ └── ic_launcher_background.xml │ ├── debug │ ├── ic_launcher-playstore.png │ └── res │ │ ├── drawable │ │ └── ic_launcher_foreground.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ └── values │ │ └── ic_launcher_background.xml │ ├── main │ ├── AndroidManifest.xml │ ├── assets │ │ └── licenses.html │ ├── ic_launcher-playstore.png │ ├── kotlin │ │ └── org │ │ │ └── jdc │ │ │ └── template │ │ │ ├── App.kt │ │ │ ├── SuppressCoverage.kt │ │ │ ├── analytics │ │ │ ├── Analytics.kt │ │ │ ├── AppAnalytics.kt │ │ │ ├── DefaultAnalytics.kt │ │ │ ├── FirebaseStrategy.kt │ │ │ └── TestStrategy.kt │ │ │ ├── inject │ │ │ ├── AppModule.kt │ │ │ ├── CoroutinesModule.kt │ │ │ └── WebServiceModule.kt │ │ │ ├── model │ │ │ ├── config │ │ │ │ ├── BaseFirebaseRemoteConfig.kt │ │ │ │ ├── RemoteConfig.kt │ │ │ │ └── RemoteConfigUpdateListener.kt │ │ │ └── datastore │ │ │ │ └── PrefsDefaults.kt │ │ │ ├── startup │ │ │ ├── AppUpgrade.kt │ │ │ ├── AppUpgradeInitializer.kt │ │ │ ├── LoggingInitializer.kt │ │ │ ├── NotificationInitializer.kt │ │ │ └── RemoteConfigInitializer.kt │ │ │ ├── ui │ │ │ ├── compose │ │ │ │ ├── AutoSizeText.kt │ │ │ │ ├── DayNightPasswordTextField.kt │ │ │ │ ├── DayNightTextField.kt │ │ │ │ ├── Previews.kt │ │ │ │ ├── TextHeader.kt │ │ │ │ ├── appbar │ │ │ │ │ ├── AppBarMenu.kt │ │ │ │ │ └── AppBarTitle.kt │ │ │ │ ├── dialog │ │ │ │ │ ├── DatePickerDialog.kt │ │ │ │ │ ├── DateRangePickerDialog.kt │ │ │ │ │ ├── DialogDefaults.kt │ │ │ │ │ ├── DialogUiState.kt │ │ │ │ │ ├── DropDownMenuDialog.kt │ │ │ │ │ ├── InputDialog.kt │ │ │ │ │ ├── MenuOptionsDialog.kt │ │ │ │ │ ├── MessageDialog.kt │ │ │ │ │ ├── MultiSelectDialog.kt │ │ │ │ │ ├── ProgressIndicatorDialog.kt │ │ │ │ │ ├── RadioDialog.kt │ │ │ │ │ └── TimePickerDialog.kt │ │ │ │ ├── form │ │ │ │ │ ├── ClickableTextField.kt │ │ │ │ │ ├── DateClickableTextField.kt │ │ │ │ │ ├── DropdownMenuBoxField.kt │ │ │ │ │ ├── FlowTextField.kt │ │ │ │ │ ├── LabelEnumExposedDropdownMenuBox.kt │ │ │ │ │ ├── SwitchField.kt │ │ │ │ │ ├── TextWithTitle.kt │ │ │ │ │ └── TimeClickableTextField.kt │ │ │ │ ├── icons │ │ │ │ │ └── google │ │ │ │ │ │ └── outlined │ │ │ │ │ │ └── People.kt │ │ │ │ ├── list │ │ │ │ │ └── ListItemTextHeader.kt │ │ │ │ ├── menu │ │ │ │ │ └── OverflowMenu.kt │ │ │ │ ├── setting │ │ │ │ │ └── Setting.kt │ │ │ │ └── util │ │ │ │ │ ├── DateUiUtil.kt │ │ │ │ │ ├── FocusUtil.kt │ │ │ │ │ └── WindowSize.kt │ │ │ ├── navigation │ │ │ │ ├── NavControllerExt.kt │ │ │ │ ├── NavTypeMaps.kt │ │ │ │ ├── NavUriLogger.kt │ │ │ │ ├── NavigationAction.kt │ │ │ │ ├── NavigationRoute.kt │ │ │ │ ├── RouteUtil.kt │ │ │ │ ├── ViewModelNavigation.kt │ │ │ │ ├── ViewModelNavigationBar.kt │ │ │ │ └── WorkManagerStatusRoute.kt │ │ │ ├── notification │ │ │ │ ├── NotificationChannels.kt │ │ │ │ └── NotificationIds.kt │ │ │ ├── strings │ │ │ │ └── StringResourcesExt.kt │ │ │ └── theme │ │ │ │ ├── Colors.kt │ │ │ │ └── Theme.kt │ │ │ ├── util │ │ │ ├── ext │ │ │ │ └── ContextExt.kt │ │ │ └── time │ │ │ │ └── TimeFormatUtil.kt │ │ │ ├── ux │ │ │ ├── MainAppScaffoldWithNavBar.kt │ │ │ ├── NavGraph.kt │ │ │ ├── about │ │ │ │ ├── AboutRoute.kt │ │ │ │ ├── AboutScreen.kt │ │ │ │ ├── AboutUiState.kt │ │ │ │ ├── AboutViewModel.kt │ │ │ │ └── typography │ │ │ │ │ ├── TypographyRoute.kt │ │ │ │ │ └── TypographyScreen.kt │ │ │ ├── acknowledgement │ │ │ │ ├── AcknowledgementUiState.kt │ │ │ │ ├── AcknowledgementViewModel.kt │ │ │ │ ├── AcknowledgementsScreen.kt │ │ │ │ └── AcknowledgmentsRoute.kt │ │ │ ├── chat │ │ │ │ ├── ChatRoute.kt │ │ │ │ ├── ChatScreen.kt │ │ │ │ ├── ChatUiState.kt │ │ │ │ ├── ChatViewModel.kt │ │ │ │ └── chatbubble │ │ │ │ │ ├── ChatBubbleConstraints.kt │ │ │ │ │ ├── ChatTextField.kt │ │ │ │ │ ├── MessageStatus.kt │ │ │ │ │ ├── MessageTimeText.kt │ │ │ │ │ ├── ReceivedMessageRow.kt │ │ │ │ │ ├── RecipientName.kt │ │ │ │ │ ├── SentMessageRow.kt │ │ │ │ │ └── TextMessageInsideBubble.kt │ │ │ ├── chats │ │ │ │ ├── ChatsRoute.kt │ │ │ │ ├── ChatsScreen.kt │ │ │ │ ├── ChatsUiState.kt │ │ │ │ └── ChatsViewModel.kt │ │ │ ├── directory │ │ │ │ ├── DirectoryRoute.kt │ │ │ │ ├── DirectoryScreen.kt │ │ │ │ ├── DirectoryUiState.kt │ │ │ │ ├── DirectoryViewModel.kt │ │ │ │ └── GetDirectoryUiStateUseCase.kt │ │ │ ├── individual │ │ │ │ ├── GetIndividualUiStateUseCase.kt │ │ │ │ ├── IndividualRoute.kt │ │ │ │ ├── IndividualScreen.kt │ │ │ │ ├── IndividualUiState.kt │ │ │ │ └── IndividualViewModel.kt │ │ │ ├── individualedit │ │ │ │ ├── GetIndividualEditUiStateUseCase.kt │ │ │ │ ├── IndividualEditRoute.kt │ │ │ │ ├── IndividualEditScreen.kt │ │ │ │ ├── IndividualEditUiState.kt │ │ │ │ └── IndividualEditViewModel.kt │ │ │ ├── main │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── MainScreen.kt │ │ │ │ ├── MainUiState.kt │ │ │ │ ├── MainViewModel.kt │ │ │ │ └── NavBarItem.kt │ │ │ └── settings │ │ │ │ ├── SettingsRoute.kt │ │ │ │ ├── SettingsScreen.kt │ │ │ │ ├── SettingsUiState.kt │ │ │ │ └── SettingsViewModel.kt │ │ │ └── work │ │ │ ├── RemoteConfigSyncWorker.kt │ │ │ ├── SimpleWorker.kt │ │ │ ├── SyncWorker.kt │ │ │ └── WorkScheduler.kt │ └── res │ │ ├── drawable │ │ └── ic_launcher_foreground.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── resources.properties │ │ ├── values-v31 │ │ └── themes.xml │ │ ├── values │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ └── remote_config_defaults.xml │ └── test │ └── kotlin │ ├── konsist │ ├── KonsistAndroidTests.kt │ ├── KonsistConts.kt │ └── KonsistTests.kt │ └── org │ └── jdc │ └── template │ ├── LoggingUtil.kt │ ├── TestFilesystem.kt │ ├── inject │ └── CommonTestModule.kt │ ├── model │ └── repository │ │ └── IndividualRepositoryTest.kt │ └── ux │ ├── SharedUseCaseTest.kt │ ├── directory │ └── GetDirectoryUiStateUseCaseTest.kt │ ├── individual │ └── GetIndividualUiStateUseCaseTest.kt │ └── individualedit │ └── GetIndividualEditUiStateUseCaseTest.kt ├── build.gradle.kts ├── buildSrc ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── AppInfo.kt ├── docs └── privacy-policy.html ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle.kts └── shared ├── build.gradle.kts ├── schemas └── org.jdc.template.shared.model.db.main.MainDatabase │ ├── 1.json │ ├── 2.json │ └── 3.json └── src ├── androidDeviceTest └── kotlin │ └── org │ └── jdc │ └── template │ └── shared │ └── ExampleInstrumentedTest.kt ├── androidHostTest └── kotlin │ └── org │ └── jdc │ └── template │ └── shared │ └── ExampleUnitTest.kt ├── androidMain ├── AndroidManifest.xml └── kotlin │ └── org │ └── jdc │ └── template │ └── shared │ └── Platform.android.kt ├── commonMain └── kotlin │ └── org │ └── jdc │ └── template │ └── shared │ ├── Platform.kt │ ├── domain │ └── usecase │ │ ├── CreateIndividualLargeTestDataUseCase.kt │ │ └── CreateIndividualTestDataUseCase.kt │ ├── model │ ├── datastore │ │ ├── Datastore.kt │ │ ├── DevicePreferenceDataSource.kt │ │ ├── UserPreferenceDataSource.kt │ │ └── migration │ │ │ ├── DevicePreferenceMigration1To3.kt │ │ │ ├── DevicePreferenceMigration2.kt │ │ │ └── DevicePreferenceMigration3.kt │ ├── db │ │ ├── converter │ │ │ ├── DataValueClassTypeConverters.kt │ │ │ └── KotlinDateTimeTextConverter.kt │ │ └── main │ │ │ ├── MainDatabase.kt │ │ │ ├── chatmessage │ │ │ ├── ChatMessageDao.kt │ │ │ └── ChatMessageEntity.kt │ │ │ ├── chatthread │ │ │ ├── ChatThreadDao.kt │ │ │ └── ChatThreadEntity.kt │ │ │ ├── directoryitem │ │ │ ├── DirectoryItemDao.kt │ │ │ └── DirectoryItemEntityView.kt │ │ │ ├── household │ │ │ ├── HouseholdDao.kt │ │ │ ├── HouseholdEntity.kt │ │ │ └── HouseholdMembers.kt │ │ │ ├── individual │ │ │ ├── IndividualDao.kt │ │ │ └── IndividualEntity.kt │ │ │ └── migration │ │ │ ├── MainAutoMigrationSpec3.kt │ │ │ └── MainMigration2.kt │ ├── domain │ │ ├── ChatMessage.kt │ │ ├── ChatThread.kt │ │ ├── ChatThreadListItem.kt │ │ ├── Household.kt │ │ ├── Individual.kt │ │ ├── inline │ │ │ └── DomainInlineValue.kt │ │ └── type │ │ │ ├── DisplayThemeType.kt │ │ │ └── IndividualType.kt │ ├── repository │ │ ├── ChatRepository.kt │ │ ├── IndividualRepository.kt │ │ └── SettingsRepository.kt │ └── webservice │ │ ├── KtorClientDefaults.kt │ │ └── colors │ │ ├── ColorService.kt │ │ └── dto │ │ ├── ColorDto.kt │ │ ├── ColorsDto.kt │ │ └── ErrorDto.kt │ └── util │ ├── datastore │ ├── DatastorePrefItem.kt │ └── PreferenceMigrations.kt │ ├── ext │ ├── EnumExt.kt │ ├── FlowExt.kt │ ├── KtorExt.kt │ ├── OkioExt.kt │ └── OkioZipExt.kt │ ├── flow │ └── RefreshFlow.kt │ ├── log │ ├── CrashLogException.kt │ └── KtorKermitLogger.kt │ └── network │ ├── ApiResponse.kt │ └── CacheApiResponse.kt ├── commonTest └── kotlin │ └── org │ └── jdc │ └── template │ └── shared │ ├── model │ └── webservice │ │ ├── TestHttpClientProvider.kt │ │ └── colors │ │ └── ColorServiceTest.kt │ └── util │ └── network │ ├── ApiResponseTest.kt │ ├── CacheApiResponseTest.kt │ └── TestHttpClientProvider.kt ├── iosMain └── kotlin │ └── org │ └── jdc │ └── template │ └── shared │ └── Platform.ios.kt ├── jvmMain └── kotlin │ └── org │ └── jdc │ └── template │ └── shared │ └── Platform.jvm.kt └── jvmTest └── kotlin └── org └── jdc └── template └── shared └── model └── db └── main ├── MainDatabaseTest.kt └── migration ├── MainMigration2Test.kt └── MainMigration3Test.kt /.github/workflows/alpha.yml: -------------------------------------------------------------------------------- 1 | name: Alpha CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | build: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | env: 13 | GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6G -XX:MaxMetaspaceSize=4G" 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: set up JDK 21 18 | uses: actions/setup-java@v4 19 | with: 20 | java-version: '21' 21 | distribution: 'temurin' 22 | cache: gradle 23 | 24 | - name: Grant execute permission for gradlew 25 | run: chmod +x gradlew 26 | 27 | - name: Setup 28 | env: 29 | ENCODED_STRING: ${{ secrets.UPLOAD_KEYSTORE }} 30 | APP_DISTRIBUTION_KEYS: ${{ secrets.APP_DISTRIBUTION_KEYS }} 31 | PLAYSTORE_KEYS: ${{ secrets.PLAYSTORE_KEYS }} 32 | run: | 33 | git log --date=format:"%Y-%m-%d" --pretty="format: * %s% b (%an, %cd)" | head -n 10 > commit-changelog.txt 34 | echo $ENCODED_STRING | base64 -d -i > app/upload-keystore 35 | echo $APP_DISTRIBUTION_KEYS > app-distribution.json 36 | echo PLAYSTORE_KEYS > playstore.json 37 | 38 | - name: License 39 | run: ./gradlew createLicenseReport 40 | 41 | - name: Build 42 | run: ./gradlew clean assembleAlpha bundleAlpha 43 | env: 44 | SIGNING_KEYSTORE: upload-keystore 45 | SIGNING_STORE_PASSWORD: ${{ secrets.UPLOAD_SIGNING_STORE_PASSWORD }} 46 | SIGNING_KEY_ALIAS: ${{ secrets.UPLOAD_SIGNING_KEY_ALIAS }} 47 | SIGNING_KEY_PASSWORD: ${{ secrets.UPLOAD_SIGNING_KEY_PASSWORD }} 48 | 49 | - name: Test 50 | run: ./gradlew testAlphaUnitTest jvmTest 51 | 52 | - name: Lint 53 | run: ./gradlew lintAlpha 54 | 55 | - name: Detekt 56 | run: ./gradlew detekt detektAlpha 57 | 58 | - name: App Distribution 59 | run: ./gradlew appDistributionUploadAlpha 60 | 61 | - name: Upload artifact 62 | uses: actions/upload-artifact@v4 63 | with: 64 | name: build-outputs 65 | path: app/build/outputs 66 | 67 | - name: Upload build reports 68 | if: always() 69 | uses: actions/upload-artifact@v4 70 | with: 71 | name: build-reports 72 | path: | 73 | **/build/licenses/*.html 74 | **/build/test-results/**/TEST-*.xml 75 | app/build/reports/*.html 76 | */build/reports/detekt/*.html 77 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: PR CI 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | 7 | jobs: 8 | build: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | # GRADLE_OPTS do not impact the JVM used by the daemon to do the actual builds (gradle.properties is where you configure the daemon's JVM). 13 | # This only impacts the configuration of the JVM that runs the gradle client. MaxMetaspaceSize is unbounded by default, which leads to a 14 | # condition where the unbounded metaspace allocations starve some other component of the build, and the gradle daemon disappears. 15 | # https://docs.gradle.org/6.7.1/userguide/build_environment.html#sec:gradle_environment_variables 16 | # https://stackoverflow.com/a/69348586/2476068 17 | env: 18 | GRADLE_OPTS: -Dorg.gradle.jvmargs="-XX:MaxMetaspaceSize=2g" 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: set up JDK 21 23 | uses: actions/setup-java@v4 24 | with: 25 | java-version: '21' 26 | distribution: 'temurin' 27 | cache: gradle 28 | 29 | - name: Grant execute permission for gradlew 30 | run: chmod +x gradlew 31 | 32 | - name: Setup 33 | env: 34 | ENCODED_STRING: ${{ secrets.UPLOAD_KEYSTORE }} 35 | APP_DISTRIBUTION_KEYS: ${{ secrets.APP_DISTRIBUTION_KEYS }} 36 | PLAYSTORE_KEYS: ${{ secrets.PLAYSTORE_KEYS }} 37 | run: | 38 | git log --date=format:"%Y-%m-%d" --pretty="format: * %s% b (%an, %cd)" | head -n 10 > commit-changelog.txt 39 | echo $ENCODED_STRING | base64 -d -i > app/upload-keystore 40 | echo $APP_DISTRIBUTION_KEYS > app-distribution.json 41 | echo PLAYSTORE_KEYS > playstore.json 42 | 43 | - name: License 44 | run: ./gradlew createLicenseReport 45 | 46 | - name: Build 47 | run: ./gradlew clean assembleAlpha bundleAlpha 48 | env: 49 | SIGNING_KEYSTORE: upload-keystore 50 | SIGNING_STORE_PASSWORD: ${{ secrets.UPLOAD_SIGNING_STORE_PASSWORD }} 51 | SIGNING_KEY_ALIAS: ${{ secrets.UPLOAD_SIGNING_KEY_ALIAS }} 52 | SIGNING_KEY_PASSWORD: ${{ secrets.UPLOAD_SIGNING_KEY_PASSWORD }} 53 | 54 | - name: Test 55 | run: ./gradlew testAlphaUnitTest jvmTest 56 | 57 | - name: Lint 58 | run: ./gradlew lintAlpha 59 | 60 | - name: Detekt 61 | run: ./gradlew detekt detektAlpha 62 | 63 | - name: Upload build reports 64 | if: always() 65 | uses: actions/upload-artifact@v4 66 | with: 67 | name: build-reports 68 | path: | 69 | **/build/licenses/*.html 70 | **/build/test-results/**/TEST-*.xml 71 | app/build/reports/*.html 72 | */build/reports/detekt/*.html 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Temp files 2 | *~ 3 | 4 | # NetBeans 5 | nb-configuration.xml 6 | # nbactions.xml 7 | 8 | # Eclipse 9 | .settings 10 | .classpath 11 | .project 12 | .mymetadata 13 | 14 | # IDEA 15 | .idea/ 16 | *.ipr 17 | *.iml 18 | *.iws 19 | 20 | # Maven 21 | target/ 22 | 23 | #Gradle 24 | .gradle/ 25 | build/ 26 | local.properties 27 | 28 | # Android 29 | classes/ 30 | bin/ 31 | gen/ 32 | gen-external-apklibs/ 33 | /commit-changelog.txt 34 | 35 | #java 36 | *.hprof 37 | 38 | #kotlin 39 | .kotlin/ -------------------------------------------------------------------------------- /.idea/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/.idea/icon.png -------------------------------------------------------------------------------- /.run/All Unit Tests.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 18 | 20 | false 21 | true 22 | 23 | 24 | 25 | 29 | 30 | 34 | 35 | 39 | 40 | 44 | 45 | 46 | false 47 | true 48 | 49 | 50 | -------------------------------------------------------------------------------- /.run/Analyze App Size.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 16 | 18 | true 19 | true 20 | false 21 | false 22 | 23 | 24 | -------------------------------------------------------------------------------- /.run/Dependency Updates.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 16 | 18 | true 19 | true 20 | false 21 | false 22 | 23 | 24 | -------------------------------------------------------------------------------- /.run/Gradle Dependencies Report.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 18 | 20 | true 21 | true 22 | false 23 | false 24 | 25 | 26 | -------------------------------------------------------------------------------- /.run/Jacoco Coverage Report.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 16 | 18 | true 19 | true 20 | false 21 | false 22 | 23 | 24 | -------------------------------------------------------------------------------- /.run/Static Analysis.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 17 | 19 | true 20 | true 21 | false 22 | false 23 | 24 | 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Android Template 2 | ================= 3 | 4 | [![Build](https://github.com/jeffdcamp/android-template/actions/workflows/alpha.yml/badge.svg)](https://github.com/jeffdcamp/android-template/actions) 5 | 6 | Sample Android app that utilizes common best practices 7 | 8 | **Features** 9 | 10 | * Compose 11 | * Kotlin 12 | * Kotlin Coroutines 13 | * Kotlin Serialization (json) 14 | * Kotlin Gradle support (build.gradle.kts) 15 | * Android Architecture Components 16 | * ViewModel 17 | * Flow 18 | * Room 19 | * Navigation 20 | * WorkManager 21 | * Startup Initializer 22 | * Dagger dependency injection using Jetpack Hilt 23 | * Material3 styles and themes 24 | * Datastore 25 | * kotlinx-datetime 26 | * Ktor Client with Okhttp3 27 | * JUnit tests / Mockk / Kover 28 | * Firebase Analytics 29 | * Kermit Logging 30 | * Gradle Play Publisher (Triple-T) 31 | * Detekt 32 | 33 | 34 | License 35 | ======= 36 | 37 | Copyright 2012-2025 Jeff Campbell 38 | 39 | Licensed under the Apache License, Version 2.0 (the "License"); 40 | you may not use this file except in compliance with the License. 41 | You may obtain a copy of the License at 42 | 43 | http://www.apache.org/licenses/LICENSE-2.0 44 | 45 | Unless required by applicable law or agreed to in writing, software 46 | distributed under the License is distributed on an "AS IS" BASIS, 47 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 48 | See the License for the specific language governing permissions and 49 | limitations under the License. -------------------------------------------------------------------------------- /api-playstore-dummy.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "", 3 | "project_id": "", 4 | "private_key_id": "", 5 | "private_key": "-----BEGIN PRIVATE KEY-----\n\n-----END PRIVATE KEY-----\n", 6 | "client_email": "", 7 | "client_id": "", 8 | "auth_uri": "", 9 | "token_uri": "", 10 | "auth_provider_x509_cert_url": "", 11 | "client_x509_cert_url": "" 12 | } 13 | -------------------------------------------------------------------------------- /app/gradle.properties: -------------------------------------------------------------------------------- 1 | archivesBaseName=android-template -------------------------------------------------------------------------------- /app/lint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | ## Add project specific ProGuard rules here. 2 | 3 | # Don't obfuscate so that Crashlytics reports and logcat errors can be read 4 | # https://firebase.google.com/docs/crashlytics/get-deobfuscated-reports?platform=android 5 | -dontobfuscate 6 | -keepattributes SourceFile,LineNumberTable # Keep file names and line numbers. 7 | -keep public class * extends java.lang.Exception # Optional: Keep custom exceptions. 8 | 9 | #https://r8.googlesource.com/r8/+/refs/heads/master/compatibility-faq.md 10 | 11 | ##---------------Begin: proguard configuration for Ktor Client ---------- 12 | # Issue Tracker (Initial issue/Work-around): https://youtrack.jetbrains.com/issue/KTOR-5528 13 | # Issue Tracker (Fix): https://youtrack.jetbrains.com/issue/KTOR-3484 14 | -dontwarn org.slf4j.impl.StaticLoggerBinder 15 | ##---------------End: proguard configuration for Ktor Client ---------- 16 | -------------------------------------------------------------------------------- /app/src/alpha/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/alpha/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/alpha/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/alpha/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/alpha/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/alpha/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/alpha/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/alpha/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/alpha/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/alpha/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/alpha/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/alpha/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/alpha/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/alpha/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/alpha/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/alpha/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/alpha/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/alpha/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/alpha/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/alpha/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/alpha/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/alpha/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/alpha/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/alpha/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/alpha/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #3574DC 4 | -------------------------------------------------------------------------------- /app/src/androidTest/java/org/jdc/template/ux/individualedit/IndividualEditScreenTest.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.individualedit 2 | 3 | import androidx.compose.ui.test.assertTextContains 4 | import androidx.compose.ui.test.junit4.createComposeRule 5 | import androidx.compose.ui.test.onNodeWithTag 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import org.junit.Rule 8 | import org.junit.Test 9 | 10 | class IndividualEditScreenTest { 11 | @get:Rule 12 | val composeTestRule = createComposeRule() 13 | 14 | @Test 15 | fun testFields() { 16 | val firstNameFlow = MutableStateFlow("Jeff") 17 | val lastNameFlow = MutableStateFlow("Campbell") 18 | val phoneNumberFlow = MutableStateFlow("801-555-0000") 19 | val emailFlow = MutableStateFlow("") 20 | 21 | val individualEditUiState = IndividualEditUiState( 22 | firstNameFlow = firstNameFlow, 23 | firstNameOnChange = { firstNameFlow.value = it }, 24 | lastNameFlow = lastNameFlow, 25 | lastNameOnChange = { lastNameFlow.value = it }, 26 | phoneFlow = phoneNumberFlow, 27 | phoneOnChange = { phoneNumberFlow.value = it }, 28 | emailFlow = emailFlow, 29 | emailOnChange = { emailFlow.value = it } 30 | ) 31 | 32 | composeTestRule.setContent { 33 | IndividualEditContent(individualEditUiState) 34 | } 35 | 36 | composeTestRule.onNodeWithTag(IndividualEditScreenFields.FIRST_NAME.name).assertTextContains("Jeff") 37 | composeTestRule.onNodeWithTag(IndividualEditScreenFields.LAST_NAME.name).assertTextContains("Campbell") 38 | composeTestRule.onNodeWithTag(IndividualEditScreenFields.PHONE.name).assertTextContains("801-555-0000") 39 | composeTestRule.onNodeWithTag(IndividualEditScreenFields.EMAIL.name).assertTextContains("") 40 | } 41 | } -------------------------------------------------------------------------------- /app/src/beta/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/beta/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/beta/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/beta/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/beta/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/beta/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/beta/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/beta/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/beta/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/beta/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/beta/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/beta/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/beta/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/beta/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/beta/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/beta/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/beta/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/beta/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/beta/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/beta/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/beta/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/beta/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/beta/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/beta/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/beta/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #3574DC 4 | -------------------------------------------------------------------------------- /app/src/debug/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/debug/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/debug/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/debug/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/debug/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/debug/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #3574DC 4 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/App.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | @HiltAndroidApp 7 | class App : Application() 8 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/SuppressCoverage.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template 2 | 3 | 4 | @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) 5 | @Retention(AnnotationRetention.RUNTIME) 6 | @MustBeDocumented 7 | annotation class SuppressCoverage 8 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/analytics/Analytics.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.analytics 2 | 3 | import android.app.Activity 4 | import android.content.Intent 5 | 6 | interface Analytics { 7 | fun upload() 8 | fun setDimensions(dimensions: List) 9 | fun logEvent(eventId: String, attributes: Map = emptyMap(), scope: AppAnalytics.ScopeLevel = AppAnalytics.ScopeLevel.DEV) 10 | fun logScreen(screen: String) 11 | fun enableInAppNotifications(allow: Boolean) 12 | fun onNewIntent(activity: Activity, intent: Intent) 13 | 14 | companion object { 15 | // Events 16 | const val EVENT_LAUNCH_APP = "launch_event" 17 | const val EVENT_VIEW_DIRECTORY = "view_directory" 18 | const val EVENT_VIEW_INDIVIDUAL = "view_individual" 19 | const val EVENT_EDIT_INDIVIDUAL = "edit_individual" 20 | const val EVENT_DELETE_INDIVIDUAL = "delete_individual" 21 | const val EVENT_VIEW_ABOUT = "view_about" 22 | const val EVENT_VIEW_SETTINGS = "view_settings" 23 | 24 | // Params 25 | const val PARAM_BUILD_TYPE = "build_type" 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/analytics/AppAnalytics.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.analytics 2 | 3 | object AppAnalytics { 4 | 5 | private val strategies: MutableList = ArrayList() 6 | private var logLevel = LogLevel.NONE 7 | val DEFAULT_EVENT_SCOPE_LEVEL = ScopeLevel.DEV 8 | val DEFAULT_SCREEN_SCOPE_LEVEL = ScopeLevel.DEV 9 | val DEFAULT_ERROR_SCOPE_LEVEL = ScopeLevel.DEV 10 | private var enableProviderLogging = false 11 | 12 | fun register(strategy: Strategy) { 13 | strategy.setLogLevel(logLevel, enableProviderLogging) 14 | strategies.add(strategy) 15 | } 16 | 17 | fun getRegistered(): List = strategies 18 | 19 | inline fun findRegistered(): List { 20 | val results = ArrayList() 21 | 22 | getRegistered().forEach { 23 | if (it is T) { 24 | results.add(it) 25 | } 26 | } 27 | 28 | return results 29 | } 30 | 31 | fun logEvent(eventId: String, parameterMap: Map = HashMap(), scopeLevel: ScopeLevel = DEFAULT_EVENT_SCOPE_LEVEL) { 32 | strategies.forEach { 33 | it.logEvent(eventId, parameterMap, scopeLevel) 34 | } 35 | } 36 | 37 | fun logScreen(screenTitle: String, parameterMap: Map = HashMap(), scopeLevel: ScopeLevel = DEFAULT_SCREEN_SCOPE_LEVEL) { 38 | strategies.forEach { 39 | it.logScreen(screenTitle, parameterMap, scopeLevel) 40 | } 41 | } 42 | 43 | fun logError(errorMessage: String, errorClass: String, scopeLevel: ScopeLevel = DEFAULT_ERROR_SCOPE_LEVEL) { 44 | strategies.forEach { 45 | it.logError(errorMessage, errorClass, scopeLevel) 46 | } 47 | } 48 | 49 | fun setLogLevel(logLevel: LogLevel, enableProviderLogging: Boolean = false) { 50 | this.logLevel = logLevel 51 | this.enableProviderLogging = enableProviderLogging 52 | 53 | strategies.forEach { 54 | it.setLogLevel(logLevel, enableProviderLogging) 55 | } 56 | } 57 | 58 | enum class LogLevel { 59 | NONE, 60 | UPLOAD, 61 | EVENT, 62 | SESSION, 63 | VERBOSE 64 | } 65 | 66 | enum class ScopeLevel { 67 | BUSINESS, 68 | DEV 69 | } 70 | 71 | interface Strategy { 72 | fun logEvent(eventId: String, parameterMap: Map = HashMap(), scopeLevel: ScopeLevel = DEFAULT_EVENT_SCOPE_LEVEL) 73 | 74 | fun logScreen(screenTitle: String, parameterMap: Map = HashMap(), scopeLevel: ScopeLevel = DEFAULT_SCREEN_SCOPE_LEVEL) 75 | 76 | fun logError(errorMessage: String, errorClass: String, scopeLevel: ScopeLevel = DEFAULT_ERROR_SCOPE_LEVEL) 77 | 78 | fun setLogLevel(logLevel: LogLevel, enableProviderLogging: Boolean = false) 79 | } 80 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/inject/CoroutinesModule.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.inject 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.CoroutineScope 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.SupervisorJob 11 | import javax.inject.Qualifier 12 | import javax.inject.Singleton 13 | 14 | 15 | @Qualifier 16 | @Retention(AnnotationRetention.RUNTIME) 17 | annotation class IoDispatcher 18 | 19 | @Retention(AnnotationRetention.RUNTIME) 20 | @Qualifier 21 | annotation class DefaultDispatcher 22 | 23 | @Retention(AnnotationRetention.RUNTIME) 24 | @Qualifier 25 | annotation class MainDispatcher 26 | 27 | @Retention(AnnotationRetention.RUNTIME) 28 | @Qualifier 29 | annotation class ApplicationScope 30 | 31 | /** 32 | * Dispatchers and CoroutineScope to be used throughout app 33 | * 34 | * Usage Example: 35 | * @HiltViewModel 36 | * class MyViewModel 37 | * @Inject constructor( 38 | * @IoDispatcher private val ioDispatcher: CoroutineDispatcher, (Disk/Network tasks) 39 | * @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, (CPU/Processing tasks) 40 | * @MainDispatcher private val mainDispatcher: CoroutineDispatcher, 41 | * @ApplicationScope private val appScope: CoroutineScope, 42 | * ) : ViewModel() { 43 | * 44 | * } 45 | */ 46 | @Module 47 | @InstallIn(SingletonComponent::class) 48 | object CoroutinesModule { 49 | 50 | @Provides 51 | @IoDispatcher 52 | fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO 53 | 54 | @Provides 55 | @DefaultDispatcher 56 | fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default 57 | 58 | @Provides 59 | @MainDispatcher 60 | fun providesMainDispatcher(): CoroutineDispatcher = Dispatchers.Main 61 | 62 | @Provides 63 | @Singleton 64 | @ApplicationScope 65 | fun providesCoroutineScope( 66 | @DefaultDispatcher dispatcher: CoroutineDispatcher 67 | ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) 68 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/inject/WebServiceModule.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.inject 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import io.ktor.client.HttpClient 8 | import io.ktor.client.engine.okhttp.OkHttp 9 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 10 | import io.ktor.client.plugins.defaultRequest 11 | import io.ktor.client.plugins.logging.Logging 12 | import io.ktor.client.plugins.resources.Resources 13 | import org.jdc.template.shared.model.webservice.KtorClientDefaults.defaultSetup 14 | import org.jdc.template.shared.model.webservice.ResponseTimePlugin 15 | import org.jdc.template.shared.model.webservice.colors.ColorService 16 | import javax.inject.Singleton 17 | 18 | @Module 19 | @InstallIn(SingletonComponent::class) 20 | class WebServiceModule { 21 | @Provides 22 | @Singleton 23 | fun provideKtorHttpClient(): HttpClient { 24 | return HttpClient(OkHttp.create()) { 25 | install(Logging) { 26 | defaultSetup() 27 | } 28 | install(ResponseTimePlugin) 29 | install(Resources) 30 | install(ContentNegotiation) { 31 | defaultSetup(allowAnyContentType = true) 32 | } 33 | 34 | defaultRequest { 35 | url("https://raw.githubusercontent.com/jeffdcamp/android-template/33017aa38f59b3ff728a26c1ee350e58c8bb9647/src/test/") 36 | } 37 | } 38 | } 39 | 40 | @Provides 41 | @Singleton 42 | fun provideColorService(httpClient: HttpClient): ColorService { 43 | return ColorService(httpClient) 44 | } 45 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/model/config/RemoteConfig.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.model.config 2 | 3 | import org.jdc.template.R 4 | import javax.inject.Inject 5 | import javax.inject.Singleton 6 | 7 | @Singleton 8 | class RemoteConfig 9 | @Inject constructor() : BaseFirebaseRemoteConfig() { 10 | 11 | // app update 12 | fun isColorServiceEnabled(): Boolean = getBoolean("colorServiceEnabled") 13 | 14 | fun showAllValues(): String { 15 | return """ 16 | * colorServiceEnabled: ${isColorServiceEnabled()} 17 | """.trimIndent() 18 | } 19 | 20 | override fun getDefaults(): Int = R.xml.remote_config_defaults 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/model/config/RemoteConfigUpdateListener.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.model.config 2 | 3 | /** 4 | * Listener for remote config updates 5 | */ 6 | fun interface RemoteConfigUpdateListener { 7 | /** 8 | * Called when the remote config has been activated with realtime config changs 9 | */ 10 | fun onRemoteConfigUpdated() 11 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/model/datastore/PrefsDefaults.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.model.datastore 2 | 3 | import android.os.Build 4 | import org.jdc.template.shared.model.domain.type.DisplayThemeType 5 | 6 | object PrefsDefaults { 7 | val SYSTEM_THEME_TYPE = getSystemThemeTypeDefault() 8 | 9 | @Deprecated("This is only required for systems that don't support a system dark mode", ReplaceWith("DisplayThemeType.SYSTEM_DEFAULT")) 10 | private fun getSystemThemeTypeDefault(): DisplayThemeType { 11 | return if (Build.VERSION.SDK_INT > 28) { 12 | // support Android Q System Theme 13 | DisplayThemeType.SYSTEM_DEFAULT 14 | } else { 15 | DisplayThemeType.LIGHT 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/startup/AppUpgrade.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.startup 2 | 3 | import co.touchlab.kermit.Logger 4 | import kotlinx.coroutines.CoroutineDispatcher 5 | import kotlinx.coroutines.runBlocking 6 | import org.jdc.template.BuildConfig 7 | import org.jdc.template.inject.IoDispatcher 8 | import org.jdc.template.shared.model.repository.SettingsRepository 9 | import javax.inject.Inject 10 | 11 | class AppUpgrade 12 | @Inject constructor( 13 | private val settingsRepository: SettingsRepository, 14 | @IoDispatcher private val ioDispatcher: CoroutineDispatcher 15 | ) { 16 | 17 | fun upgradeApp() = runBlocking(ioDispatcher) { 18 | val lastInstalledVersionCode = settingsRepository.getLastInstalledVersionCode() 19 | Logger.i { "Checking for app upgrade from [$lastInstalledVersionCode]" } 20 | 21 | if (lastInstalledVersionCode == 0) { 22 | Logger.i { "Skipping app upgrade on fresh install" } 23 | settingsRepository.setLastInstalledVersionCodeAsync(BuildConfig.VERSION_CODE) 24 | return@runBlocking 25 | } 26 | 27 | // if (lastInstalledVersionCode < VERSION_CODE_HERE) { 28 | // migrateXXX() 29 | // } 30 | 31 | // set the current version 32 | settingsRepository.setLastInstalledVersionCodeAsync(BuildConfig.VERSION_CODE) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/startup/AppUpgradeInitializer.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.startup 2 | 3 | import android.content.Context 4 | import androidx.startup.Initializer 5 | import dagger.hilt.EntryPoint 6 | import dagger.hilt.EntryPoints 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.components.SingletonComponent 9 | 10 | class AppUpgradeInitializer : Initializer { 11 | 12 | override fun create(context: Context) { 13 | val applicationContext = checkNotNull(context.applicationContext) { "Missing Application Context" } 14 | val injector = EntryPoints.get(applicationContext, AppUpgradeInitializerInjector::class.java) 15 | 16 | injector.getAppUpgrade().upgradeApp() 17 | } 18 | 19 | override fun dependencies(): List>> { 20 | return listOf(LoggingInitializer::class.java) 21 | } 22 | 23 | @EntryPoint 24 | @InstallIn(SingletonComponent::class) 25 | interface AppUpgradeInitializerInjector { 26 | fun getAppUpgrade(): AppUpgrade 27 | } 28 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/startup/LoggingInitializer.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.startup 2 | 3 | import android.content.Context 4 | import androidx.startup.Initializer 5 | import co.touchlab.kermit.ExperimentalKermitApi 6 | import co.touchlab.kermit.Logger 7 | import co.touchlab.kermit.Severity 8 | import co.touchlab.kermit.crashlytics.CrashlyticsLogWriter 9 | import org.jdc.template.BuildConfig 10 | 11 | class LoggingInitializer : Initializer { 12 | 13 | @OptIn(ExperimentalKermitApi::class) 14 | override fun create(context: Context) { 15 | Logger.setTag(BuildConfig.APPLICATION_ID) 16 | 17 | if (!BuildConfig.DEBUG) { 18 | Logger.setMinSeverity(Severity.Info) 19 | Logger.addLogWriter(CrashlyticsLogWriter()) 20 | } 21 | } 22 | 23 | override fun dependencies(): List>> { 24 | return emptyList() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/startup/NotificationInitializer.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.startup 2 | 3 | import android.content.Context 4 | import androidx.startup.Initializer 5 | import org.jdc.template.ui.notification.NotificationChannels 6 | 7 | class NotificationInitializer : Initializer { 8 | 9 | override fun create(context: Context) { 10 | NotificationChannels.registerAllChannels(context) 11 | } 12 | 13 | override fun dependencies(): List>> { 14 | return emptyList() 15 | } 16 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/startup/RemoteConfigInitializer.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.startup 2 | 3 | import android.content.Context 4 | import androidx.startup.Initializer 5 | import dagger.hilt.EntryPoint 6 | import dagger.hilt.EntryPoints 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.components.SingletonComponent 9 | import org.jdc.template.model.config.RemoteConfig 10 | 11 | class RemoteConfigInitializer : Initializer { 12 | 13 | override fun create(context: Context) { 14 | val applicationContext = checkNotNull(context.applicationContext) { "Missing Application Context" } 15 | val injector = EntryPoints.get(applicationContext, RemoteConfigInitializerInjector::class.java) 16 | 17 | val remoteConfig = injector.getRemoteConfig() 18 | 19 | remoteConfig.fetchAsync() 20 | remoteConfig.activateAsync() 21 | } 22 | 23 | override fun dependencies(): List>> { 24 | return listOf(LoggingInitializer::class.java) 25 | } 26 | 27 | @EntryPoint 28 | @InstallIn(SingletonComponent::class) 29 | interface RemoteConfigInitializerInjector { 30 | fun getRemoteConfig(): RemoteConfig 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/compose/AutoSizeText.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.compose 2 | 3 | import androidx.compose.material3.LocalTextStyle 4 | import androidx.compose.material3.Text 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.runtime.mutableStateOf 8 | import androidx.compose.runtime.remember 9 | import androidx.compose.runtime.setValue 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.draw.drawWithContent 12 | import androidx.compose.ui.graphics.Color 13 | import androidx.compose.ui.text.TextStyle 14 | import androidx.compose.ui.text.font.FontFamily 15 | import androidx.compose.ui.text.font.FontStyle 16 | import androidx.compose.ui.text.font.FontWeight 17 | import androidx.compose.ui.text.style.TextAlign 18 | import androidx.compose.ui.text.style.TextDecoration 19 | import androidx.compose.ui.text.style.TextOverflow 20 | import androidx.compose.ui.unit.TextUnit 21 | import androidx.compose.ui.unit.sp 22 | 23 | @Composable 24 | fun AutoSizeText( 25 | text: String, 26 | modifier: Modifier = Modifier, 27 | color: Color = Color.Unspecified, 28 | fontSize: TextUnit = TextUnit.Unspecified, 29 | fontStyle: FontStyle? = null, 30 | fontWeight: FontWeight? = null, 31 | fontFamily: FontFamily? = null, 32 | letterSpacing: TextUnit = TextUnit.Unspecified, 33 | textDecoration: TextDecoration? = null, 34 | textAlign: TextAlign? = null, 35 | lineHeight: TextUnit = TextUnit.Unspecified, 36 | overflow: TextOverflow = TextOverflow.Clip, 37 | maxLines: Int = Int.MAX_VALUE, 38 | style: TextStyle = LocalTextStyle.current, 39 | minFontSize: TextUnit = 12.sp 40 | ) { 41 | var scaledTextStyle by remember(text) { mutableStateOf(style) } 42 | var readyToDraw by remember(text) { mutableStateOf(false) } 43 | 44 | Text( 45 | text, 46 | modifier.drawWithContent { 47 | if (readyToDraw) { 48 | drawContent() 49 | } 50 | }, 51 | color = color, 52 | style = scaledTextStyle, 53 | softWrap = false, 54 | onTextLayout = { textLayoutResult -> 55 | if (textLayoutResult.didOverflowWidth) { 56 | val newFontSize = scaledTextStyle.fontSize * 0.9 57 | if (newFontSize > minFontSize) { 58 | scaledTextStyle = scaledTextStyle.copy(fontSize = newFontSize) 59 | } else { 60 | readyToDraw = true 61 | } 62 | } else { 63 | readyToDraw = true 64 | } 65 | }, 66 | fontSize = fontSize, 67 | fontStyle = fontStyle, 68 | fontWeight = fontWeight, 69 | fontFamily = fontFamily, 70 | letterSpacing = letterSpacing, 71 | textDecoration = textDecoration, 72 | textAlign = textAlign, 73 | lineHeight = lineHeight, 74 | overflow = overflow, 75 | maxLines = maxLines 76 | ) 77 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/compose/Previews.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.compose 2 | 3 | import androidx.compose.ui.tooling.preview.PreviewLightDark 4 | import androidx.compose.ui.tooling.preview.PreviewScreenSizes 5 | 6 | @PreviewLightDark 7 | annotation class PreviewDefault 8 | 9 | @PreviewLightDark 10 | @PreviewScreenSizes 11 | annotation class PreviewAll 12 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/compose/TextHeader.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.compose 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.HorizontalDivider 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.Surface 9 | import androidx.compose.material3.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.graphics.Color 13 | import androidx.compose.ui.text.TextStyle 14 | import androidx.compose.ui.unit.dp 15 | 16 | @Composable 17 | fun TextHeader( 18 | text: String, 19 | modifier: Modifier = Modifier, 20 | color: Color = Color.Unspecified, 21 | style: TextStyle = MaterialTheme.typography.bodyMedium, 22 | ) { 23 | Surface(modifier) { 24 | Column { 25 | Text( 26 | text = text, 27 | modifier = Modifier 28 | .fillMaxWidth() 29 | .padding(bottom = 8.dp), 30 | color = color, 31 | style = style 32 | ) 33 | HorizontalDivider() 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/compose/appbar/AppBarTitle.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.compose.appbar 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.text.style.TextOverflow 9 | import org.jdc.template.ui.compose.AutoSizeText 10 | 11 | @Composable 12 | fun AppBarTitle( 13 | title: String, 14 | subtitle: String? = null, 15 | autoSizeTitle: Boolean = false, 16 | titleOverflow: TextOverflow = TextOverflow.Clip, 17 | subitleOverflow: TextOverflow = TextOverflow.Clip 18 | ) { 19 | Column(verticalArrangement = Arrangement.Center) { 20 | // title 21 | if (!autoSizeTitle) { 22 | Text( 23 | text = title, 24 | style = MaterialTheme.typography.titleLarge, 25 | maxLines = 1, 26 | overflow = titleOverflow 27 | ) 28 | } else { 29 | AutoSizeText( 30 | text = title, 31 | style = MaterialTheme.typography.titleLarge, 32 | maxLines = 1, 33 | // overflow = TextOverflow.Clip // NOTE: AutoSizeText can ONLY use TextOverflow.Clip 34 | ) 35 | } 36 | 37 | // subtitle 38 | if (!subtitle.isNullOrBlank()) { 39 | Text( 40 | text = subtitle, 41 | style = MaterialTheme.typography.bodySmall, 42 | maxLines = 1, 43 | overflow = subitleOverflow 44 | ) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/compose/dialog/DialogDefaults.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.compose.dialog 2 | 3 | import androidx.compose.foundation.layout.PaddingValues 4 | import androidx.compose.ui.unit.dp 5 | 6 | object DialogDefaults { 7 | val DialogPadding = PaddingValues(start = 24.dp, top = 24.dp, end = 24.dp, bottom = 18.dp) // from AlertDialog.DialogPadding 8 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/compose/form/ClickableTextField.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.compose.form 2 | 3 | import androidx.compose.foundation.interaction.MutableInteractionSource 4 | import androidx.compose.foundation.interaction.collectIsPressedAsState 5 | import androidx.compose.material3.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.input.key.onPreviewKeyEvent 10 | import androidx.compose.ui.platform.LocalFocusManager 11 | import org.jdc.template.ui.compose.DayNightTextField 12 | import org.jdc.template.ui.compose.util.formKeyEventHandler 13 | 14 | @Composable 15 | fun ClickableTextField( 16 | label: String, 17 | text: String, 18 | onClick: () -> Unit, 19 | modifier: Modifier = Modifier, 20 | supportingText: String? = null, 21 | isError: Boolean = false 22 | ) { 23 | val source = remember { MutableInteractionSource() } 24 | val focusManager = LocalFocusManager.current 25 | 26 | DayNightTextField( 27 | value = text, 28 | onValueChange = { }, 29 | readOnly = true, 30 | label = { Text(label) }, 31 | interactionSource = source, 32 | supportingText = supportingText?.let{ { Text(it) } }, 33 | isError = isError, 34 | modifier = modifier 35 | .onPreviewKeyEvent { formKeyEventHandler(it, focusManager, onEnter = onClick) } 36 | ) 37 | 38 | if (source.collectIsPressedAsState().value) { 39 | onClick() 40 | } 41 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/compose/form/DateClickableTextField.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.compose.form 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.platform.LocalContext 7 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 8 | import kotlinx.coroutines.flow.MutableStateFlow 9 | import kotlinx.coroutines.flow.StateFlow 10 | import kotlinx.datetime.LocalDate 11 | import org.jdc.template.ui.compose.util.DateUiUtil 12 | 13 | @Composable 14 | fun DateClickableTextField( 15 | label: String, 16 | localDateFlow: StateFlow, 17 | onClick: () -> Unit, 18 | modifier: Modifier = Modifier, 19 | errorTextFlow: StateFlow = MutableStateFlow(null) 20 | ) { 21 | val date by localDateFlow.collectAsStateWithLifecycle() 22 | val text = DateUiUtil.getLocalDateText(LocalContext.current, date) 23 | val errorText by errorTextFlow.collectAsStateWithLifecycle() 24 | 25 | ClickableTextField( 26 | label = label, 27 | text = text, 28 | supportingText = errorText, 29 | isError = !errorText.isNullOrBlank(), 30 | onClick = onClick, 31 | modifier = modifier 32 | ) 33 | } 34 | 35 | @Composable 36 | fun DateClickableTextField( 37 | label: String, 38 | date: LocalDate, 39 | onClick: () -> Unit, 40 | modifier: Modifier = Modifier, 41 | errorText: String? = null 42 | ) { 43 | val text = DateUiUtil.getLocalDateText(LocalContext.current, date) 44 | 45 | ClickableTextField( 46 | label = label, 47 | text = text, 48 | supportingText = errorText, 49 | isError = !errorText.isNullOrBlank(), 50 | onClick = onClick, 51 | modifier = modifier 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/compose/form/FlowTextField.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.compose.form 2 | 3 | import androidx.compose.material3.Text 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.input.key.onPreviewKeyEvent 8 | import androidx.compose.ui.platform.LocalFocusManager 9 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 10 | import kotlinx.coroutines.flow.MutableStateFlow 11 | import kotlinx.coroutines.flow.StateFlow 12 | import org.jdc.template.ui.compose.DayNightPasswordTextField 13 | import org.jdc.template.ui.compose.DayNightTextField 14 | import org.jdc.template.ui.compose.util.formKeyEventHandler 15 | 16 | @Composable 17 | fun FlowTextField( 18 | label: String, 19 | textFlow: StateFlow, 20 | onChange: (String) -> Unit, 21 | modifier: Modifier = Modifier, 22 | errorTextFlow: StateFlow = MutableStateFlow(null), 23 | ) { 24 | val text by textFlow.collectAsStateWithLifecycle() 25 | val errorText by errorTextFlow.collectAsStateWithLifecycle() 26 | val focusManager = LocalFocusManager.current 27 | 28 | DayNightTextField( 29 | value = text, 30 | onValueChange = { onChange(it) }, 31 | label = { Text(label) }, 32 | singleLine = true, 33 | isError = !errorText.isNullOrBlank(), 34 | supportingText = errorText?.let{ { Text(it) } }, 35 | modifier = modifier 36 | .onPreviewKeyEvent { formKeyEventHandler(it, focusManager) } 37 | ) 38 | } 39 | 40 | @Composable 41 | fun PasswordFlowTextField( 42 | label: String, 43 | textFlow: StateFlow, 44 | onChange: (String) -> Unit, 45 | modifier: Modifier = Modifier, 46 | errorTextFlow: StateFlow = MutableStateFlow(null), 47 | ) { 48 | val text by textFlow.collectAsStateWithLifecycle() 49 | val errorText by errorTextFlow.collectAsStateWithLifecycle() 50 | val focusManager = LocalFocusManager.current 51 | 52 | DayNightPasswordTextField( 53 | value = text, 54 | onValueChange = { onChange(it) }, 55 | label = { Text(label) }, 56 | isError = !errorText.isNullOrBlank(), 57 | supportingText = errorText?.let{ { Text(it) } }, 58 | modifier = modifier 59 | .onPreviewKeyEvent { formKeyEventHandler(it, focusManager) } 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/compose/form/LabelEnumExposedDropdownMenuBox.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.compose.form 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.material3.DropdownMenuItem 6 | import androidx.compose.material3.ExposedDropdownMenuBox 7 | import androidx.compose.material3.ExposedDropdownMenuDefaults 8 | import androidx.compose.material3.Text 9 | import androidx.compose.material3.TextButton 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.getValue 12 | import androidx.compose.runtime.mutableStateOf 13 | import androidx.compose.runtime.remember 14 | import androidx.compose.runtime.setValue 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | 18 | @Composable 19 | fun > LabelEnumExposedDropdownMenuBox( 20 | options: List, 21 | selectedOption: T, 22 | onOptionSelected: (T) -> Unit, 23 | optionToText: @Composable (T) -> String, 24 | modifier: Modifier = Modifier 25 | ) { 26 | var expanded by remember { mutableStateOf(false) } 27 | var currentSelectedOption by remember { mutableStateOf(selectedOption) } 28 | 29 | ExposedDropdownMenuBox( 30 | expanded = expanded, 31 | onExpandedChange = { }, 32 | modifier = modifier 33 | ) { 34 | TextButton( 35 | onClick = { expanded = !expanded }, 36 | ) { 37 | Row { 38 | Text( 39 | optionToText(currentSelectedOption), 40 | Modifier.align(Alignment.CenterVertically) 41 | ) 42 | Box( 43 | Modifier.align(Alignment.CenterVertically) 44 | ) { 45 | ExposedDropdownMenuDefaults.TrailingIcon(expanded) 46 | } 47 | } 48 | } 49 | ExposedDropdownMenu( 50 | expanded = expanded, 51 | onDismissRequest = { expanded = false }, 52 | ) { 53 | options.forEach { option -> 54 | DropdownMenuItem( 55 | text = { Text(optionToText(option)) }, 56 | onClick = { 57 | currentSelectedOption = option 58 | onOptionSelected(option) 59 | expanded = false 60 | } 61 | ) 62 | } 63 | } 64 | } 65 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/compose/form/SwitchField.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.compose.form 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.material3.Switch 5 | import androidx.compose.material3.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.getValue 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 11 | import kotlinx.coroutines.flow.StateFlow 12 | 13 | @Composable 14 | fun SwitchField( 15 | label: String, 16 | checkedFlow: StateFlow, 17 | onCheckedChange: (Boolean) -> Unit, 18 | modifier: Modifier = Modifier, 19 | ) { 20 | val checked by checkedFlow.collectAsStateWithLifecycle() 21 | 22 | Box(modifier = modifier) { 23 | Text( 24 | text = label, 25 | modifier = Modifier.align(Alignment.CenterStart) 26 | ) 27 | 28 | Switch( 29 | checked = checked, 30 | onCheckedChange = { onCheckedChange(it) }, 31 | modifier = Modifier.align(Alignment.CenterEnd) 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/compose/form/TextWithTitle.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.compose.form 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.text.TextStyle 11 | import androidx.compose.ui.text.font.FontWeight 12 | import androidx.compose.ui.unit.dp 13 | import org.jdc.template.ui.compose.PreviewDefault 14 | 15 | @Composable 16 | fun TextWithTitle( 17 | text: String?, 18 | label: String? = null, 19 | labelTextStyle: TextStyle = MaterialTheme.typography.labelSmall, 20 | labelTextFontWeight: FontWeight = FontWeight.Bold, 21 | textStyle: TextStyle = MaterialTheme.typography.bodyMedium 22 | ) { 23 | if (text.isNullOrBlank()) { 24 | return 25 | } 26 | Column { 27 | if (label != null) { 28 | Text( 29 | text = label, 30 | style = labelTextStyle, 31 | fontWeight = labelTextFontWeight, 32 | modifier = Modifier 33 | .padding(top = 8.dp) 34 | ) 35 | } 36 | Text( 37 | text = text, 38 | style = textStyle, 39 | modifier = Modifier 40 | .padding(top = if (label != null) 4.dp else 8.dp, bottom = 8.dp) 41 | ) 42 | } 43 | } 44 | 45 | @PreviewDefault 46 | @Composable 47 | private fun Preview() { 48 | MaterialTheme { 49 | Column( 50 | modifier = Modifier.fillMaxWidth() 51 | ) { 52 | TextWithTitle(text = "John", label = "First Name") 53 | TextWithTitle(text = "Adams", label = "Last Name") 54 | TextWithTitle(text = "123 Main Street") 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/compose/form/TimeClickableTextField.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.compose.form 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.platform.LocalContext 7 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 8 | import kotlinx.coroutines.flow.MutableStateFlow 9 | import kotlinx.coroutines.flow.StateFlow 10 | import kotlinx.datetime.LocalTime 11 | import org.jdc.template.ui.compose.util.DateUiUtil 12 | 13 | @Composable 14 | fun TimeClickableTextField( 15 | label: String, 16 | localTimeFlow: StateFlow, 17 | onClick: () -> Unit, 18 | modifier: Modifier = Modifier, 19 | errorTextFlow: StateFlow = MutableStateFlow(null) 20 | ) { 21 | val time by localTimeFlow.collectAsStateWithLifecycle() 22 | val text = DateUiUtil.getLocalTimeText(LocalContext.current, time) 23 | val errorText by errorTextFlow.collectAsStateWithLifecycle() 24 | 25 | ClickableTextField( 26 | label = label, 27 | text = text, 28 | supportingText = errorText, 29 | isError = !errorText.isNullOrBlank(), 30 | onClick = onClick, 31 | modifier = modifier 32 | ) 33 | } 34 | 35 | @Composable 36 | fun TimeClickableTextField( 37 | label: String, 38 | localTime: LocalTime, 39 | onClick: () -> Unit, 40 | modifier: Modifier = Modifier, 41 | errorText: String? = null 42 | ) { 43 | val text = DateUiUtil.getLocalTimeText(LocalContext.current, localTime) 44 | 45 | ClickableTextField( 46 | label = label, 47 | text = text, 48 | supportingText = errorText, 49 | isError = !errorText.isNullOrBlank(), 50 | onClick = onClick, 51 | modifier = modifier 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/compose/list/ListItemTextHeader.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.compose.list 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.PaddingValues 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material3.ListItem 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.Surface 11 | import androidx.compose.material3.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.graphics.Color 15 | import androidx.compose.ui.text.TextStyle 16 | import androidx.compose.ui.tooling.preview.Preview 17 | import androidx.compose.ui.unit.dp 18 | 19 | @Composable 20 | fun ListItemTextHeader( 21 | text: String, 22 | modifier: Modifier = Modifier, 23 | color: Color = MaterialTheme.colorScheme.primary, 24 | style: TextStyle = MaterialTheme.typography.titleSmall, 25 | textPadding: PaddingValues = PaddingValues(start = 16.dp, top = 16.dp), 26 | ) { 27 | Surface(modifier) { 28 | Column { 29 | Text( 30 | text = text, 31 | modifier = Modifier 32 | .fillMaxWidth() 33 | .padding(textPadding), 34 | color = color, 35 | style = style 36 | ) 37 | } 38 | } 39 | } 40 | 41 | @Preview 42 | @Composable 43 | private fun ListItemTextHeaderPreview() { 44 | MaterialTheme { 45 | Surface { 46 | Column( 47 | verticalArrangement = Arrangement.spacedBy(10.dp), 48 | modifier = Modifier.padding(start = 16.dp) 49 | ) { 50 | ListItemTextHeader(text = "Food") 51 | ListItem(headlineContent = { Text("Pizza") }) 52 | ListItem(headlineContent = { Text("Taco") }) 53 | ListItem(headlineContent = { Text("Hot Dog") }) 54 | ListItem(headlineContent = { Text("Banana") }) 55 | 56 | ListItemTextHeader(text = "Colors") 57 | ListItem(headlineContent = { Text("Red") }) 58 | ListItem(headlineContent = { Text("Green") }) 59 | ListItem(headlineContent = { Text("Blue") }) 60 | ListItem(headlineContent = { Text("Yellow") }) 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/compose/util/DateUiUtil.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.compose.util 2 | 3 | import android.content.Context 4 | import android.text.format.DateUtils 5 | import kotlinx.datetime.Clock 6 | import kotlinx.datetime.LocalDate 7 | import kotlinx.datetime.LocalDateTime 8 | import kotlinx.datetime.LocalTime 9 | import kotlinx.datetime.TimeZone 10 | import kotlinx.datetime.atStartOfDayIn 11 | import kotlinx.datetime.toInstant 12 | import kotlinx.datetime.toLocalDateTime 13 | 14 | object DateUiUtil { 15 | /** 16 | * Android DateUtils does not support formatting a LocalDate (it needs epoch millis). 17 | * 18 | * This function takes a LocalDate and Clock.System.now(), combines them and then tells DateUtils to ONLY show the resulting date and year 19 | */ 20 | fun getLocalDateText( 21 | context: Context, 22 | localDate: LocalDate?, 23 | dateUtilsFlags: Int = DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR 24 | ): String { 25 | localDate ?: return "" 26 | 27 | localDate.atStartOfDayIn(TimeZone.currentSystemDefault()) 28 | 29 | val millis = localDate.atStartOfDayIn(TimeZone.currentSystemDefault()).toEpochMilliseconds() 30 | return DateUtils.formatDateTime(context, millis, dateUtilsFlags) 31 | } 32 | 33 | /** 34 | * Android DateUtils does not support formatting a LocalTime (it needs epoch millis). 35 | * 36 | * This function takes a LocalTime and Clock.System.now(), combines them and then tells DateUtils to ONLY show the resulting time 37 | */ 38 | fun getLocalTimeText( 39 | context: Context, 40 | localTime: LocalTime?, 41 | dateUtilsFlags: Int = DateUtils.FORMAT_SHOW_TIME 42 | ): String { 43 | localTime ?: return "" 44 | val millis = LocalDateTime(Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date, localTime).toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds() 45 | return DateUtils.formatDateTime(context, millis, dateUtilsFlags) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/compose/util/FocusUtil.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.compose.util 2 | 3 | import androidx.compose.ui.focus.FocusDirection 4 | import androidx.compose.ui.focus.FocusManager 5 | import androidx.compose.ui.input.key.Key 6 | import androidx.compose.ui.input.key.KeyEvent 7 | import androidx.compose.ui.input.key.KeyEventType 8 | import androidx.compose.ui.input.key.isShiftPressed 9 | import androidx.compose.ui.input.key.key 10 | import androidx.compose.ui.input.key.type 11 | 12 | fun formKeyEventHandler( 13 | keyEvent: KeyEvent, 14 | focusManager: FocusManager, 15 | inputFilterKeys: List = listOf(Key.Tab, Key.Enter), 16 | onEnter: (() -> Unit)? = null 17 | ): Boolean { 18 | // prevent character in from being placed in the TextField (default Tab / Enter) 19 | if (keyEvent.type == KeyEventType.KeyDown && inputFilterKeys.contains(keyEvent.key)) { 20 | return true 21 | } 22 | 23 | // prevent double calls (only handle UP action) 24 | if (keyEvent.type != KeyEventType.KeyUp) { 25 | return false 26 | } 27 | 28 | @Suppress("OptionalWhenBraces") // does not read well here when removed 29 | return when { 30 | keyEvent.key == Key.Tab -> { 31 | focusManager.moveFocus(FocusDirection.Next) 32 | true 33 | } 34 | keyEvent.key == Key.DirectionDown -> { 35 | focusManager.moveFocus(FocusDirection.Down) 36 | true 37 | } 38 | keyEvent.key == Key.Tab && keyEvent.isShiftPressed -> { 39 | focusManager.moveFocus(FocusDirection.Previous) 40 | true 41 | } 42 | keyEvent.key == Key.DirectionUp -> { 43 | focusManager.moveFocus(FocusDirection.Up) 44 | true 45 | } 46 | keyEvent.key == Key.Enter -> { 47 | if (onEnter != null) { 48 | onEnter() 49 | true 50 | } else { 51 | false 52 | } 53 | } 54 | else -> false 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/compose/util/WindowSize.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.compose.util 2 | 3 | import android.app.Activity 4 | import android.content.res.Configuration 5 | import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass 6 | import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass 7 | import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.remember 10 | import androidx.compose.ui.platform.LocalConfiguration 11 | 12 | /** 13 | * Opinionated set of viewport breakpoints 14 | * - Compact: Phones 15 | * - Medium: Foldable, Tablet 16 | * - Expanded: Large Tablet, Desktop 17 | * 18 | * More info: 19 | * - https://m3.material.io/foundations/adaptive-design/large-screens/overview 20 | * - https://material.io/archive/guidelines/layout/responsive-ui.html 21 | */ 22 | enum class WindowSize { 23 | COMPACT, // Phone 24 | MEDIUM, // Foldable, Tablet 25 | EXPANDED, // Extra large tablet, Desktop 26 | } 27 | 28 | /** 29 | * Remembers the [WindowSize] class for the window corresponding to the current window metrics. 30 | */ 31 | @Composable 32 | fun Activity.rememberWindowSize(): WindowSize { 33 | val configuration = LocalConfiguration.current 34 | val windowSize = calculateWindowSizeClass(this) 35 | return remember(windowSize, configuration) { 36 | if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { 37 | when(windowSize.heightSizeClass) { 38 | WindowHeightSizeClass.Compact -> WindowSize.COMPACT 39 | WindowHeightSizeClass.Medium -> WindowSize.MEDIUM 40 | WindowHeightSizeClass.Expanded -> WindowSize.EXPANDED 41 | else -> WindowSize.COMPACT 42 | } 43 | } else { 44 | when(windowSize.widthSizeClass) { 45 | WindowWidthSizeClass.Compact -> WindowSize.COMPACT 46 | WindowWidthSizeClass.Medium -> WindowSize.MEDIUM 47 | WindowWidthSizeClass.Expanded -> WindowSize.EXPANDED 48 | else -> WindowSize.COMPACT 49 | } 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/navigation/NavControllerExt.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.navigation 2 | 3 | import android.content.Context 4 | import androidx.navigation.NavController 5 | import org.jdc.template.util.ext.requireActivity 6 | 7 | /** 8 | * NavController extension to popBackStack() AND finishAffinity() IF pop did NOT succeed 9 | * 10 | * This is useful for support of Navigation Back button when using deeplinking 11 | * 12 | * @param context Ui Context (do not use Application Context) 13 | */ 14 | fun NavController.popBackStackOrFinishActivity(context: Context) { 15 | if (!popBackStack()) { 16 | context.requireActivity().finishAffinity() 17 | } 18 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/navigation/NavTypeMaps.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.navigation 2 | 3 | import android.os.Bundle 4 | import androidx.navigation.NavType 5 | import org.jdc.template.shared.model.domain.inline.ChatThreadId 6 | import org.jdc.template.shared.model.domain.inline.IndividualId 7 | import kotlin.reflect.KType 8 | import kotlin.reflect.typeOf 9 | 10 | /** 11 | * Mappings for type safe navigation 12 | * 13 | * Notes: 14 | * 1. parseValue() should know how to parse what is returned from serializeAsValue() (format doesn't really matter... doesn't need to be json) 15 | * 2. If value for NavType is complex (class with multiple variables) use Json.encodeToString(value) and Json.decodeFromString(value) 16 | */ 17 | object NavTypeMaps { 18 | val IndividualIdNavType = object : NavType( 19 | isNullableAllowed = false 20 | ) { 21 | override fun put(bundle: Bundle, key: String, value: IndividualId) = bundle.putString(key, serializeAsValue(value)) 22 | override fun get(bundle: Bundle, key: String): IndividualId? = bundle.getString(key)?.let { parseValue(it) } 23 | override fun serializeAsValue(value: IndividualId): String = value.value 24 | override fun parseValue(value: String) = IndividualId(value) 25 | } 26 | 27 | val IndividualIdNullableNavType = object : NavType( 28 | isNullableAllowed = true 29 | ) { 30 | override fun put(bundle: Bundle, key: String, value: IndividualId?) { 31 | value?.let { bundle.putString(key, it.value) } 32 | } 33 | override fun get(bundle: Bundle, key: String): IndividualId? = bundle.getString(key)?.let { IndividualId(it) } 34 | 35 | override fun serializeAsValue(value: IndividualId?): String = value?.value.orEmpty() 36 | 37 | override fun parseValue(value: String): IndividualId? { 38 | if (value.isBlank()) return null 39 | return IndividualId(value) 40 | } 41 | } 42 | 43 | val ChatThreadIdNavType = object : NavType( 44 | isNullableAllowed = false 45 | ) { 46 | override fun put(bundle: Bundle, key: String, value: ChatThreadId) = bundle.putString(key, serializeAsValue(value)) 47 | override fun get(bundle: Bundle, key: String): ChatThreadId? = bundle.getString(key)?.let { parseValue(it) } 48 | override fun serializeAsValue(value: ChatThreadId): String = value.value 49 | override fun parseValue(value: String) = ChatThreadId(value) 50 | } 51 | 52 | val typeMap: Map> = mapOf( 53 | typeOf() to IndividualIdNavType, 54 | typeOf() to IndividualIdNullableNavType, 55 | typeOf() to ChatThreadIdNavType, 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/navigation/NavUriLogger.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.navigation 2 | 3 | import android.content.Intent 4 | import android.os.Build 5 | import android.os.Bundle 6 | import android.os.Parcelable 7 | import androidx.navigation.NavController 8 | import androidx.navigation.NavDestination 9 | import co.touchlab.kermit.Logger 10 | import java.net.URLEncoder 11 | 12 | /** 13 | * Used to debug navigating Route and DeepLink 14 | * 15 | * Usage: navHostFragment.navController.addOnDestinationChangedListener(NavUriLogger()) 16 | */ 17 | @Suppress("MemberVisibilityCanBePrivate") 18 | class NavUriLogger(val prefix: String = "") : NavController.OnDestinationChangedListener { 19 | override fun onDestinationChanged(controller: NavController, destination: NavDestination, arguments: Bundle?) { 20 | arguments?.parcelable(NavController.KEY_DEEP_LINK_INTENT)?.data?.let { deepLinkUri -> 21 | val uri = deepLinkUri.toString() 22 | 23 | // Find all path and query parameter placeholders 24 | val matches = Regex("\\{([^\\}]*)\\}").findAll(uri) 25 | // Create a list of the placeholder names 26 | val placeholderNames = matches.map { it.groupValues[1] }.toList() 27 | // Map placeholder names to values. Note: getString() wouldn't work when value was an enum type 28 | val nameValuePairs = placeholderNames.map { it to arguments[it] } 29 | 30 | // Replace placeholders with values 31 | var uriWithValues = uri 32 | nameValuePairs.forEach { (name, value) -> 33 | val oldValue = "{$name}" 34 | val newValue = URLEncoder.encode(value.toString(), "UTF-8") 35 | uriWithValues = uriWithValues.replace(oldValue, newValue) 36 | } 37 | 38 | if (uriWithValues.startsWith(ROUTE_PREFIX)) { 39 | val route = uriWithValues.removePrefix(ROUTE_PREFIX) 40 | Logger.d { "$prefix Route Navigate -> route: [$route] definition: [${destination.route}]" } 41 | } else { 42 | Logger.d { "$prefix DeepLink Navigate -> uri: [$uriWithValues] " } 43 | } 44 | } 45 | } 46 | 47 | companion object { 48 | const val ROUTE_PREFIX = "android-app://androidx.navigation/" 49 | } 50 | } 51 | 52 | private inline fun Bundle.parcelable(key: String): T? = when { 53 | Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getParcelable(key, T::class.java) 54 | else -> @Suppress("DEPRECATION") getParcelable(key) as? T 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/navigation/NavigationRoute.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.navigation 2 | 3 | interface NavigationRoute -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/navigation/RouteUtil.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.navigation 2 | 3 | @Suppress("MemberVisibilityCanBePrivate") 4 | object RouteUtil { 5 | /** 6 | * Used to define a route 7 | * 8 | * Usage: navDeepLink { uriPattern = "individual/${defineArg("individualId")}" } 9 | */ 10 | fun defineArg(argName: String): String { 11 | return "{$argName}" 12 | } 13 | 14 | /** 15 | * Used to define a route 16 | * 17 | * Usage: navDeepLink { uriPattern = "individual?${defineOptionalArgs("individualId","enabled")}" } 18 | */ 19 | fun defineOptionalArgs(vararg argNames: String): String { 20 | return argNames.joinToString("&") { argName -> "$argName={$argName}" } 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/navigation/WorkManagerStatusRoute.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.navigation 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | object WorkManagerStatusRoute: NavigationRoute -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/notification/NotificationChannels.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.notification 2 | 3 | import android.app.NotificationChannel 4 | import android.app.NotificationManager 5 | import android.content.Context 6 | import android.os.Build 7 | import androidx.annotation.StringRes 8 | import androidx.core.app.NotificationManagerCompat 9 | import androidx.core.content.getSystemService 10 | import org.jdc.template.R 11 | 12 | enum class NotificationChannels constructor( 13 | val channelId: String, 14 | @StringRes val textResId: Int, 15 | val importance: Int 16 | ) { 17 | GENERAL("general_channel", R.string.notification_channel_general, NotificationManagerCompat.IMPORTANCE_DEFAULT); 18 | 19 | companion object { 20 | fun registerAllChannels(context: Context) { 21 | if (Build.VERSION.SDK_INT >= 26) { 22 | val notificationManager = context.getSystemService() 23 | 24 | values().forEach { 25 | val channel = NotificationChannel(it.channelId, context.getString(it.textResId), it.importance) 26 | notificationManager?.createNotificationChannel(channel) 27 | } 28 | } 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/notification/NotificationIds.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.notification 2 | 3 | enum class NotificationIds constructor(val notificationId: Int) { 4 | APP_UPDATE_AVAILABLE(10), 5 | } 6 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ui/strings/StringResourcesExt.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ui.strings 2 | 3 | import android.app.Application 4 | import org.jdc.template.R 5 | import org.jdc.template.shared.model.domain.type.DisplayThemeType 6 | import org.jdc.template.shared.model.domain.type.IndividualType 7 | 8 | fun DisplayThemeType.toString(application: Application): String { 9 | val stringResId = when (this) { 10 | DisplayThemeType.LIGHT -> R.string.light 11 | DisplayThemeType.DARK -> R.string.dark 12 | DisplayThemeType.SYSTEM_DEFAULT -> R.string.system_default 13 | } 14 | 15 | return application.getString(stringResId) 16 | } 17 | 18 | fun IndividualType.getStringResId(): Int { 19 | return when (this) { 20 | IndividualType.HEAD -> R.string.head 21 | IndividualType.SPOUSE -> R.string.spouse 22 | IndividualType.CHILD -> R.string.child 23 | IndividualType.UNKNOWN -> R.string.unkown 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/util/ext/ContextExt.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.util.ext 2 | 3 | import android.content.Context 4 | import android.content.ContextWrapper 5 | import androidx.activity.ComponentActivity 6 | 7 | fun Context.requireActivity(): ComponentActivity = when (this) { 8 | is ComponentActivity -> this 9 | is ContextWrapper -> baseContext.requireActivity() 10 | else -> error("No Activity Found") 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/util/time/TimeFormatUtil.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.util.time 2 | 3 | import android.content.Context 4 | import android.text.format.DateUtils 5 | import kotlinx.datetime.Clock 6 | import kotlinx.datetime.Instant 7 | import kotlinx.datetime.LocalDate 8 | import kotlinx.datetime.TimeZone 9 | import kotlinx.datetime.toLocalDateTime 10 | import kotlinx.datetime.todayIn 11 | 12 | 13 | object TimeFormatUtil { 14 | fun millisToHoursMinutesSeconds(millis: Long): String { 15 | val totalSeconds = millis / 1000 16 | val hours = totalSeconds / 3600 17 | val minutes = (totalSeconds % 3600) / 60 18 | val seconds = totalSeconds % 60 19 | 20 | val builder = StringBuilder() 21 | if (hours > 0) { 22 | builder.append("${hours}h ") 23 | } 24 | if (minutes > 0) { 25 | builder.append("${minutes}m ") 26 | } 27 | if (seconds > 0) { 28 | builder.append("${seconds}s ") 29 | } 30 | 31 | return builder.toString() 32 | } 33 | 34 | fun secondsToHoursMinutesSeconds(totalSeconds: Long): String { 35 | val hours = totalSeconds / 3600 36 | val minutes = (totalSeconds % 3600) / 60 37 | val seconds = totalSeconds % 60 38 | 39 | val builder = StringBuilder() 40 | if (hours > 0) { 41 | builder.append("${hours}h ") 42 | } 43 | if (minutes > 0) { 44 | builder.append("${minutes}m ") 45 | } 46 | if (seconds > 0) { 47 | builder.append("${seconds}s ") 48 | } 49 | 50 | return builder.toString() 51 | } 52 | 53 | fun formatMessageTime(context: Context, messageInstant: Instant): String { 54 | val nowDate: LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault()) 55 | val messageDate: LocalDate = messageInstant.toLocalDateTime(TimeZone.currentSystemDefault()).date 56 | val isToday = nowDate == messageDate 57 | 58 | return if (isToday) { 59 | DateUtils.formatDateTime(context, messageInstant.toEpochMilliseconds(), DateUtils.FORMAT_ABBREV_ALL or DateUtils.FORMAT_SHOW_TIME) 60 | } else { 61 | DateUtils.formatDateTime(context, messageInstant.toEpochMilliseconds(), DateUtils.FORMAT_ABBREV_ALL or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE) 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/about/AboutRoute.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.about 2 | 3 | import kotlinx.serialization.Serializable 4 | import org.jdc.template.ui.navigation.NavigationRoute 5 | 6 | @Serializable 7 | object AboutRoute: NavigationRoute -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/about/AboutUiState.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.about 2 | 3 | import kotlinx.coroutines.flow.MutableStateFlow 4 | import kotlinx.coroutines.flow.StateFlow 5 | 6 | data class AboutUiState( 7 | // Data 8 | val resetServiceEnabledFlow: StateFlow = MutableStateFlow(false), 9 | 10 | // Events 11 | val testQueryWebServiceCall: () -> Unit = {}, 12 | val testFullUrlQueryWebServiceCall: () -> Unit = {}, 13 | val testCachedUrlQueryWebServiceCall: () -> Unit = {}, 14 | val testSaveQueryWebServiceCall: () -> Unit = {}, 15 | val workManagerSimpleTest: () -> Unit = {}, 16 | val workManagerSyncTest: () -> Unit = {}, 17 | val testTableChange: () -> Unit = {}, 18 | val licensesClick: () -> Unit = {}, 19 | val createSampleData: () -> Unit = {}, 20 | val createLargeSampleData: () -> Unit = {}, 21 | val m3TypographyClick: () -> Unit = {}, 22 | val onChatClick: () -> Unit = {}, 23 | ) 24 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/about/typography/TypographyRoute.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.about.typography 2 | 3 | import kotlinx.serialization.Serializable 4 | import org.jdc.template.ui.navigation.NavigationRoute 5 | 6 | @Serializable 7 | object TypographyRoute: NavigationRoute -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/acknowledgement/AcknowledgementUiState.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.acknowledgement 2 | 3 | import kotlinx.coroutines.flow.MutableStateFlow 4 | import kotlinx.coroutines.flow.StateFlow 5 | 6 | data class AcknowledgementUiState( 7 | val acknowledgementHtmlFlow: StateFlow = MutableStateFlow(null) 8 | ) 9 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/acknowledgement/AcknowledgementViewModel.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.acknowledgement 2 | 3 | import android.app.Application 4 | import androidx.lifecycle.ViewModel 5 | import co.touchlab.kermit.Logger 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import org.jdc.template.ui.navigation.ViewModelNavigation 9 | import org.jdc.template.ui.navigation.ViewModelNavigationImpl 10 | import javax.inject.Inject 11 | 12 | @HiltViewModel 13 | class AcknowledgementViewModel 14 | @Inject constructor( 15 | private val application: Application 16 | ) : ViewModel(), ViewModelNavigation by ViewModelNavigationImpl() { 17 | 18 | private val acknowledgementHtmlStateFlow = MutableStateFlow(null) 19 | 20 | val uiState = AcknowledgementUiState( 21 | acknowledgementHtmlFlow = acknowledgementHtmlStateFlow 22 | ) 23 | 24 | init { 25 | loadLicenses() 26 | } 27 | 28 | private fun loadLicenses() { 29 | try { 30 | val htmlFilename = "licenses.html" 31 | 32 | // read the file 33 | acknowledgementHtmlStateFlow.value = application.assets.open(htmlFilename).bufferedReader().use { it.readText() } 34 | } catch (expected: Exception) { 35 | Logger.e(expected) { "Failed to render Acknowledgments html" } 36 | acknowledgementHtmlStateFlow.value = "Failed to load licenses:\n [${expected.message}]" 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/acknowledgement/AcknowledgmentsRoute.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.acknowledgement 2 | 3 | import kotlinx.serialization.Serializable 4 | import org.jdc.template.ui.navigation.NavigationRoute 5 | 6 | @Serializable 7 | object AcknowledgmentsRoute: NavigationRoute -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/chat/ChatRoute.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.chat 2 | 3 | import kotlinx.serialization.Serializable 4 | import org.jdc.template.shared.model.domain.inline.ChatThreadId 5 | import org.jdc.template.shared.model.domain.inline.IndividualId 6 | import org.jdc.template.ui.navigation.NavTypeMaps 7 | import org.jdc.template.ui.navigation.NavigationRoute 8 | import kotlin.reflect.typeOf 9 | 10 | @Serializable 11 | data class ChatRoute( 12 | val chatThreadId: ChatThreadId, 13 | val individualId: IndividualId, 14 | ): NavigationRoute 15 | 16 | fun ChatRoute.Companion.typeMap() = mapOf( 17 | typeOf() to NavTypeMaps.ChatThreadIdNavType, 18 | typeOf() to NavTypeMaps.IndividualIdNavType, 19 | ) 20 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/chat/ChatUiState.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.chat 2 | 3 | import androidx.paging.PagingData 4 | import kotlinx.coroutines.flow.MutableStateFlow 5 | import kotlinx.coroutines.flow.StateFlow 6 | import org.jdc.template.shared.model.domain.ChatMessage 7 | import org.jdc.template.shared.model.domain.inline.ChatMessageId 8 | import org.jdc.template.shared.model.domain.inline.IndividualId 9 | import org.jdc.template.ui.compose.dialog.DialogUiState 10 | 11 | data class ChatUiState( 12 | val dialogUiStateFlow: StateFlow?> = MutableStateFlow(null), 13 | 14 | // Data 15 | val threadNameFlow: StateFlow, 16 | val fromPerspectiveUserId: StateFlow, 17 | val allMessagesPagingFlow: StateFlow>, 18 | 19 | // events 20 | val onSendClick: (String) -> Unit, 21 | val onDeleteClick: (ChatMessageId) -> Unit, 22 | ) 23 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/chat/chatbubble/ChatBubbleConstraints.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.chat.chatbubble 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.Modifier 5 | import androidx.compose.ui.layout.Measurable 6 | import androidx.compose.ui.layout.Placeable 7 | import androidx.compose.ui.layout.SubcomposeLayout 8 | import androidx.compose.ui.unit.Constraints 9 | import androidx.compose.ui.unit.IntSize 10 | 11 | @Composable 12 | fun ChatBubbleConstraints( 13 | modifier: Modifier = Modifier, 14 | content: @Composable () -> Unit = {}, 15 | ) { 16 | SubcomposeLayout(modifier = modifier) { constraints -> 17 | var recompositionIndex = 0 18 | var placeables: List = subcompose(recompositionIndex++, content).map { 19 | it.measure(constraints) 20 | } 21 | val columnSize = 22 | placeables.fold(IntSize.Zero) { currentMax: IntSize, placeable: Placeable -> 23 | IntSize( 24 | width = maxOf(currentMax.width, placeable.width), 25 | height = currentMax.height + placeable.height 26 | ) 27 | } 28 | if (placeables.isNotEmpty() && (placeables.size > 1)) { 29 | placeables = subcompose(recompositionIndex, content).map { measurable: Measurable -> 30 | measurable.measure(Constraints(columnSize.width, constraints.maxWidth)) 31 | } 32 | } 33 | layout(columnSize.width, columnSize.height) { 34 | var yPos = 0 35 | placeables.forEach { placeable: Placeable -> 36 | placeable.placeRelative(0, yPos) 37 | yPos += placeable.height 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/chat/chatbubble/MessageStatus.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.chat.chatbubble 2 | 3 | enum class MessageStatus { 4 | NONE, PENDING, RECEIVED, READ 5 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/chat/chatbubble/MessageTimeText.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.chat.chatbubble 2 | 3 | import androidx.compose.foundation.layout.Row 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.foundation.layout.size 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.filled.DoneAll 8 | import androidx.compose.material3.Icon 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.graphics.Color 15 | import androidx.compose.ui.unit.dp 16 | 17 | @Composable 18 | fun MessageTimeText( 19 | modifier: Modifier = Modifier, 20 | messageTime: String? = null, 21 | messageStatus: MessageStatus = MessageStatus.NONE, 22 | ) { 23 | Row( 24 | modifier = modifier, 25 | verticalAlignment = Alignment.CenterVertically 26 | ) { 27 | if (messageTime != null) { 28 | Text( 29 | text = messageTime, 30 | style = MaterialTheme.typography.bodySmall, 31 | color = MaterialTheme.colorScheme.onPrimaryContainer, 32 | ) 33 | } 34 | 35 | if (messageStatus != MessageStatus.NONE) { 36 | Icon( 37 | modifier = Modifier 38 | .size(18.dp) 39 | .padding(start = 4.dp), 40 | imageVector = Icons.Default.DoneAll, 41 | tint = when (messageStatus) { 42 | MessageStatus.READ -> Color.Blue 43 | else -> Color.Gray 44 | }, 45 | contentDescription = "messageStatus" 46 | ) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/chat/chatbubble/RecipientName.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.chat.chatbubble 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.graphics.Color 12 | import androidx.compose.ui.text.font.FontWeight 13 | import androidx.compose.ui.text.style.TextOverflow 14 | import androidx.compose.ui.unit.dp 15 | import androidx.compose.ui.unit.sp 16 | 17 | @Composable 18 | fun RecipientName( 19 | modifier: Modifier = Modifier, 20 | name: String, 21 | isName: Boolean = true, 22 | altName: String? = null, 23 | color: Color = MaterialTheme.colorScheme.onSecondary, 24 | onClick: ((String) -> Unit)? = null, 25 | ) { 26 | Row( 27 | modifier = modifier 28 | .clickable { 29 | onClick?.invoke(name) 30 | } 31 | .padding(start = 4.dp, top = 2.dp, end = 8.dp), 32 | verticalAlignment = Alignment.Top 33 | ) { 34 | Text( 35 | modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), 36 | text = name, 37 | color = color, 38 | // fontSize = 15.sp, 39 | style = MaterialTheme.typography.bodyLarge, 40 | maxLines = 1, 41 | // letterSpacing = 1.sp, 42 | fontWeight = FontWeight.Bold, 43 | overflow = TextOverflow.Ellipsis 44 | ) 45 | if (!isName && altName != null) { 46 | Text( 47 | modifier = Modifier.padding(vertical = 4.dp), 48 | text = "~$altName", 49 | fontSize = 12.sp, 50 | ) 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/chats/ChatsRoute.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.chats 2 | 3 | import kotlinx.serialization.Serializable 4 | import org.jdc.template.ui.navigation.NavigationRoute 5 | 6 | @Serializable 7 | object ChatsRoute: NavigationRoute -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/chats/ChatsScreen.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.chats 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.lazy.LazyColumn 6 | import androidx.compose.foundation.lazy.items 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.filled.Add 9 | import androidx.compose.material3.FloatingActionButton 10 | import androidx.compose.material3.Icon 11 | import androidx.compose.material3.ListItem 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.getValue 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.res.stringResource 17 | import androidx.hilt.navigation.compose.hiltViewModel 18 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 19 | import androidx.navigation.NavController 20 | import org.jdc.template.R 21 | import org.jdc.template.ui.navigation.HandleNavigation 22 | import org.jdc.template.ux.MainAppScaffoldWithNavBar 23 | 24 | @Composable 25 | fun ChatsScreen( 26 | navController: NavController, 27 | viewModel: ChatsViewModel = hiltViewModel(), 28 | ) { 29 | val uiState = viewModel.uiState 30 | 31 | MainAppScaffoldWithNavBar( 32 | title = stringResource(R.string.chats), 33 | navigationIconVisible = false, 34 | onNavigationClick = { navController.popBackStack() }, 35 | floatingActionButton = { 36 | FloatingActionButton( 37 | onClick = { uiState.onNewClick() }, 38 | ) { 39 | Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add)) 40 | } 41 | } 42 | ) { 43 | ChatsContent( 44 | uiState, 45 | ) 46 | } 47 | 48 | HandleNavigation(viewModel, navController) 49 | } 50 | 51 | @Composable 52 | private fun ChatsContent( 53 | uiState: ChatsUiState 54 | ) { 55 | val threadsList by uiState.chatListFlow.collectAsStateWithLifecycle() 56 | 57 | LazyColumn(modifier = Modifier.fillMaxWidth()) { 58 | items(threadsList) { listItem -> 59 | ListItem( 60 | headlineContent = { Text(listItem.name) }, 61 | Modifier 62 | .clickable { uiState.onThreadClick(listItem.id) }, 63 | ) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/chats/ChatsUiState.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.chats 2 | 3 | import kotlinx.coroutines.flow.MutableStateFlow 4 | import kotlinx.coroutines.flow.StateFlow 5 | import org.jdc.template.shared.model.domain.ChatThreadListItem 6 | import org.jdc.template.shared.model.domain.inline.ChatThreadId 7 | import org.jdc.template.ui.compose.dialog.DialogUiState 8 | 9 | data class ChatsUiState( 10 | val dialogUiStateFlow: StateFlow?> = MutableStateFlow(null), 11 | 12 | // Data 13 | val chatListFlow: StateFlow>, 14 | 15 | // events 16 | val onThreadClick: (ChatThreadId) -> Unit, 17 | val onNewClick: () -> Unit 18 | ) 19 | 20 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/chats/ChatsViewModel.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.chats 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import co.touchlab.kermit.Logger 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.launch 9 | import org.jdc.template.shared.model.domain.ChatThread 10 | import org.jdc.template.shared.model.domain.inline.ChatThreadId 11 | import org.jdc.template.shared.model.repository.ChatRepository 12 | import org.jdc.template.shared.model.repository.IndividualRepository 13 | import org.jdc.template.shared.util.ext.stateInDefault 14 | import org.jdc.template.ui.compose.dialog.DialogUiState 15 | import org.jdc.template.ui.navigation.ViewModelNavigation 16 | import org.jdc.template.ui.navigation.ViewModelNavigationImpl 17 | import org.jdc.template.ux.chat.ChatRoute 18 | import java.util.UUID 19 | import javax.inject.Inject 20 | 21 | @HiltViewModel 22 | class ChatsViewModel 23 | @Inject constructor( 24 | private val individualRepository: IndividualRepository, 25 | private val chatMessageRepository: ChatRepository, 26 | ) : ViewModel(), ViewModelNavigation by ViewModelNavigationImpl() { 27 | 28 | private val dialogUiStateFlow = MutableStateFlow?>(null) 29 | 30 | val uiState: ChatsUiState = ChatsUiState( 31 | dialogUiStateFlow = dialogUiStateFlow, 32 | chatListFlow = chatMessageRepository.getChatThreadListFlow().stateInDefault(viewModelScope, emptyList()), 33 | onThreadClick = { onChatThreadClick(it) }, 34 | onNewClick = { onNewChatClick() }, 35 | ) 36 | 37 | private fun onNewChatClick() = viewModelScope.launch { 38 | val allIndividuals = individualRepository.getAllIndividuals() 39 | 40 | if (allIndividuals.size < 2) { 41 | Logger.e { "Not enough individuals to start a chat" } 42 | return@launch 43 | } 44 | 45 | // Create a new thread 46 | val individual1 = allIndividuals.first() 47 | val individual2 = allIndividuals.last() 48 | 49 | val chatThread = ChatThread( 50 | id = ChatThreadId(UUID.randomUUID().toString()), 51 | name = "${individual1.firstName?.value.orEmpty()} & ${individual2.firstName?.value.orEmpty()}", 52 | ownerIndividualId = individual1.id 53 | ) 54 | 55 | chatMessageRepository.saveNewChatThread(chatThread) 56 | 57 | // navigate to the new thread 58 | navigate(ChatRoute(chatThread.id, chatThread.ownerIndividualId)) 59 | } 60 | 61 | private fun onChatThreadClick(chatThreadId: ChatThreadId) = viewModelScope.launch { 62 | val chatThread = chatMessageRepository.getChatThreadById(chatThreadId) ?: return@launch 63 | navigate(ChatRoute(chatThread.id, chatThread.ownerIndividualId)) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/directory/DirectoryRoute.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.directory 2 | 3 | import kotlinx.serialization.Serializable 4 | import org.jdc.template.ui.navigation.NavigationRoute 5 | 6 | @Serializable 7 | object DirectoryRoute: NavigationRoute -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/directory/DirectoryScreen.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.directory 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.lazy.LazyColumn 6 | import androidx.compose.foundation.lazy.items 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.filled.Add 9 | import androidx.compose.material.icons.outlined.Search 10 | import androidx.compose.material3.FloatingActionButton 11 | import androidx.compose.material3.Icon 12 | import androidx.compose.material3.ListItem 13 | import androidx.compose.material3.Text 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.getValue 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.res.stringResource 18 | import androidx.hilt.navigation.compose.hiltViewModel 19 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 20 | import androidx.navigation.NavController 21 | import org.jdc.template.R 22 | import org.jdc.template.ui.compose.appbar.AppBarMenu 23 | import org.jdc.template.ui.compose.appbar.AppBarMenuItem 24 | import org.jdc.template.ui.navigation.HandleNavigation 25 | import org.jdc.template.ux.MainAppScaffoldWithNavBar 26 | 27 | @Composable 28 | fun DirectoryScreen( 29 | navController: NavController, 30 | viewModel: DirectoryViewModel = hiltViewModel(), 31 | ) { 32 | val uiState = viewModel.uiState 33 | 34 | val appBarMenuItems = listOf( 35 | // icons 36 | AppBarMenuItem.Icon(Icons.Outlined.Search, R.string.search) {}, 37 | 38 | // overflow 39 | AppBarMenuItem.OverflowMenuItem(R.string.settings) { uiState.onSettingsClick() } 40 | ) 41 | 42 | MainAppScaffoldWithNavBar( 43 | title = stringResource(R.string.directory), 44 | navigationIconVisible = false, 45 | actions = { AppBarMenu(appBarMenuItems) }, 46 | onNavigationClick = { navController.popBackStack() }, 47 | floatingActionButton = { 48 | FloatingActionButton( 49 | onClick = { uiState.onNewClick() }, 50 | ) { 51 | Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add)) 52 | } 53 | } 54 | ) { 55 | DirectoryContent( 56 | uiState, 57 | ) 58 | } 59 | 60 | HandleNavigation(viewModel, navController) 61 | } 62 | 63 | @Composable 64 | private fun DirectoryContent( 65 | uiState: DirectoryUiState 66 | ) { 67 | val directoryList by uiState.directoryListFlow.collectAsStateWithLifecycle() 68 | 69 | LazyColumn(modifier = Modifier.fillMaxWidth()) { 70 | items(directoryList) { individual -> 71 | ListItem( 72 | headlineContent = { Text(individual.getFullName()) }, 73 | Modifier 74 | .clickable { uiState.onIndividualClick(individual.individualId) }, 75 | ) 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/directory/DirectoryUiState.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.directory 2 | 3 | import kotlinx.coroutines.flow.MutableStateFlow 4 | import kotlinx.coroutines.flow.StateFlow 5 | import org.jdc.template.shared.model.db.main.directoryitem.DirectoryItemEntityView 6 | import org.jdc.template.shared.model.domain.inline.IndividualId 7 | 8 | data class DirectoryUiState( 9 | // Data 10 | val directoryListFlow: StateFlow> = MutableStateFlow(emptyList()), 11 | 12 | // Events 13 | val onNewClick: () -> Unit = {}, 14 | val onIndividualClick: (individualId: IndividualId) -> Unit = {}, 15 | val onSettingsClick: () -> Unit = {} 16 | ) -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/directory/DirectoryViewModel.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.directory 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import org.jdc.template.ui.navigation.ViewModelNavigation 7 | import org.jdc.template.ui.navigation.ViewModelNavigationImpl 8 | import javax.inject.Inject 9 | 10 | @HiltViewModel 11 | class DirectoryViewModel 12 | @Inject constructor( 13 | getDirectoryUiStateUseCase: GetDirectoryUiStateUseCase, 14 | ) : ViewModel(), ViewModelNavigation by ViewModelNavigationImpl() { 15 | val uiState: DirectoryUiState = getDirectoryUiStateUseCase(viewModelScope) { navigate(it) } 16 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/directory/GetDirectoryUiStateUseCase.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.directory 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import org.jdc.template.shared.model.repository.IndividualRepository 5 | import org.jdc.template.shared.util.ext.stateInDefault 6 | import org.jdc.template.ui.navigation.NavigationAction 7 | import org.jdc.template.ux.individual.IndividualRoute 8 | import org.jdc.template.ux.individualedit.IndividualEditRoute 9 | import org.jdc.template.ux.settings.SettingsRoute 10 | import javax.inject.Inject 11 | 12 | class GetDirectoryUiStateUseCase 13 | @Inject constructor( 14 | private val individualRepository: IndividualRepository, 15 | ) { 16 | operator fun invoke( 17 | coroutineScope: CoroutineScope, 18 | navigate: (NavigationAction) -> Unit, 19 | ): DirectoryUiState { 20 | return DirectoryUiState( 21 | directoryListFlow = individualRepository.getDirectoryListFlow().stateInDefault(coroutineScope, emptyList()), 22 | onNewClick = { navigate(NavigationAction.Navigate(IndividualEditRoute())) }, 23 | onIndividualClick = { individualId -> navigate(NavigationAction.Navigate(IndividualRoute(individualId))) }, 24 | onSettingsClick = { navigate(NavigationAction.Navigate(SettingsRoute)) } 25 | ) 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/individual/IndividualRoute.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.individual 2 | 3 | import androidx.navigation.navDeepLink 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | import org.jdc.template.shared.model.domain.inline.IndividualId 7 | import org.jdc.template.ui.navigation.NavTypeMaps 8 | import org.jdc.template.ui.navigation.NavigationRoute 9 | import org.jdc.template.ui.navigation.RouteUtil 10 | import org.jdc.template.ux.NavIntentFilterPart 11 | import kotlin.reflect.typeOf 12 | 13 | @Serializable 14 | data class IndividualRoute( 15 | @SerialName(DeepLinkArgs.PATH_INDIVIDUAL_ID) 16 | val individualId: IndividualId 17 | ): NavigationRoute 18 | 19 | fun IndividualRoute.Companion.typeMap() = mapOf( 20 | typeOf() to NavTypeMaps.IndividualIdNavType, 21 | ) 22 | 23 | fun IndividualRoute.Companion.deepLinks() = listOf( 24 | // Deep link with path/query arguments by using uriPattern 25 | // (don't use generated path in order to maintain deep link contract with other apps) 26 | // ./adb shell am start -W -a android.intent.action.VIEW -d "android-template://individual/xxxx" 27 | navDeepLink { 28 | uriPattern = "${NavIntentFilterPart.DEFAULT_APP_SCHEME}://individual/${RouteUtil.defineArg(DeepLinkArgs.PATH_INDIVIDUAL_ID)}" 29 | }, 30 | ) 31 | 32 | private object DeepLinkArgs { 33 | const val PATH_INDIVIDUAL_ID = "individualId" 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/individual/IndividualUiState.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.individual 2 | 3 | import kotlinx.coroutines.flow.MutableStateFlow 4 | import kotlinx.coroutines.flow.StateFlow 5 | import org.jdc.template.shared.model.domain.Individual 6 | import org.jdc.template.ui.compose.dialog.DialogUiState 7 | 8 | data class IndividualUiState( 9 | val dialogUiStateFlow: StateFlow?> = MutableStateFlow(null), 10 | 11 | // Data 12 | val individualFlow: StateFlow = MutableStateFlow(null), 13 | 14 | // Events 15 | val onEditClick: () -> Unit = {}, 16 | val onDeleteClick: () -> Unit = {}, 17 | val deleteIndividual: () -> Unit = {}, 18 | ) -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/individual/IndividualViewModel.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.individual 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import androidx.navigation.toRoute 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import org.jdc.template.ui.navigation.ViewModelNavigation 9 | import org.jdc.template.ui.navigation.ViewModelNavigationImpl 10 | import javax.inject.Inject 11 | 12 | @HiltViewModel 13 | class IndividualViewModel 14 | @Inject constructor( 15 | getIndividualUiStateUseCase: GetIndividualUiStateUseCase, 16 | savedStateHandle: SavedStateHandle 17 | ) : ViewModel(), ViewModelNavigation by ViewModelNavigationImpl() { 18 | private val individualRoute = savedStateHandle.toRoute(IndividualRoute.typeMap()) 19 | val uiState: IndividualUiState = getIndividualUiStateUseCase(individualRoute.individualId, viewModelScope) { navigate(it) } 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/individualedit/IndividualEditRoute.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.individualedit 2 | 3 | import kotlinx.serialization.Serializable 4 | import org.jdc.template.shared.model.domain.inline.IndividualId 5 | import org.jdc.template.ui.navigation.NavigationRoute 6 | import org.jdc.template.ui.navigation.NavTypeMaps 7 | import kotlin.reflect.typeOf 8 | 9 | @Serializable 10 | data class IndividualEditRoute( 11 | val individualId: IndividualId? = null 12 | ): NavigationRoute 13 | 14 | fun IndividualEditRoute.Companion.typeMap() = mapOf( 15 | typeOf() to NavTypeMaps.IndividualIdNullableNavType, 16 | ) 17 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/individualedit/IndividualEditUiState.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.individualedit 2 | 3 | import kotlinx.coroutines.flow.MutableStateFlow 4 | import kotlinx.coroutines.flow.StateFlow 5 | import kotlinx.datetime.LocalDate 6 | import kotlinx.datetime.LocalTime 7 | import org.jdc.template.shared.model.domain.type.IndividualType 8 | import org.jdc.template.ui.compose.dialog.DialogUiState 9 | 10 | @Suppress("LongParameterList") 11 | class IndividualEditUiState( 12 | val dialogUiStateFlow: StateFlow?> = MutableStateFlow(null), 13 | 14 | // Data 15 | val firstNameFlow: StateFlow = MutableStateFlow(""), 16 | val firstNameErrorFlow: StateFlow = MutableStateFlow(null), 17 | val firstNameOnChange: (String) -> Unit = {}, 18 | val lastNameFlow: StateFlow = MutableStateFlow(""), 19 | val lastNameOnChange: (String) -> Unit = {}, 20 | val phoneFlow: StateFlow = MutableStateFlow(""), 21 | val phoneOnChange: (String) -> Unit = {}, 22 | val emailFlow: StateFlow = MutableStateFlow(""), 23 | val emailErrorFlow: StateFlow = MutableStateFlow(null), 24 | val emailOnChange: (String) -> Unit = {}, 25 | 26 | val birthDateFlow: StateFlow = MutableStateFlow(null), 27 | val birthDateErrorFlow: StateFlow = MutableStateFlow(null), 28 | val birthDateClick: () -> Unit = {}, 29 | 30 | val alarmTimeFlow: StateFlow = MutableStateFlow(null), 31 | val alarmTimeClick: () -> Unit = {}, 32 | 33 | val individualTypeFlow: StateFlow = MutableStateFlow(null), 34 | val individualTypeErrorFlow: StateFlow = MutableStateFlow(null), 35 | val individualTypeChange: (IndividualType) -> Unit = {}, 36 | 37 | val availableFlow: StateFlow = MutableStateFlow(false), 38 | val availableOnChange: (Boolean) -> Unit = {}, 39 | 40 | // Events 41 | val onSaveIndividualClick: () -> Unit = {}, 42 | ) 43 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/individualedit/IndividualEditViewModel.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.individualedit 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import androidx.navigation.toRoute 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import org.jdc.template.ui.navigation.ViewModelNavigation 9 | import org.jdc.template.ui.navigation.ViewModelNavigationImpl 10 | import javax.inject.Inject 11 | 12 | @HiltViewModel 13 | class IndividualEditViewModel 14 | @Inject constructor( 15 | getIndividualEditUiStateUseCase: GetIndividualEditUiStateUseCase, 16 | savedStateHandle: SavedStateHandle 17 | ) : ViewModel(), ViewModelNavigation by ViewModelNavigationImpl() { 18 | private val individualEditRoute = savedStateHandle.toRoute(IndividualEditRoute.typeMap()) 19 | val uiState: IndividualEditUiState = getIndividualEditUiStateUseCase(individualEditRoute.individualId, viewModelScope) { navigate(it) } 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/main/MainScreen.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.main 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.platform.LocalContext 5 | import androidx.hilt.navigation.compose.hiltViewModel 6 | import androidx.navigation.compose.rememberNavController 7 | import org.jdc.template.ui.navigation.HandleNavBarNavigation 8 | import org.jdc.template.util.ext.requireActivity 9 | import org.jdc.template.ux.NavGraph 10 | 11 | @Composable 12 | fun MainScreen( 13 | viewModel: MainViewModel = hiltViewModel(LocalContext.current.requireActivity()) // make sure we share the same ViewModel here and in MainAppScaffoldWithNavBar 14 | ) { 15 | val navController = rememberNavController() 16 | 17 | NavGraph(navController) 18 | 19 | HandleNavBarNavigation(viewModel, navController) 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/main/MainUiState.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.main 2 | 3 | import kotlinx.coroutines.flow.MutableStateFlow 4 | import kotlinx.coroutines.flow.StateFlow 5 | 6 | data class MainUiState( 7 | val selectedAppThemeFlow: StateFlow = MutableStateFlow(null), 8 | ) 9 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/main/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.main 2 | 3 | import androidx.annotation.VisibleForTesting 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import co.touchlab.kermit.Logger 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import kotlinx.coroutines.flow.combine 9 | import kotlinx.coroutines.launch 10 | import org.jdc.template.shared.domain.usecase.CreateIndividualTestDataUseCase 11 | import org.jdc.template.shared.model.domain.type.DisplayThemeType 12 | import org.jdc.template.shared.model.repository.SettingsRepository 13 | import org.jdc.template.shared.util.ext.stateInDefault 14 | import org.jdc.template.ui.navigation.DefaultNavigationBarConfig 15 | import org.jdc.template.ui.navigation.ViewModelNavigationBar 16 | import org.jdc.template.ui.navigation.ViewModelNavigationBarImpl 17 | import org.jdc.template.work.WorkScheduler 18 | import javax.inject.Inject 19 | 20 | @HiltViewModel 21 | class MainViewModel 22 | @Inject constructor( 23 | private val workScheduler: WorkScheduler, 24 | settingsRepository: SettingsRepository, 25 | private val createIndividualTestDataUseCase: CreateIndividualTestDataUseCase 26 | ) : ViewModel(), ViewModelNavigationBar by ViewModelNavigationBarImpl(NavBarItem.PEOPLE, DefaultNavigationBarConfig(NavBarItem.getNavBarItemRouteMap())) { 27 | val uiState = MainUiState( 28 | selectedAppThemeFlow = combine( 29 | settingsRepository.themeFlow.stateInDefault(viewModelScope, null), 30 | settingsRepository.dynamicThemeFlow.stateInDefault(viewModelScope, null)) { displayThemeType, dynamicTheme -> 31 | SelectedAppTheme(displayThemeType ?: DisplayThemeType.SYSTEM_DEFAULT, dynamicTheme ?: false) 32 | }.stateInDefault(viewModelScope, null) 33 | ) 34 | 35 | private var startupComplete = false 36 | fun startup() = viewModelScope.launch { 37 | if (startupComplete) { 38 | return@launch 39 | } 40 | 41 | // run any startup/initialization code here (NOTE: these tasks should NOT exceed 1000ms (per Google Guidelines)) 42 | Logger.i { "Startup task..." } 43 | 44 | // schedule workers 45 | workScheduler.startPeriodicWorkSchedules() 46 | 47 | // Startup finished 48 | Logger.i { "Startup finished" } 49 | 50 | startupComplete = true 51 | } 52 | 53 | @VisibleForTesting 54 | suspend fun createSampleData() { 55 | createIndividualTestDataUseCase() 56 | } 57 | } 58 | 59 | data class SelectedAppTheme(val displayThemeType: DisplayThemeType, val dynamicTheme: Boolean) -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/main/NavBarItem.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.main 2 | 3 | import androidx.annotation.StringRes 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.filled.Info 6 | import androidx.compose.material.icons.filled.People 7 | import androidx.compose.material.icons.outlined.Info 8 | import androidx.compose.ui.graphics.vector.ImageVector 9 | import org.jdc.template.R 10 | import org.jdc.template.ui.compose.icons.google.outlined.People 11 | import org.jdc.template.ui.navigation.NavigationRoute 12 | import org.jdc.template.ux.about.AboutRoute 13 | import org.jdc.template.ux.directory.DirectoryRoute 14 | 15 | enum class NavBarItem( 16 | val unselectedImageVector: ImageVector, 17 | val selectedImageVector: ImageVector, 18 | val typeSafeRoute: NavigationRoute, 19 | @StringRes val textResId: Int? = null, 20 | ) { 21 | PEOPLE(Icons.Outlined.People, Icons.Filled.People, DirectoryRoute, R.string.people), 22 | ABOUT(Icons.Outlined.Info, Icons.Filled.Info, AboutRoute, R.string.about); 23 | 24 | companion object { 25 | fun getNavBarItemRouteMap(): Map { 26 | return entries.associateWith { item -> item.typeSafeRoute } 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/settings/SettingsRoute.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.settings 2 | 3 | import androidx.navigation.navDeepLink 4 | import kotlinx.serialization.Serializable 5 | import org.jdc.template.ui.navigation.NavigationRoute 6 | import org.jdc.template.ux.NavIntentFilterPart 7 | 8 | @Serializable 9 | object SettingsRoute: NavigationRoute 10 | 11 | fun SettingsRoute.deeplinks() = listOf( 12 | // Simple deep link without any path/query arguments 13 | // ./adb shell am start -W -a android.intent.action.VIEW -d "android-template://settings" 14 | navDeepLink("${NavIntentFilterPart.DEFAULT_APP_SCHEME}://settings"), 15 | ) 16 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/settings/SettingsScreen.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.settings 2 | 3 | import android.os.Build 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.rememberScrollState 6 | import androidx.compose.foundation.verticalScroll 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.res.stringResource 10 | import androidx.hilt.navigation.compose.hiltViewModel 11 | import androidx.navigation.NavController 12 | import org.jdc.template.R 13 | import org.jdc.template.ui.compose.dialog.HandleDialogUiState 14 | import org.jdc.template.ui.compose.setting.Setting 15 | import org.jdc.template.ui.navigation.WorkManagerStatusRoute 16 | import org.jdc.template.ux.MainAppScaffoldWithNavBar 17 | 18 | @Composable 19 | fun SettingsScreen( 20 | navController: NavController? = null, 21 | viewModel: SettingsViewModel = hiltViewModel() 22 | ) { 23 | val uiState = viewModel.uiState 24 | 25 | val scrollState = rememberScrollState() 26 | 27 | MainAppScaffoldWithNavBar( 28 | title = stringResource(R.string.settings), 29 | hideNavigation = true, 30 | onNavigationClick = { navController?.popBackStack() }, 31 | ) { 32 | Column( 33 | Modifier.verticalScroll(scrollState) 34 | ) { 35 | Setting.Header(stringResource(R.string.display)) 36 | Setting.Clickable(stringResource(R.string.theme), uiState.currentThemeTitleFlow) { uiState.onThemeSettingClick() } 37 | // Dynamic themes are only available on Android 13+ 38 | if (Build.VERSION.SDK_INT >= 33) { 39 | Setting.Switch(stringResource(R.string.dynamic_theme), uiState.dynamicThemeFlow) { uiState.setDynamicTheme(it) } 40 | } 41 | Setting.Switch(stringResource(R.string.sort_by_last_name), uiState.sortByLastNameFlow) { uiState.setSortByLastName(it) } 42 | 43 | // not translated because this should not be visible for release builds 44 | Setting.Header("Developer Options") 45 | Setting.Clickable(text = "Work Manager Status", secondaryText = "Show status of all background workers") { 46 | navController?.navigate(WorkManagerStatusRoute) 47 | } 48 | Setting.Clickable(text = "Last Installed Version Code", uiState.currentLastInstalledVersionCodeFlow) { uiState.onLastInstalledVersionCodeClick() } 49 | Setting.Clickable(text = "Range", uiState.rangeFlow) { uiState.onRangeClick() } 50 | } 51 | 52 | HandleDialogUiState(uiState.dialogUiStateFlow) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/ux/settings/SettingsUiState.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.ux.settings 2 | 3 | import kotlinx.coroutines.flow.MutableStateFlow 4 | import kotlinx.coroutines.flow.StateFlow 5 | import org.jdc.template.ui.compose.dialog.DialogUiState 6 | 7 | data class SettingsUiState( 8 | val dialogUiStateFlow: StateFlow?> = MutableStateFlow(null), 9 | 10 | // Data 11 | val currentThemeTitleFlow: StateFlow = MutableStateFlow(null), 12 | val currentLastInstalledVersionCodeFlow: StateFlow = MutableStateFlow(null), 13 | val rangeFlow: StateFlow = MutableStateFlow(null), 14 | val dynamicThemeFlow: StateFlow = MutableStateFlow(false), 15 | val sortByLastNameFlow: StateFlow = MutableStateFlow(false), 16 | 17 | // Events 18 | val onThemeSettingClick: () -> Unit = {}, 19 | val onLastInstalledVersionCodeClick: () -> Unit = {}, 20 | val onRangeClick: () -> Unit = {}, 21 | val dismissSetLastInstalledVersionCodeDialog: () -> Unit = {}, 22 | val setDynamicTheme: (checked: Boolean) -> Unit = {}, 23 | val setSortByLastName: (checked: Boolean) -> Unit = {}, 24 | ) 25 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/work/RemoteConfigSyncWorker.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.work 2 | 3 | import android.content.Context 4 | import androidx.hilt.work.HiltWorker 5 | import androidx.work.CoroutineWorker 6 | import androidx.work.WorkerParameters 7 | import co.touchlab.kermit.Logger 8 | import dagger.assisted.Assisted 9 | import dagger.assisted.AssistedInject 10 | import org.jdc.template.model.config.RemoteConfig 11 | 12 | @HiltWorker 13 | class RemoteConfigSyncWorker 14 | @AssistedInject constructor( 15 | private val remoteConfig: RemoteConfig, 16 | @Assisted appContext: Context, 17 | @Assisted workerParams: WorkerParameters 18 | ) : CoroutineWorker(appContext, workerParams) { 19 | 20 | override suspend fun doWork(): Result { 21 | Logger.i { "RemoteConfigSyncWorker: fetching and activating" } 22 | remoteConfig.fetchAndActivateNow() 23 | return Result.success() 24 | } 25 | 26 | companion object { 27 | const val UNIQUE_ONE_TIME_WORK_NAME = "OneTimeRemoteConfigSyncWorker" 28 | const val UNIQUE_PERIODIC_WORK_NAME = "PeriodicRemoteConfigSyncWorker" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/work/SimpleWorker.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.work 2 | 3 | import android.content.Context 4 | import androidx.annotation.WorkerThread 5 | import androidx.hilt.work.HiltWorker 6 | import androidx.work.CoroutineWorker 7 | import androidx.work.Data 8 | import androidx.work.WorkerParameters 9 | import co.touchlab.kermit.Logger 10 | import dagger.assisted.Assisted 11 | import dagger.assisted.AssistedInject 12 | import kotlinx.coroutines.delay 13 | 14 | /** 15 | * Example simple worker... one that should execute every time it is called 16 | */ 17 | @HiltWorker 18 | class SimpleWorker 19 | @AssistedInject constructor( 20 | @Assisted appContext: Context, 21 | @Assisted workerParams: WorkerParameters 22 | ) : CoroutineWorker(appContext, workerParams) { 23 | 24 | @WorkerThread 25 | override suspend fun doWork(): Result { 26 | val inputText = inputData.getString(KEY_TEXT) 27 | 28 | logProgress("RUNNING: Text: [$inputText]") 29 | try { 30 | delay(1000) 31 | } catch (e: InterruptedException) { 32 | Logger.e(e) { "Sleep Failure" } 33 | } 34 | 35 | // return result 36 | return Result.success() 37 | } 38 | 39 | private fun logProgress(progress: String) { 40 | Logger.e { "*** SyncWorker[$progress] Thread:[${Thread.currentThread().name}] Job:[${this.id}]" } 41 | } 42 | 43 | companion object { 44 | private const val KEY_TEXT = "TEXT" 45 | 46 | fun createInputData( 47 | text: String 48 | ): Data { 49 | val dataBuilder = Data.Builder() 50 | .putString(KEY_TEXT, text) 51 | 52 | return dataBuilder.build() 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/jdc/template/work/SyncWorker.kt: -------------------------------------------------------------------------------- 1 | package org.jdc.template.work 2 | 3 | import android.content.Context 4 | import androidx.annotation.WorkerThread 5 | import androidx.hilt.work.HiltWorker 6 | import androidx.work.CoroutineWorker 7 | import androidx.work.WorkerParameters 8 | import co.touchlab.kermit.Logger 9 | import dagger.assisted.Assisted 10 | import dagger.assisted.AssistedInject 11 | import kotlinx.coroutines.delay 12 | import org.jdc.template.shared.model.repository.SettingsRepository 13 | 14 | /** 15 | * Example data sync worker... one that should sync your changes when the user is finished changing/editing data 16 | * 17 | * This type of worker should: 18 | * - Only run once (don't sync on every single edit) 19 | * - Delay for 30 seconds (group together as many changes/edits as possible) 20 | * - Replace any existing scheduled (if there is a pending sync request... remove it and reset delay for 30 seconds) 21 | * - Require network connection 22 | */ 23 | @HiltWorker 24 | class SyncWorker 25 | @AssistedInject constructor( 26 | val settingsRepository: SettingsRepository, 27 | @Assisted appContext: Context, 28 | @Assisted workerParams: WorkerParameters 29 | ) : CoroutineWorker(appContext, workerParams) { 30 | 31 | @WorkerThread 32 | override suspend fun doWork(): Result { 33 | logProgress("RUNNING") 34 | 35 | // simulate some work... 36 | logProgress("WORK1-STARTED") 37 | logProgress("WORK1-developerMode=${settingsRepository.isDeveloperModeEnabled()}") 38 | delay(5000) 39 | logProgress("WORK1-FINISHED") 40 | 41 | if (isStopped) { 42 | logProgress("WORK2-SKIPPED") 43 | return Result.success() 44 | } 45 | 46 | // simulate some work... 47 | logProgress("WORK2-STARTED") 48 | delay(1000) 49 | logProgress("WORK2-FINISHED") 50 | 51 | logProgress("FINISHED") 52 | // return result 53 | return Result.success() 54 | } 55 | 56 | private fun logProgress(progress: String) { 57 | Logger.e { "*** SyncWorker[$progress] Thread:[${Thread.currentThread().name}] Job:[${this.id}]" } 58 | } 59 | 60 | companion object { 61 | const val UNIQUE_WORK_NAME = "SyncWorker" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdcamp/android-template/20a43f20c6ee67e0effa7187d67bb10f0672b482/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/resources.properties: -------------------------------------------------------------------------------- 1 | unqualifiedResLocale=en-US 2 | -------------------------------------------------------------------------------- /app/src/main/res/values-v31/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #3574DC 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 |