├── .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 |
5 |
6 |
7 |
8 |
11 |
18 |
19 |
20 | false
21 | true
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | false
47 | true
48 |
49 |
50 |
--------------------------------------------------------------------------------
/.run/Analyze App Size.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | true
19 | true
20 | false
21 | false
22 |
23 |
24 |
--------------------------------------------------------------------------------
/.run/Dependency Updates.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | true
19 | true
20 | false
21 | false
22 |
23 |
24 |
--------------------------------------------------------------------------------
/.run/Gradle Dependencies Report.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | true
21 | true
22 | false
23 | false
24 |
25 |
26 |
--------------------------------------------------------------------------------
/.run/Jacoco Coverage Report.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | true
19 | true
20 | false
21 | false
22 |
23 |
24 |
--------------------------------------------------------------------------------
/.run/Static Analysis.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | true
20 | true
21 | false
22 | false
23 |
24 |
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Android Template
2 | =================
3 |
4 | [](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 |
12 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/remote_config_defaults.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | colorServiceEnabled
5 | true
6 |
7 |
8 |
--------------------------------------------------------------------------------
/app/src/test/kotlin/konsist/KonsistAndroidTests.kt:
--------------------------------------------------------------------------------
1 | package konsist
2 |
3 | import androidx.lifecycle.ViewModel
4 | import com.lemonappdev.konsist.api.Konsist
5 | import com.lemonappdev.konsist.api.ext.list.withAllParentsOf
6 | import com.lemonappdev.konsist.api.verify.assertTrue
7 | import kotlin.test.Test
8 |
9 | class KonsistAndroidTests {
10 | @Test
11 | fun `classes extending 'ViewModel' should have 'ViewModel' suffix`() {
12 | Konsist.scopeFromProject()
13 | .classes()
14 | .withAllParentsOf(ViewModel::class)
15 | .assertTrue { it.name.endsWith("ViewModel") }
16 | }
17 | }
--------------------------------------------------------------------------------
/app/src/test/kotlin/konsist/KonsistConts.kt:
--------------------------------------------------------------------------------
1 | package konsist
2 |
3 | object KonsistConts {
4 | const val APP_PACKAGE_NAME = "org.jdc.template"
5 | }
--------------------------------------------------------------------------------
/app/src/test/kotlin/konsist/KonsistTests.kt:
--------------------------------------------------------------------------------
1 | package konsist
2 |
3 | import androidx.annotation.VisibleForTesting
4 | import com.lemonappdev.konsist.api.Konsist
5 | import com.lemonappdev.konsist.api.ext.list.functions
6 | import com.lemonappdev.konsist.api.ext.list.modifierprovider.withPublicModifier
7 | import com.lemonappdev.konsist.api.ext.list.print
8 | import com.lemonappdev.konsist.api.ext.list.withNameEndingWith
9 | import com.lemonappdev.konsist.api.ext.list.withoutAnnotationOf
10 | import kotlin.test.Test
11 |
12 | class KonsistTests {
13 | @Test
14 | fun `classes with 'UseCase' suffix should reside in 'usecase' package`() {
15 | Konsist.scopeFromProject()
16 | .classes()
17 | .withNameEndingWith("UseCase")
18 | .functions()
19 | .withoutAnnotationOf(VisibleForTesting::class)
20 | .print("xxx-")
21 | .withPublicModifier()
22 | .print("yyy-")
23 | }
24 | }
--------------------------------------------------------------------------------
/app/src/test/kotlin/org/jdc/template/LoggingUtil.kt:
--------------------------------------------------------------------------------
1 | package org.jdc.template
2 |
3 | object LoggingUtil {
4 | /**
5 | * Java Logger (used by JUnit and OkHttp on JVM) adds a timestamp and newline with each log... this can make it difficult to read the output logs of OkHttp
6 | * This function will setup the format of the Logger so that the logs are on a single line
7 | *
8 | * NOTE: For JUnit tests... This call should be made in the setup() (before the Logger is initialized)
9 | *
10 | * @param logMessageOnly Set to true to ONLY show the log message (exclude timestamp etc)
11 | */
12 | fun setupSingleLineLogging(logMessageOnly: Boolean = false) {
13 | if (logMessageOnly) {
14 | System.setProperty("java.util.logging.SimpleFormatter.format", "%5\$s %6\$s%n")
15 | } else {
16 | System.setProperty("java.util.logging.SimpleFormatter.format", "%1\$tY-%1\$tm-%1\$td %1\$tH:%1\$tM:%1\$tS.%1\$tL %4$-7s [%3\$s] %5\$s %6\$s%n")
17 | }
18 | }
19 | }
--------------------------------------------------------------------------------
/app/src/test/kotlin/org/jdc/template/TestFilesystem.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("MemberVisibilityCanBePrivate")
2 |
3 | package org.jdc.template
4 |
5 | import java.io.File
6 |
7 | object TestFilesystem {
8 | const val FILESYSTEM_DIR_PATH = "build/test-filesystem"
9 | val FILESYSTEM_DIR = File(FILESYSTEM_DIR_PATH)
10 | const val INTERNAL_DIR_PATH = "$FILESYSTEM_DIR_PATH/internal"
11 | val INTERNAL_DIR = File(INTERNAL_DIR_PATH)
12 | const val EXTERNAL_DIR_PATH = "$FILESYSTEM_DIR_PATH/external"
13 | val EXTERNAL_DIR = File(EXTERNAL_DIR_PATH)
14 |
15 | const val INTERNAL_FILES_DIR_PATH = "$INTERNAL_DIR_PATH/files"
16 | val INTERNAL_FILES_DIR = File(INTERNAL_FILES_DIR_PATH)
17 | const val INTERNAL_DATABASES_DIR_PATH = "$INTERNAL_DIR_PATH/databases"
18 |
19 | const val EXTERNAL_FILES_DIR_PATH = "$EXTERNAL_DIR_PATH/files"
20 | val EXTERNAL_FILES_DIR = File(EXTERNAL_FILES_DIR_PATH)
21 |
22 | fun deleteFilesystem() {
23 | FILESYSTEM_DIR.deleteRecursively()
24 | }
25 |
26 | fun copyDatabase(sourcePath: String, targetPath: String) {
27 | val targetDBFile = File(targetPath)
28 | val dbDirectory = targetDBFile.parentFile
29 |
30 | try {
31 | dbDirectory.mkdirs()
32 |
33 | if (targetDBFile.exists()) {
34 | targetDBFile.delete()
35 | }
36 |
37 | File(sourcePath).copyTo(targetDBFile, true)
38 | } catch (e: Exception) {
39 | e.printStackTrace()
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/app/src/test/kotlin/org/jdc/template/ux/SharedUseCaseTest.kt:
--------------------------------------------------------------------------------
1 | package org.jdc.template.ux
2 |
3 | import android.app.Application
4 | import io.mockk.coEvery
5 | import io.mockk.every
6 | import io.mockk.mockk
7 | import kotlinx.coroutines.flow.flowOf
8 | import org.jdc.template.shared.model.db.main.directoryitem.DirectoryItemEntityView
9 | import org.jdc.template.shared.model.domain.Individual
10 | import org.jdc.template.shared.model.domain.inline.FirstName
11 | import org.jdc.template.shared.model.domain.inline.IndividualId
12 | import org.jdc.template.shared.model.domain.inline.LastName
13 | import org.jdc.template.shared.model.repository.IndividualRepository
14 |
15 | fun mockApplication(): Application {
16 | val mockApplication = mockk()
17 |
18 | every { mockApplication.getString(any()) } coAnswers {
19 | val resId = firstArg()
20 | "application string: $resId"
21 | }
22 |
23 | return mockApplication
24 | }
25 |
26 | fun mockIndividualRepository(): IndividualRepository {
27 | val testIndividuals = TestIndividuals()
28 |
29 | val mockIndividualRepository = mockk()
30 |
31 | coEvery { mockIndividualRepository.getDirectoryListFlow() } returns flowOf(testIndividuals.directoryItems)
32 |
33 | coEvery { mockIndividualRepository.getIndividual(any()) } coAnswers {
34 | val individualId = firstArg()
35 | testIndividuals.individuals.firstOrNull { it.id == individualId }
36 | }
37 | coEvery { mockIndividualRepository.getIndividualFlow(any()) } coAnswers {
38 | val individualId = firstArg()
39 | flowOf(testIndividuals.individuals.firstOrNull { it.id == individualId })
40 | }
41 | coEvery { mockIndividualRepository.deleteIndividual(any()) } coAnswers {
42 | val individualId = firstArg()
43 | testIndividuals.individuals.removeAll { it.id == individualId }
44 | }
45 | coEvery { mockIndividualRepository.saveIndividual(any()) } coAnswers {
46 | val individual = firstArg()
47 | testIndividuals.individuals.add(individual)
48 | }
49 |
50 | return mockIndividualRepository
51 | }
52 |
53 | class TestIndividuals {
54 | val individuals: MutableList = mutableListOf(
55 | individual1,
56 | individual2,
57 | )
58 |
59 | val directoryItems: List
60 | get() {
61 | return individuals.map { DirectoryItemEntityView(it.id, it.firstName, it.lastName) }
62 | }
63 |
64 | companion object {
65 | val individualId1 = IndividualId("1")
66 | val individual1 = Individual(id = individualId1, firstName = FirstName("Jeff"), lastName = LastName("Campbell"))
67 | val individualId2 = IndividualId("2")
68 | val individual2 = Individual(id = individualId2, firstName = FirstName("Mark"), lastName = LastName("Brown"))
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/app/src/test/kotlin/org/jdc/template/ux/individualedit/GetIndividualEditUiStateUseCaseTest.kt:
--------------------------------------------------------------------------------
1 | package org.jdc.template.ux.individualedit
2 |
3 | import app.cash.turbine.turbineScope
4 | import assertk.assertThat
5 | import assertk.assertions.isEqualTo
6 | import io.mockk.mockk
7 | import kotlinx.coroutines.CoroutineScope
8 | import kotlinx.coroutines.Job
9 | import kotlinx.coroutines.cancel
10 | import kotlinx.coroutines.flow.MutableStateFlow
11 | import kotlinx.coroutines.test.runTest
12 | import org.jdc.template.ui.navigation.NavigationAction
13 | import org.jdc.template.ux.TestIndividuals
14 | import org.jdc.template.ux.mockApplication
15 | import org.jdc.template.ux.mockIndividualRepository
16 | import kotlin.test.Test
17 |
18 | class GetIndividualEditUiStateUseCaseTest {
19 | @Test
20 | fun testNavigation() = runTest {
21 | turbineScope {
22 | val useCase = GetIndividualEditUiStateUseCase(
23 | application = mockApplication(),
24 | individualRepository = mockIndividualRepository(),
25 | analytics = mockk(relaxed = true)
26 | )
27 | val stateScope = CoroutineScope(Job())
28 | val lastNavigationActionFlow = MutableStateFlow(null)
29 | val navigationActionTurbine = lastNavigationActionFlow.testIn(stateScope)
30 | navigationActionTurbine.awaitItem() // consume default value
31 |
32 | val selectedIndividualId = TestIndividuals.individualId1
33 | val uiState = useCase(
34 | individualId = selectedIndividualId,
35 | coroutineScope = stateScope,
36 | navigate = { lastNavigationActionFlow.value = it }
37 | )
38 |
39 | val dialogUiStateFlowTurbine = uiState.dialogUiStateFlow.testIn(stateScope)
40 | dialogUiStateFlowTurbine.awaitItem() // consume default value
41 |
42 | uiState.onSaveIndividualClick()
43 | assertThat(navigationActionTurbine.awaitItem()).isEqualTo(NavigationAction.Pop())
44 |
45 | stateScope.cancel()
46 | }
47 | }
48 | }
--------------------------------------------------------------------------------
/buildSrc/build.gradle.kts:
--------------------------------------------------------------------------------
1 | repositories {
2 | mavenCentral()
3 | }
4 |
5 | plugins {
6 | `kotlin-dsl`
7 | }
8 |
--------------------------------------------------------------------------------
/buildSrc/src/main/kotlin/AppInfo.kt:
--------------------------------------------------------------------------------
1 | @Suppress("MemberVisibilityCanBePrivate")
2 |
3 | object AppInfo {
4 | const val APPLICATION_ID = "org.jdc.template"
5 |
6 | // Manifest version information
7 | object Version {
8 | const val CODE = 1007
9 | val NAME = "1.0.0 ($CODE.${System.getenv("BUILD_NUMBER")})"
10 | }
11 |
12 | object AndroidSdk {
13 | const val MIN = 23
14 | const val COMPILE = 36
15 | const val TARGET = COMPILE
16 | }
17 | }
--------------------------------------------------------------------------------
/docs/privacy-policy.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Privacy Policy
4 |
5 |
6 |
7 | Privacy Policy
8 | (11/30/2023)
9 |
10 | Data collected
11 |
12 | - Crash logs - analytics
13 | - Diagnostics data - analytics
14 | - Usage statistics - analytics
15 |
16 |
17 | Data is encrypted in transit
18 |
19 | - Data is transferred over a secure connection
20 |
21 |
22 | Data can’t be deleted
23 |
24 | - We do not provide a way to delete your data.
25 |
26 |
27 | Data usage
28 |
29 | - Aggregated anonymous app usage data through Google Analytics to help improve the app
30 |
31 |
32 | Contact
33 | jeffdcamp@gmail.com
34 |
35 |