├── app
├── .gitignore
├── src
│ ├── main
│ │ ├── res
│ │ │ ├── values
│ │ │ │ ├── strings.xml
│ │ │ │ ├── dimens.xml
│ │ │ │ ├── themes.xml
│ │ │ │ └── colors.xml
│ │ │ ├── font
│ │ │ │ ├── roboto_black.ttf
│ │ │ │ ├── roboto_bold.ttf
│ │ │ │ ├── roboto_light.ttf
│ │ │ │ ├── roboto_thin.ttf
│ │ │ │ ├── ubuntu_bold.ttf
│ │ │ │ ├── ubuntu_light.ttf
│ │ │ │ ├── roboto_italic.ttf
│ │ │ │ ├── roboto_medium.ttf
│ │ │ │ ├── roboto_regular.ttf
│ │ │ │ ├── ubuntu_italic.ttf
│ │ │ │ ├── ubuntu_medium.ttf
│ │ │ │ ├── ubuntu_regular.ttf
│ │ │ │ ├── roboto_bolditalic.ttf
│ │ │ │ ├── roboto_thinitalic.ttf
│ │ │ │ ├── ubuntu_bolditalic.ttf
│ │ │ │ ├── roboto_blackitalic.ttf
│ │ │ │ ├── roboto_lightitalic.ttf
│ │ │ │ ├── roboto_mediumitalic.ttf
│ │ │ │ ├── ubuntu_lightitalic.ttf
│ │ │ │ └── ubuntu_mediumitalic.ttf
│ │ │ ├── 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
│ │ │ ├── drawable
│ │ │ │ ├── logo_250_250_transparent.png
│ │ │ │ ├── ic_baseline_visibility_on_24.xml
│ │ │ │ ├── ic_baseline_visibility_off_24.xml
│ │ │ │ └── ic_launcher_background.xml
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── layout
│ │ │ │ ├── compose_view.xml
│ │ │ │ └── activity_main.xml
│ │ │ ├── navigation
│ │ │ │ ├── main_graph.xml
│ │ │ │ ├── auth_graph.xml
│ │ │ │ └── launch_graph.xml
│ │ │ ├── values-night
│ │ │ │ └── themes.xml
│ │ │ └── drawable-v24
│ │ │ │ └── ic_launcher_foreground.xml
│ │ ├── java
│ │ │ └── com
│ │ │ │ └── codingwithmitch
│ │ │ │ └── openchat
│ │ │ │ ├── common
│ │ │ │ ├── framework
│ │ │ │ │ ├── presentation
│ │ │ │ │ │ ├── Utils.kt
│ │ │ │ │ │ ├── UIController.kt
│ │ │ │ │ │ ├── navigation
│ │ │ │ │ │ │ └── Navigation.kt
│ │ │ │ │ │ ├── theme
│ │ │ │ │ │ │ ├── Shape.kt
│ │ │ │ │ │ │ ├── Color.kt
│ │ │ │ │ │ │ ├── Theme.kt
│ │ │ │ │ │ │ └── Type.kt
│ │ │ │ │ │ ├── BaseApplication.kt
│ │ │ │ │ │ ├── state
│ │ │ │ │ │ │ ├── TextEmailState.kt
│ │ │ │ │ │ │ ├── TextUsernameState.kt
│ │ │ │ │ │ │ ├── TextPasswordState.kt
│ │ │ │ │ │ │ └── TextFieldState.kt
│ │ │ │ │ │ ├── MainActivity.kt
│ │ │ │ │ │ ├── components
│ │ │ │ │ │ │ ├── TextFieldError.kt
│ │ │ │ │ │ │ ├── CircularIndeterminateProgressBar.kt
│ │ │ │ │ │ │ ├── EmailInputField.kt
│ │ │ │ │ │ │ ├── UsernameInputField.kt
│ │ │ │ │ │ │ └── PasswordInputField.kt
│ │ │ │ │ │ └── BaseMainFragment.kt
│ │ │ │ │ └── datasource
│ │ │ │ │ │ └── cache
│ │ │ │ │ │ └── database
│ │ │ │ │ │ └── AppDatabase.kt
│ │ │ │ ├── business
│ │ │ │ │ ├── domain
│ │ │ │ │ │ ├── state
│ │ │ │ │ │ │ ├── ViewState.kt
│ │ │ │ │ │ │ ├── StateEvent.kt
│ │ │ │ │ │ │ ├── DataState.kt
│ │ │ │ │ │ │ ├── StateResource.kt
│ │ │ │ │ │ │ ├── StateEventManager.kt
│ │ │ │ │ │ │ ├── MessageStack.kt
│ │ │ │ │ │ │ └── DataChannelManager.kt
│ │ │ │ │ │ └── util
│ │ │ │ │ │ │ ├── EntityMapper.kt
│ │ │ │ │ │ │ ├── Logger.kt
│ │ │ │ │ │ │ ├── DateUtil.kt
│ │ │ │ │ │ │ ├── ProjectRegex.kt
│ │ │ │ │ │ │ └── AsyncExtensions.kt
│ │ │ │ │ └── data
│ │ │ │ │ │ ├── cache
│ │ │ │ │ │ ├── CacheConstants.kt
│ │ │ │ │ │ ├── exceptions
│ │ │ │ │ │ │ └── CacheException.kt
│ │ │ │ │ │ ├── CacheResult.kt
│ │ │ │ │ │ ├── CacheErrors.kt
│ │ │ │ │ │ └── CacheResponseHandler.kt
│ │ │ │ │ │ ├── network
│ │ │ │ │ │ ├── exceptions
│ │ │ │ │ │ │ └── NetworkException.kt
│ │ │ │ │ │ ├── NetworkConstants.kt
│ │ │ │ │ │ ├── ApiResult.kt
│ │ │ │ │ │ ├── NetworkErrors.kt
│ │ │ │ │ │ └── ApiResponseHandler.kt
│ │ │ │ │ │ └── util
│ │ │ │ │ │ └── GenericErrors.kt
│ │ │ │ └── di
│ │ │ │ │ └── AppModule.kt
│ │ │ │ ├── auth
│ │ │ │ └── framework
│ │ │ │ │ └── presentation
│ │ │ │ │ ├── state
│ │ │ │ │ ├── AuthStateEvent.kt
│ │ │ │ │ ├── AuthBundle.kt
│ │ │ │ │ └── AuthViewState.kt
│ │ │ │ │ ├── navigation
│ │ │ │ │ └── AuthNavigation.kt
│ │ │ │ │ ├── AuthFragment.kt
│ │ │ │ │ ├── screens
│ │ │ │ │ ├── PasswordResetScreen.kt
│ │ │ │ │ └── CreateAccountScreen.kt
│ │ │ │ │ └── AuthViewModel.kt
│ │ │ │ ├── session
│ │ │ │ ├── framework
│ │ │ │ │ ├── datasource
│ │ │ │ │ │ ├── datastore
│ │ │ │ │ │ │ └── SessionPreferences.kt
│ │ │ │ │ │ ├── network
│ │ │ │ │ │ │ ├── SessionService.kt
│ │ │ │ │ │ │ ├── exceptions
│ │ │ │ │ │ │ │ └── AuthException.kt
│ │ │ │ │ │ │ ├── model
│ │ │ │ │ │ │ │ └── AuthTokenNetworkEntity.kt
│ │ │ │ │ │ │ ├── retrofit
│ │ │ │ │ │ │ │ └── AuthRetrofitService.kt
│ │ │ │ │ │ │ ├── mappers
│ │ │ │ │ │ │ │ └── SessionNetworkMapper.kt
│ │ │ │ │ │ │ └── SessionServiceImpl.kt
│ │ │ │ │ │ └── cache
│ │ │ │ │ │ │ ├── AuthTokenDaoService.kt
│ │ │ │ │ │ │ ├── database
│ │ │ │ │ │ │ └── AuthTokenDao.kt
│ │ │ │ │ │ │ ├── model
│ │ │ │ │ │ │ └── AuthTokenCacheEntity.kt
│ │ │ │ │ │ │ ├── mappers
│ │ │ │ │ │ │ └── AuthCacheMapper.kt
│ │ │ │ │ │ │ └── AuthTokenDaoServiceImpl.kt
│ │ │ │ │ └── presentation
│ │ │ │ │ │ ├── SessionState.kt
│ │ │ │ │ │ ├── SessionStateEvent.kt
│ │ │ │ │ │ └── SessionManager.kt
│ │ │ │ ├── business
│ │ │ │ │ ├── data
│ │ │ │ │ │ ├── network
│ │ │ │ │ │ │ ├── AuthNetworkDataSource.kt
│ │ │ │ │ │ │ └── AuthNetworkDataSourceImpl.kt
│ │ │ │ │ │ └── cache
│ │ │ │ │ │ │ ├── AuthCacheDataSource.kt
│ │ │ │ │ │ │ └── AuthCacheDataSourceImpl.kt
│ │ │ │ │ ├── domain
│ │ │ │ │ │ └── model
│ │ │ │ │ │ │ └── AuthToken.kt
│ │ │ │ │ └── interactors
│ │ │ │ │ │ ├── Logout.kt
│ │ │ │ │ │ ├── CheckAuthToken.kt
│ │ │ │ │ │ └── Login.kt
│ │ │ │ └── di
│ │ │ │ │ ├── SessionCacheModule.kt
│ │ │ │ │ ├── SessionModule.kt
│ │ │ │ │ └── SessionNetworkModule.kt
│ │ │ │ ├── account
│ │ │ │ ├── business
│ │ │ │ │ └── domain
│ │ │ │ │ │ └── model
│ │ │ │ │ │ └── Account.kt
│ │ │ │ ├── framework
│ │ │ │ │ └── datasource
│ │ │ │ │ │ └── cache
│ │ │ │ │ │ ├── database
│ │ │ │ │ │ └── AccountDao.kt
│ │ │ │ │ │ ├── model
│ │ │ │ │ │ └── AccountCacheEntity.kt
│ │ │ │ │ │ └── mappers
│ │ │ │ │ │ └── AccountCacheMapper.kt
│ │ │ │ └── di
│ │ │ │ │ └── AccountCacheModule.kt
│ │ │ │ ├── splash
│ │ │ │ └── framework
│ │ │ │ │ └── presentation
│ │ │ │ │ ├── SplashScreen.kt
│ │ │ │ │ └── SplashFragment.kt
│ │ │ │ └── main
│ │ │ │ └── framework
│ │ │ │ └── presentation
│ │ │ │ └── main
│ │ │ │ └── MainFragment.kt
│ │ └── AndroidManifest.xml
│ ├── debug
│ │ └── java
│ │ │ └── com
│ │ │ └── codingwithmitch
│ │ │ └── openchat
│ │ │ ├── util
│ │ │ └── Constants.kt
│ │ │ └── EspressoIdlingResource.kt
│ ├── release
│ │ └── java
│ │ │ └── com
│ │ │ └── codingwithmitch
│ │ │ └── openchat
│ │ │ ├── util
│ │ │ └── Constants.kt
│ │ │ └── EspressoIdlingResource.kt
│ ├── test
│ │ └── java
│ │ │ └── com
│ │ │ └── codingwithmitch
│ │ │ └── openchat
│ │ │ └── ExampleUnitTest.kt
│ └── androidTest
│ │ └── java
│ │ └── com
│ │ └── codingwithmitch
│ │ └── openchat
│ │ └── ExampleInstrumentedTest.kt
├── proguard-rules.pro
└── build.gradle
├── settings.gradle
├── .idea
├── .gitignore
├── compiler.xml
├── vcs.xml
├── misc.xml
├── gradle.xml
└── jarRepositories.xml
├── screenshots
├── login.png
├── splash.png
├── create_account.png
└── reset_password.png
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── .gitignore
├── gradle.properties
├── README.md
├── gradlew.bat
└── gradlew
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | rootProject.name = "OpenChat"
2 | include ':app'
3 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/screenshots/login.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/screenshots/login.png
--------------------------------------------------------------------------------
/screenshots/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/screenshots/splash.png
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | OpenChat
3 |
--------------------------------------------------------------------------------
/screenshots/create_account.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/screenshots/create_account.png
--------------------------------------------------------------------------------
/screenshots/reset_password.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/screenshots/reset_password.png
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/res/font/roboto_black.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/font/roboto_black.ttf
--------------------------------------------------------------------------------
/app/src/main/res/font/roboto_bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/font/roboto_bold.ttf
--------------------------------------------------------------------------------
/app/src/main/res/font/roboto_light.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/font/roboto_light.ttf
--------------------------------------------------------------------------------
/app/src/main/res/font/roboto_thin.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/font/roboto_thin.ttf
--------------------------------------------------------------------------------
/app/src/main/res/font/ubuntu_bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/font/ubuntu_bold.ttf
--------------------------------------------------------------------------------
/app/src/main/res/font/ubuntu_light.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/font/ubuntu_light.ttf
--------------------------------------------------------------------------------
/app/src/debug/java/com/codingwithmitch/openchat/util/Constants.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.util
2 |
3 | const val DEBUG = true
--------------------------------------------------------------------------------
/app/src/main/res/font/roboto_italic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/font/roboto_italic.ttf
--------------------------------------------------------------------------------
/app/src/main/res/font/roboto_medium.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/font/roboto_medium.ttf
--------------------------------------------------------------------------------
/app/src/main/res/font/roboto_regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/font/roboto_regular.ttf
--------------------------------------------------------------------------------
/app/src/main/res/font/ubuntu_italic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/font/ubuntu_italic.ttf
--------------------------------------------------------------------------------
/app/src/main/res/font/ubuntu_medium.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/font/ubuntu_medium.ttf
--------------------------------------------------------------------------------
/app/src/main/res/font/ubuntu_regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/font/ubuntu_regular.ttf
--------------------------------------------------------------------------------
/app/src/main/res/font/roboto_bolditalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/font/roboto_bolditalic.ttf
--------------------------------------------------------------------------------
/app/src/main/res/font/roboto_thinitalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/font/roboto_thinitalic.ttf
--------------------------------------------------------------------------------
/app/src/main/res/font/ubuntu_bolditalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/font/ubuntu_bolditalic.ttf
--------------------------------------------------------------------------------
/app/src/release/java/com/codingwithmitch/openchat/util/Constants.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.util
2 |
3 | const val DEBUG = false
--------------------------------------------------------------------------------
/app/src/main/res/font/roboto_blackitalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/font/roboto_blackitalic.ttf
--------------------------------------------------------------------------------
/app/src/main/res/font/roboto_lightitalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/font/roboto_lightitalic.ttf
--------------------------------------------------------------------------------
/app/src/main/res/font/roboto_mediumitalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/font/roboto_mediumitalic.ttf
--------------------------------------------------------------------------------
/app/src/main/res/font/ubuntu_lightitalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/font/ubuntu_lightitalic.ttf
--------------------------------------------------------------------------------
/app/src/main/res/font/ubuntu_mediumitalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/font/ubuntu_mediumitalic.ttf
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/logo_250_250_transparent.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitchtabian/OpenChat/HEAD/app/src/main/res/drawable/logo_250_250_transparent.png
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/framework/presentation/Utils.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.framework.presentation
2 |
3 | const val TAG = "AppDebug"
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/business/domain/state/ViewState.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.business.domain.state
2 |
3 | interface ViewState {
4 | }
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/framework/presentation/UIController.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.framework.presentation
2 |
3 | interface UIController {
4 |
5 |
6 | }
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/business/data/cache/CacheConstants.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.business.data.cache
2 |
3 | object CacheConstants {
4 |
5 | const val CACHE_TIMEOUT = 2000L
6 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/business/data/cache/exceptions/CacheException.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.business.data.cache.exceptions
2 |
3 | open class CacheException(message: String): Exception(message)
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/business/data/network/exceptions/NetworkException.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.business.data.network.exceptions
2 |
3 | open class NetworkException(message: String): Exception(message)
--------------------------------------------------------------------------------
/app/src/release/java/com/codingwithmitch/openchat/EspressoIdlingResource.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat
2 |
3 | object EspressoIdlingResource {
4 |
5 | fun increment() {
6 | }
7 |
8 | fun decrement() {
9 | }
10 |
11 | fun clear(){
12 |
13 | }
14 | }
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Mon Nov 16 12:59:55 PST 2020
2 | distributionBase=GRADLE_USER_HOME
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-bin.zip
4 | distributionPath=wrapper/dists
5 | zipStorePath=wrapper/dists
6 | zipStoreBase=GRADLE_USER_HOME
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea/caches
5 | /.idea/libraries
6 | /.idea/modules.xml
7 | /.idea/workspace.xml
8 | /.idea/navEditor.xml
9 | /.idea/assetWizardSettings.xml
10 | .DS_Store
11 | /build
12 | /captures
13 | .externalNativeBuild
14 | .cxx
15 | local.properties
16 |
--------------------------------------------------------------------------------
/app/src/debug/java/com/codingwithmitch/openchat/EspressoIdlingResource.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat
2 |
3 |
4 | object EspressoIdlingResource {
5 |
6 |
7 | fun increment() {
8 | }
9 |
10 | fun decrement() {
11 | }
12 |
13 | fun clear() {
14 | }
15 | }
16 |
17 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/auth/framework/presentation/state/AuthStateEvent.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.auth.framework.presentation.state
2 |
3 | import com.codingwithmitch.openchat.common.business.domain.state.StateEvent
4 |
5 | sealed class AuthStateEvent: StateEvent {
6 |
7 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/business/data/util/GenericErrors.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.business.data.util
2 |
3 | object GenericErrors {
4 |
5 | const val ERROR_UNKNOWN = "Unknown error"
6 | const val INVALID_STATE_EVENT = "Invalid state event"
7 |
8 | }
9 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/business/domain/state/StateEvent.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.business.domain.state
2 |
3 | interface StateEvent {
4 |
5 | fun errorInfo(): String
6 |
7 | fun eventName(): String
8 |
9 | fun shouldDisplayProgressBar(): Boolean
10 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/business/domain/util/EntityMapper.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.business.domain.util
2 |
3 | interface EntityMapper {
4 |
5 | fun mapFromEntity(entity: Entity): DomainModel
6 |
7 | fun mapToEntity(domainModel: DomainModel): Entity
8 | }
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/business/data/network/NetworkConstants.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.business.data.network
2 |
3 | object NetworkConstants {
4 |
5 | const val NETWORK_TIMEOUT = 6000L
6 | const val RESPONSE_CODE_SUCCESS = "Success"
7 | const val RESPONSE_CODE_ERROR = "Error"
8 | }
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/framework/datasource/datastore/SessionPreferences.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.framework.datasource.datastore
2 |
3 | import androidx.datastore.preferences.preferencesKey
4 |
5 | object SessionPreferences {
6 |
7 | val KEY_ACCOUNT_PK = preferencesKey("account_pk")
8 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/business/data/network/AuthNetworkDataSource.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.business.data.network
2 |
3 | import com.codingwithmitch.openchat.session.business.domain.model.AuthToken
4 |
5 | interface AuthNetworkDataSource {
6 |
7 | suspend fun login(email: String, password: String): AuthToken
8 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/framework/datasource/network/SessionService.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.framework.datasource.network
2 |
3 | import com.codingwithmitch.openchat.session.business.domain.model.AuthToken
4 |
5 | interface SessionService {
6 |
7 | suspend fun login(email: String, password: String): AuthToken
8 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/framework/datasource/network/exceptions/AuthException.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.framework.datasource.network.exceptions
2 |
3 | import com.codingwithmitch.openchat.common.business.data.network.exceptions.NetworkException
4 |
5 | class AuthException(message: String) : NetworkException(message)
6 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/business/data/cache/CacheResult.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.business.data.cache
2 |
3 | sealed class CacheResult {
4 |
5 | data class Success(val value: T): CacheResult()
6 |
7 | data class GenericError(
8 | val errorMessage: String? = null
9 | ): CacheResult()
10 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/framework/presentation/navigation/Navigation.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.framework.presentation.navigation
2 |
3 |
4 | interface Screen {
5 |
6 | fun name(): String
7 | }
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/account/business/domain/model/Account.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.account.business.domain.model
2 |
3 | class Account (
4 | val id: Int,
5 | val email: String,
6 | val username: String = "",
7 | val profileImage: String = "",
8 | val hideEmail: Boolean = true,
9 | )
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/business/data/cache/CacheErrors.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.business.data.cache
2 |
3 | object CacheErrors {
4 |
5 | const val CACHE_ERROR_UNKNOWN = "Unknown cache error"
6 | const val CACHE_ERROR = "Cache error"
7 | const val CACHE_ERROR_TIMEOUT = "Cache timeout"
8 | const val CACHE_DATA_NULL = "Cache data is null"
9 |
10 | }
--------------------------------------------------------------------------------
/app/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 16dp
4 |
5 | 10dp
6 | 4dp
7 |
8 | 16dp
9 | 4dp
10 | 8dp
11 |
--------------------------------------------------------------------------------
/app/src/test/java/com/codingwithmitch/openchat/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/framework/presentation/theme/Shape.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.framework.presentation.theme
2 |
3 | import androidx.compose.foundation.shape.RoundedCornerShape
4 | import androidx.compose.material.Shapes
5 | import androidx.compose.ui.unit.dp
6 |
7 | val AppShapes = Shapes(
8 | small = RoundedCornerShape(4.dp),
9 | medium = RoundedCornerShape(6.dp),
10 | large = RoundedCornerShape(8.dp)
11 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/framework/presentation/SessionState.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.framework.presentation
2 |
3 | import android.os.Parcelable
4 | import com.codingwithmitch.openchat.session.business.domain.model.AuthToken
5 | import com.codingwithmitch.openchat.common.business.domain.state.ViewState
6 | import kotlinx.android.parcel.Parcelize
7 |
8 | @Parcelize
9 | class SessionState(
10 | var authToken: AuthToken? = null
11 | ): ViewState, Parcelable
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/business/domain/model/AuthToken.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.business.domain.model
2 |
3 | import android.os.Parcelable
4 | import kotlinx.android.parcel.Parcelize
5 | import java.util.*
6 |
7 | @Parcelize
8 | data class AuthToken(
9 | var accountId: Int? = null,
10 | var token: String? = null,
11 | var timestamp: Date? = null,
12 | ) : Parcelable
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/business/data/network/ApiResult.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.business.data.network
2 |
3 | sealed class ApiResult {
4 |
5 | data class Success(val value: T): ApiResult()
6 |
7 | data class GenericError(
8 | val code: Int? = null,
9 | val errorMessage: String? = null
10 | ): ApiResult()
11 |
12 | data class NetworkError(
13 | val errorMessage: String? = null
14 | ): ApiResult()
15 | }
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/business/data/cache/AuthCacheDataSource.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.business.data.cache
2 |
3 | import com.codingwithmitch.openchat.account.business.domain.model.Account
4 | import com.codingwithmitch.openchat.session.business.domain.model.AuthToken
5 |
6 | interface AuthCacheDataSource {
7 |
8 | suspend fun getTokens(): List
9 |
10 | suspend fun insertToken(authToken: AuthToken): Long
11 |
12 | suspend fun insertAccount(account: Account): Long
13 |
14 | suspend fun deleteTokens()
15 |
16 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/compose_view.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/framework/datasource/cache/AuthTokenDaoService.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.framework.datasource.cache
2 |
3 | import com.codingwithmitch.openchat.account.business.domain.model.Account
4 | import com.codingwithmitch.openchat.session.business.domain.model.AuthToken
5 |
6 | interface AuthTokenDaoService {
7 |
8 | suspend fun getAuthTokens(): List
9 |
10 | suspend fun insertToken(authToken: AuthToken): Long
11 |
12 | suspend fun insertAccount(account: Account): Long
13 |
14 | suspend fun deleteTokens()
15 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/framework/presentation/BaseApplication.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.framework.presentation
2 |
3 | import android.app.Application
4 | import androidx.compose.runtime.getValue
5 | import androidx.compose.runtime.mutableStateOf
6 | import androidx.compose.runtime.setValue
7 | import dagger.hilt.android.HiltAndroidApp
8 |
9 |
10 | @HiltAndroidApp
11 | class BaseApplication : Application(){
12 |
13 | var isLight by mutableStateOf(true)
14 |
15 | fun toggleLightTheme(){
16 | isLight = !isLight
17 | }
18 | }
19 |
20 |
21 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/business/data/network/AuthNetworkDataSourceImpl.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.business.data.network
2 |
3 | import com.codingwithmitch.openchat.session.business.domain.model.AuthToken
4 | import com.codingwithmitch.openchat.session.framework.datasource.network.SessionService
5 |
6 | class AuthNetworkDataSourceImpl(
7 | private val sessionService: SessionService
8 | ): AuthNetworkDataSource{
9 |
10 | override suspend fun login(email: String, password: String): AuthToken {
11 | return sessionService.login(email, password)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_baseline_visibility_on_24.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/framework/datasource/network/model/AuthTokenNetworkEntity.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.framework.datasource.network.model
2 |
3 | import com.google.gson.annotations.SerializedName
4 |
5 | class AuthTokenNetworkEntity(
6 |
7 | @SerializedName("account_id")
8 | var accountId: Int? = null,
9 |
10 | @SerializedName("response_code")
11 | var responseCode: String? = null,
12 |
13 | @SerializedName("login_response")
14 | var loginResponse: String? = null,
15 |
16 | @SerializedName("token")
17 | var token: String? = null,
18 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/framework/datasource/network/retrofit/AuthRetrofitService.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.framework.datasource.network.retrofit
2 |
3 | import com.codingwithmitch.openchat.session.framework.datasource.network.model.AuthTokenNetworkEntity
4 | import retrofit2.http.Field
5 | import retrofit2.http.FormUrlEncoded
6 | import retrofit2.http.POST
7 |
8 | interface AuthRetrofitService {
9 |
10 | @POST("login")
11 | @FormUrlEncoded
12 | suspend fun login(
13 | @Field("username") email: String,
14 | @Field("password") password: String
15 | ): AuthTokenNetworkEntity
16 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/framework/presentation/state/TextEmailState.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.framework.presentation.state
2 |
3 | import com.codingwithmitch.openchat.common.business.domain.util.ProjectRegex
4 | import java.util.regex.Pattern
5 |
6 | open class TextEmailState(
7 | value: String
8 | ): TextFieldState(){
9 |
10 | init {
11 | text = value
12 | }
13 |
14 | override fun getLabel(): String {
15 | return "Email"
16 | }
17 |
18 | override fun isValid(): Boolean {
19 | return Pattern.matches(ProjectRegex.EMAIL_VALIDATION_REGEX, text)
20 | }
21 |
22 | override fun getErrorMessage(): String {
23 | return "Invalid email: $text"
24 | }
25 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/framework/presentation/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.framework.presentation
2 |
3 | import androidx.appcompat.app.AppCompatActivity
4 | import android.os.Bundle
5 | import com.codingwithmitch.openchat.R
6 | import dagger.hilt.android.AndroidEntryPoint
7 | import kotlinx.coroutines.ExperimentalCoroutinesApi
8 |
9 | @ExperimentalCoroutinesApi
10 | @AndroidEntryPoint
11 | class MainActivity : AppCompatActivity(), UIController {
12 |
13 | override fun onCreate(savedInstanceState: Bundle?) {
14 | super.onCreate(savedInstanceState)
15 | setContentView(R.layout.activity_main)
16 |
17 | }
18 |
19 | }
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/app/src/main/res/navigation/main_graph.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
11 |
16 |
17 |
--------------------------------------------------------------------------------
/app/src/main/res/navigation/auth_graph.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
11 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/codingwithmitch/openchat/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("com.codingwithmitch.openchat", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
20 |
21 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/business/data/network/NetworkErrors.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.business.data.network
2 |
3 | object NetworkErrors {
4 |
5 | const val UNABLE_TO_RESOLVE_HOST = "Unable to resolve host"
6 | const val UNABLE_TODO_OPERATION_WO_INTERNET = "Can't do that operation without an internet connection"
7 | const val ERROR_CHECK_NETWORK_CONNECTION = "Check network connection."
8 | const val NETWORK_ERROR_UNKNOWN = "Unknown network error"
9 | const val NETWORK_ERROR = "Network error"
10 | const val NETWORK_ERROR_TIMEOUT = "Network timeout"
11 | const val NETWORK_DATA_NULL = "Network data is null"
12 |
13 |
14 | fun isNetworkError(msg: String): Boolean{
15 | when{
16 | msg.contains(UNABLE_TO_RESOLVE_HOST) -> return true
17 | else-> return false
18 | }
19 | }
20 | }
--------------------------------------------------------------------------------
/app/src/main/res/navigation/launch_graph.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
11 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | ## For more details on how to configure your build environment visit
2 | # http://www.gradle.org/docs/current/userguide/build_environment.html
3 | #
4 | # Specifies the JVM arguments used for the daemon process.
5 | # The setting is particularly useful for tweaking memory settings.
6 | # Default value: -Xmx1024m -XX:MaxPermSize=256m
7 | # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
8 | #
9 | # When configured, Gradle will run in incubating parallel mode.
10 | # This option should only be used with decoupled projects. More details, visit
11 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
12 | # org.gradle.parallel=true
13 | #Fri Nov 20 14:46:11 PST 2020
14 | kotlin.code.style=official
15 | #org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M"
16 | android.useAndroidX=true
17 |
--------------------------------------------------------------------------------
/app/src/main/res/values-night/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
16 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/framework/presentation/state/TextUsernameState.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.framework.presentation.state
2 |
3 | import com.codingwithmitch.openchat.common.business.domain.util.ProjectRegex
4 | import com.codingwithmitch.openchat.common.business.domain.util.ProjectRegex.USERNAME_VALIDATION_INFO
5 | import java.util.regex.Pattern
6 |
7 | open class TextUsernameState(
8 | value: String
9 | ): TextFieldState(){
10 |
11 | init {
12 | text = value
13 | }
14 |
15 | override fun getLabel(): String {
16 | return "Username"
17 | }
18 |
19 | override fun isValid(): Boolean {
20 | return Pattern.matches(ProjectRegex.USERNAME_VALIDATION_REGEX, text)
21 | }
22 |
23 | override fun getErrorMessage(): String {
24 | return USERNAME_VALIDATION_INFO
25 | }
26 | }
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/account/framework/datasource/cache/database/AccountDao.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.account.framework.datasource.cache.database
2 |
3 | import androidx.room.Dao
4 | import androidx.room.Insert
5 | import androidx.room.OnConflictStrategy
6 | import androidx.room.Query
7 | import com.codingwithmitch.openchat.account.framework.datasource.cache.model.AccountCacheEntity
8 |
9 | @Dao
10 | interface AccountDao {
11 |
12 | // Insert a new token into the cache
13 | @Insert(onConflict = OnConflictStrategy.REPLACE)
14 | suspend fun insertAccount(account: AccountCacheEntity): Long
15 |
16 | // There should only be one token at any given time.
17 | @Query("SELECT * FROM account WHERE id = :id")
18 | suspend fun getAccount(id: Int): AccountCacheEntity
19 |
20 | }
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/account/framework/datasource/cache/model/AccountCacheEntity.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.account.framework.datasource.cache.model
2 |
3 | import androidx.room.ColumnInfo
4 | import androidx.room.Entity
5 | import androidx.room.PrimaryKey
6 |
7 |
8 | /**
9 | * This entity is part of [AuthDatabase]
10 | */
11 | @Entity(tableName = "account")
12 | data class AccountCacheEntity(
13 |
14 | @PrimaryKey(autoGenerate = false)
15 | @ColumnInfo(name = "id")
16 | var id: Int,
17 |
18 | @ColumnInfo(name = "email")
19 | var email: String,
20 |
21 | @ColumnInfo(name = "username")
22 | var username: String,
23 |
24 | @ColumnInfo(name = "profile_image")
25 | var profileImage: String,
26 |
27 | @ColumnInfo(name = "hide_email")
28 | var hideEmail: Boolean,
29 |
30 | )
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_baseline_visibility_off_24.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
20 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
22 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/framework/datasource/cache/database/AuthTokenDao.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.framework.datasource.cache.database
2 |
3 | import androidx.room.Dao
4 | import androidx.room.Insert
5 | import androidx.room.OnConflictStrategy
6 | import androidx.room.Query
7 | import com.codingwithmitch.openchat.session.framework.datasource.cache.model.AuthTokenCacheEntity
8 |
9 | @Dao
10 | interface AuthTokenDao {
11 |
12 | // Insert a new token into the cache
13 | @Insert(onConflict = OnConflictStrategy.REPLACE)
14 | suspend fun insertToken(authToken: AuthTokenCacheEntity): Long
15 |
16 | // There should only be one token at any given time.
17 | @Query("SELECT * FROM auth_token")
18 | suspend fun getAuthToken(): List
19 |
20 | // Delete all tokens from cache (Should only be one)
21 | @Query("DELETE FROM auth_token")
22 | suspend fun deleteTokens()
23 |
24 | }
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/framework/datasource/cache/database/AppDatabase.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.framework.datasource.cache.database
2 |
3 | import androidx.room.Database
4 | import androidx.room.RoomDatabase
5 | import com.codingwithmitch.openchat.account.framework.datasource.cache.model.AccountCacheEntity
6 | import com.codingwithmitch.openchat.account.framework.datasource.cache.database.AccountDao
7 | import com.codingwithmitch.openchat.session.framework.datasource.cache.database.AuthTokenDao
8 | import com.codingwithmitch.openchat.session.framework.datasource.cache.model.AuthTokenCacheEntity
9 |
10 | @Database(
11 | entities = [
12 | AuthTokenCacheEntity::class,
13 | AccountCacheEntity::class
14 | ],
15 | version = 1,
16 | exportSchema = false
17 | )
18 | abstract class AppDatabase: RoomDatabase() {
19 |
20 | abstract fun authDao(): AuthTokenDao
21 |
22 | abstract fun accountDao(): AccountDao
23 |
24 | companion object{
25 | val DATABASE_NAME: String = "app_db"
26 | }
27 |
28 |
29 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/business/data/cache/AuthCacheDataSourceImpl.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.business.data.cache
2 |
3 | import com.codingwithmitch.openchat.account.business.domain.model.Account
4 | import com.codingwithmitch.openchat.session.business.domain.model.AuthToken
5 | import com.codingwithmitch.openchat.session.framework.datasource.cache.AuthTokenDaoService
6 |
7 | class AuthCacheDataSourceImpl(
8 | private val authTokenDaoService: AuthTokenDaoService
9 | ) : AuthCacheDataSource {
10 |
11 | override suspend fun getTokens(): List {
12 | return authTokenDaoService.getAuthTokens()
13 | }
14 |
15 | override suspend fun insertToken(authToken: AuthToken): Long {
16 | return authTokenDaoService.insertToken(authToken)
17 | }
18 |
19 | override suspend fun insertAccount(account: Account): Long {
20 | return authTokenDaoService.insertAccount(account)
21 | }
22 |
23 | override suspend fun deleteTokens() {
24 | return authTokenDaoService.deleteTokens()
25 | }
26 |
27 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/account/di/AccountCacheModule.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.account.di
2 |
3 | import com.codingwithmitch.openchat.account.framework.datasource.cache.mappers.AccountCacheMapper
4 | import com.codingwithmitch.openchat.account.framework.datasource.cache.database.AccountDao
5 | import com.codingwithmitch.openchat.common.framework.datasource.cache.database.AppDatabase
6 | import dagger.Module
7 | import dagger.Provides
8 | import dagger.hilt.InstallIn
9 | import dagger.hilt.android.components.ApplicationComponent
10 | import javax.inject.Singleton
11 |
12 | @Module
13 | @InstallIn(ApplicationComponent::class)
14 | object AccountCacheModule {
15 |
16 | @Singleton
17 | @Provides
18 | fun provideAccountCacheMapper(): AccountCacheMapper {
19 | return AccountCacheMapper()
20 | }
21 |
22 | @Singleton
23 | @Provides
24 | fun provideAccountDao(db: AppDatabase): AccountDao {
25 | return db.accountDao()
26 | }
27 |
28 |
29 | }
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/.idea/jarRepositories.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/business/domain/util/Logger.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.business.domain.util
2 |
3 |
4 | import android.util.Log
5 | import com.codingwithmitch.openchat.common.framework.presentation.TAG
6 | import com.codingwithmitch.openchat.util.DEBUG
7 |
8 | var isUnitTest = false
9 |
10 |
11 | fun printLogD(className: String?, message: String ) {
12 | if (DEBUG && !isUnitTest) {
13 | Log.d(TAG, "$className: $message")
14 | }
15 | else if(DEBUG && isUnitTest){
16 | println("$className: $message")
17 | }
18 | }
19 |
20 | fun printError(className: String?, message: String?){
21 | Log.e(TAG, "$className: $message")
22 | }
23 |
24 | /*
25 | Priorities: Log.DEBUG, Log. etc....
26 | */
27 | fun cLog(className: String? = null, message: String?){
28 | if(!DEBUG){
29 | // TODO("Setup Crashlytics and log")
30 | // FirebaseCrashlytics.getInstance().log(it)
31 | }else{
32 | printError(className = className, message = message)
33 | }
34 | }
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/framework/datasource/cache/model/AuthTokenCacheEntity.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.framework.datasource.cache.model
2 |
3 | import androidx.room.ColumnInfo
4 | import androidx.room.Entity
5 | import androidx.room.ForeignKey
6 | import androidx.room.ForeignKey.CASCADE
7 | import androidx.room.PrimaryKey
8 | import com.codingwithmitch.openchat.account.framework.datasource.cache.model.AccountCacheEntity
9 |
10 | @Entity(
11 | tableName = "auth_token",
12 | foreignKeys = [
13 | ForeignKey(
14 | entity = AccountCacheEntity::class,
15 | parentColumns = ["id"],
16 | childColumns = ["account_id"],
17 | onDelete = CASCADE
18 | )
19 | ]
20 | )
21 | data class AuthTokenCacheEntity(
22 |
23 | @PrimaryKey
24 | @ColumnInfo(name = "account_id")
25 | var account_id: Int,
26 |
27 | @ColumnInfo(name = "token")
28 | var token: String,
29 |
30 | @ColumnInfo(name = "timestamp")
31 | var timestamp: Long,
32 |
33 | )
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/framework/presentation/state/TextPasswordState.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.framework.presentation.state
2 |
3 | import androidx.compose.runtime.getValue
4 | import androidx.compose.runtime.mutableStateOf
5 | import androidx.compose.runtime.setValue
6 | import com.codingwithmitch.openchat.common.business.domain.util.ProjectRegex.PASSWORD_VALIDATION_INFO
7 | import com.codingwithmitch.openchat.common.business.domain.util.ProjectRegex.PASSWORD_VALIDATION_REGEX
8 | import java.util.regex.Pattern
9 |
10 | open class TextPasswordState(
11 | value: String,
12 | isShowing: Boolean = false,
13 | ): TextFieldState(){
14 |
15 | var showPassword: Boolean by mutableStateOf(false)
16 |
17 | init {
18 | text = value
19 | showPassword = isShowing
20 | }
21 |
22 | override fun getLabel(): String {
23 | return "Password"
24 | }
25 |
26 | override fun isValid(): Boolean {
27 | return Pattern.matches(PASSWORD_VALIDATION_REGEX, text)
28 | }
29 |
30 | override fun getErrorMessage(): String {
31 | return PASSWORD_VALIDATION_INFO
32 | }
33 | }
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/business/domain/state/DataState.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.business.domain.state
2 |
3 | data class DataState(
4 | var stateMessage: StateMessage? = null,
5 | var data: T? = null,
6 | var stateEvent: StateEvent? = null
7 | ) {
8 |
9 | companion object {
10 |
11 | fun error(
12 | response: Response,
13 | stateEvent: StateEvent?
14 | ): DataState {
15 | return DataState(
16 | stateMessage = StateMessage(
17 | response
18 | ),
19 | data = null,
20 | stateEvent = stateEvent
21 | )
22 | }
23 |
24 | fun data(
25 | response: Response?,
26 | data: T? = null,
27 | stateEvent: StateEvent?
28 | ): DataState {
29 | return DataState(
30 | stateMessage = response?.let {
31 | StateMessage(
32 | it
33 | )
34 | },
35 | data = data,
36 | stateEvent = stateEvent
37 | )
38 | }
39 | }
40 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/business/domain/util/DateUtil.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.business.domain.util
2 |
3 | import java.lang.Exception
4 | import java.text.SimpleDateFormat
5 | import java.util.*
6 | import javax.inject.Inject
7 |
8 |
9 | class DateUtil
10 | @Inject
11 | constructor(
12 | private val dateFormat: SimpleDateFormat
13 | ){
14 |
15 | /**
16 | * Convert date string from server into Date
17 | * Ex: "2020-11-19 23:32:12.179342+00:00" to Date
18 | */
19 |
20 | fun serverDateStringToDate(dateString: String): Date? {
21 | return try {
22 | dateFormat.parse(dateString)
23 | }catch (e: Exception){
24 | return null
25 | }
26 | }
27 |
28 |
29 | /**
30 | * Convert date long from cache into Date
31 | * Ex: 5325225235 to Date
32 | */
33 | fun dateLongToDate(dateLong: Long): Date?{
34 | return try {
35 | Date(dateLong)
36 | }catch (e: Exception){
37 | return null
38 | }
39 | }
40 |
41 | fun dateToLong(date: Date): Long{
42 | return date.time
43 | }
44 |
45 | fun generateTimestamp(): Date{
46 | return Date()
47 | }
48 | }
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Work in progress...
2 | Incomplete
3 |
4 | # Android app to communicate with Open-Chat.xyz
5 | Planning the code for android app that will communicate with [open-chat.xyz](https://open-chat.xyz).
6 |
7 | # Features
8 | 1. Kotlin
9 | 2. Clean architecture
10 | 3. MVI
11 | 4. Jetpack Compose
12 | 5. Flow and State Flow
13 | 6. Hilt
14 | 7. Sockets (Probably OkHTTP)
15 | 8. Notifications
16 | 1. Background
17 | 2. Foreground
18 | 3. Closed
19 | 9. Navigation Component
20 | 10. Hilt
21 | 11. REST API (Django REST framework)
22 | 1. User Management (login, registration, password reset)
23 | 1. Account Properties (profile image, username, email, password)
24 |
25 |
26 |
27 |
28 |
29 | # Screens
30 |
31 | ## Auth Screens
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | #### More coming soon...
44 |
45 |
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/account/framework/datasource/cache/mappers/AccountCacheMapper.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.account.framework.datasource.cache.mappers
2 |
3 | import com.codingwithmitch.openchat.account.business.domain.model.Account
4 | import com.codingwithmitch.openchat.account.framework.datasource.cache.model.AccountCacheEntity
5 | import com.codingwithmitch.openchat.common.business.domain.util.EntityMapper
6 |
7 | class AccountCacheMapper
8 | constructor(
9 | ): EntityMapper {
10 |
11 | override fun mapFromEntity(entity: AccountCacheEntity): Account {
12 | return Account(
13 | id = entity.id,
14 | email = entity.email,
15 | username = entity.username,
16 | profileImage = entity.profileImage,
17 | hideEmail = entity.hideEmail
18 | )
19 | }
20 |
21 | override fun mapToEntity(domainModel: Account): AccountCacheEntity {
22 | return AccountCacheEntity(
23 | id = domainModel.id,
24 | email = domainModel.email,
25 | username = domainModel.username,
26 | profileImage = domainModel.profileImage,
27 | hideEmail = domainModel.hideEmail
28 | )
29 | }
30 |
31 | }
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/framework/datasource/network/mappers/SessionNetworkMapper.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.framework.datasource.network.mappers
2 |
3 | import com.codingwithmitch.openchat.session.business.domain.model.AuthToken
4 | import com.codingwithmitch.openchat.session.framework.datasource.network.model.AuthTokenNetworkEntity
5 | import com.codingwithmitch.openchat.common.business.domain.util.DateUtil
6 | import com.codingwithmitch.openchat.common.business.domain.util.EntityMapper
7 |
8 | class SessionNetworkMapper
9 | constructor(
10 | private val dateUtil: DateUtil
11 | ): EntityMapper {
12 |
13 | override fun mapFromEntity(entity: AuthTokenNetworkEntity): AuthToken {
14 | return AuthToken(
15 | accountId = entity.accountId,
16 | token = entity.token,
17 | timestamp = dateUtil.generateTimestamp()
18 | )
19 | }
20 |
21 | /**
22 | * This is not used
23 | */
24 | override fun mapToEntity(domainModel: AuthToken): AuthTokenNetworkEntity {
25 | return AuthTokenNetworkEntity(
26 | accountId = domainModel.accountId,
27 | token = domainModel.token,
28 | responseCode = "who cares",
29 | loginResponse = "also who cares"
30 | )
31 | }
32 |
33 | }
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/framework/presentation/components/TextFieldError.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.framework.presentation.components
2 |
3 | import androidx.compose.foundation.layout.Row
4 | import androidx.compose.foundation.layout.Spacer
5 | import androidx.compose.foundation.layout.fillMaxWidth
6 | import androidx.compose.foundation.layout.preferredWidth
7 | import androidx.compose.material.AmbientTextStyle
8 | import androidx.compose.material.MaterialTheme
9 | import androidx.compose.material.Text
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.unit.dp
13 |
14 | /**
15 | * Got from:
16 | * https://github.com/android/compose-samples/blob/e1ab50d935be6d2062d4defed3d610026dde4e2d/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInSignUp.kt#L215
17 | *
18 | * WARNING:
19 | * This will be removed later when TextFields support errors.
20 | */
21 | @Composable
22 | fun TextFieldError(
23 | textError: String
24 | ) {
25 | Row(modifier = Modifier.fillMaxWidth()) {
26 | Spacer(modifier = Modifier.preferredWidth(16.dp))
27 | Text(
28 | text = textError,
29 | modifier = Modifier.fillMaxWidth(),
30 | style = AmbientTextStyle.current.copy(color = MaterialTheme.colors.error)
31 | )
32 | }
33 | }
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/framework/presentation/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.framework.presentation.theme
2 |
3 | import androidx.compose.ui.graphics.Color
4 |
5 | val Purple200 = Color(0xFFBB86FC)
6 | val Purple500 = Color(0xFF6200EE)
7 |
8 | val Blue300 = Color(0xFF64B5F6)
9 | val Blue400 = Color(0xFFFFFFFF)
10 | val Blue500 = Color(0xFF2196F3)
11 | val Blue600 = Color(0xFF1E88E5)
12 | val Blue700 = Color(0xFF1976D2)
13 | val Blue800 = Color(0xFF1565C0)
14 |
15 | val Orange300 = Color(0xFFFFB74D)
16 | val Orange400 = Color(0xFFFFA726)
17 | val Orange500 = Color(0xFFFF9800)
18 | val Orange600 = Color(0xFFFB8C00)
19 | val Orange700 = Color(0xFFF57C00)
20 | val Orange800 = Color(0xFFEF6C00)
21 |
22 | val Grey1 = Color(0xFFF2F2F2)
23 | val Grey2 = Color(0xFFE2E2E2)
24 | val Grey3 = Color(0xFFD2D2D2)
25 | val Grey4 = Color(0xFFC2C2C2)
26 | val Grey5 = Color(0xFFB2B2B2)
27 | val Grey6 = Color(0xFFA2A2A2)
28 |
29 | val GreyDark1 = Color(0xFF999999)
30 | val GreyDark2 = Color(0xFF888888)
31 | val GreyDark3 = Color(0xFF777777)
32 | val GreyDark4 = Color(0xFF666666)
33 | val GreyDark5 = Color(0xFF555555)
34 | val GreyDark6 = Color(0xFF444444)
35 |
36 | val Black1 = Color(0xFF444444)
37 | val Black2 = Color(0xFF333333)
38 | val Black3 = Color(0xFF222222)
39 | val Black4 = Color(0xFF111111)
40 | val Black5 = Color(0xFF000000)
41 |
42 |
43 |
44 | val RedErrorDark = Color(0xFFB00020)
45 | val RedErrorLight = Color(0xFFEF5350)
46 |
47 |
48 |
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/di/AppModule.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.di
2 |
3 | import android.content.Context
4 | import androidx.room.Room
5 | import com.codingwithmitch.openchat.common.business.domain.util.DateUtil
6 | import com.codingwithmitch.openchat.common.framework.datasource.cache.database.AppDatabase
7 | import dagger.Module
8 | import dagger.Provides
9 | import dagger.hilt.InstallIn
10 | import dagger.hilt.android.components.ApplicationComponent
11 | import dagger.hilt.android.qualifiers.ApplicationContext
12 | import java.text.SimpleDateFormat
13 | import java.util.*
14 | import javax.inject.Singleton
15 |
16 | @Module
17 | @InstallIn(ApplicationComponent::class)
18 | object AppModule {
19 |
20 | @Singleton
21 | @Provides
22 | fun provideDateFormat(): SimpleDateFormat {
23 | val sdf = SimpleDateFormat("yyyy-MM-dd hh:mm:ss a", Locale.ENGLISH)
24 | sdf.timeZone = TimeZone.getTimeZone("UTC") // match server
25 | return sdf
26 | }
27 |
28 | @Singleton
29 | @Provides
30 | fun provideDateUtil(dateFormat: SimpleDateFormat): DateUtil {
31 | return DateUtil(dateFormat)
32 | }
33 |
34 | @Singleton
35 | @Provides
36 | fun provideAppDb(@ApplicationContext app: Context): AppDatabase {
37 | return Room
38 | .databaseBuilder(app, AppDatabase::class.java, AppDatabase.DATABASE_NAME)
39 | .fallbackToDestructiveMigration()
40 | .build()
41 | }
42 | }
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/auth/framework/presentation/navigation/AuthNavigation.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.auth.framework.presentation.navigation
2 |
3 | import com.codingwithmitch.openchat.auth.framework.presentation.navigation.AuthScreenName.*
4 | import com.codingwithmitch.openchat.common.framework.presentation.navigation.Screen
5 |
6 | enum class AuthScreenName{
7 | LOGIN,
8 | CREATE_ACCOUNT,
9 | PASSWORD_RESET,
10 | }
11 |
12 | fun toAuthScreen(name: String): AuthScreen {
13 | return when(name){
14 | LOGIN.name ->{
15 | AuthScreen.Login
16 | }
17 | CREATE_ACCOUNT.name ->{
18 | AuthScreen.CreateAccount
19 | }
20 | PASSWORD_RESET.name ->{
21 | AuthScreen.PasswordReset
22 | }
23 | else -> AuthScreen.Login
24 | }
25 | }
26 |
27 | /**
28 | * Class defining the screens we have in the app: login, Create account and Password reset
29 | */
30 | sealed class AuthScreen: Screen {
31 |
32 | object Login : AuthScreen() {
33 | override fun name(): String {
34 | return LOGIN.name
35 | }
36 | }
37 |
38 | object CreateAccount : AuthScreen() {
39 | override fun name(): String {
40 | return CREATE_ACCOUNT.name
41 | }
42 | }
43 |
44 | object PasswordReset : AuthScreen() {
45 | override fun name(): String {
46 | return PASSWORD_RESET.name
47 | }
48 | }
49 | }
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/framework/presentation/SessionStateEvent.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.framework.presentation
2 |
3 | import com.codingwithmitch.openchat.common.business.domain.state.StateEvent
4 |
5 | sealed class SessionStateEvent: StateEvent{
6 |
7 | class LogoutEvent: SessionStateEvent() {
8 |
9 | override fun errorInfo(): String {
10 | return "Error logging out."
11 | }
12 |
13 | override fun eventName(): String {
14 | return "LogoutEvent"
15 | }
16 |
17 | override fun shouldDisplayProgressBar(): Boolean {
18 | return true
19 | }
20 | }
21 |
22 | data class LoginEvent(
23 | val email: String,
24 | val password: String
25 | ): SessionStateEvent() {
26 |
27 | override fun errorInfo(): String {
28 | return "Error trying to login."
29 | }
30 |
31 | override fun eventName(): String {
32 | return "LoginEvent"
33 | }
34 |
35 | override fun shouldDisplayProgressBar(): Boolean {
36 | return true
37 | }
38 | }
39 |
40 | data class CheckAuthTokenEvent(
41 | var accountPk: Int
42 | ): SessionStateEvent() {
43 |
44 | override fun errorInfo(): String {
45 | return "Error checking for a cached AuthToken."
46 | }
47 |
48 | override fun eventName(): String {
49 | return "CheckAuthTokenEvent"
50 | }
51 |
52 | override fun shouldDisplayProgressBar(): Boolean {
53 | return true
54 | }
55 | }
56 |
57 |
58 | }
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/business/domain/util/ProjectRegex.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.business.domain.util
2 |
3 | object ProjectRegex{
4 |
5 | /*
6 | https://stackoverflow.com/questions/23214434/regular-expression-in-android-for-password-field
7 | ^ # start-of-string
8 | (?=.*[0-9]) # a digit must occur at least once
9 | (?=.*[a-z]) # a lower case letter must occur at least once
10 | (?=.*[A-Z]) # an upper case letter must occur at least once
11 | (?=.*[!@#$%^&+=]) # a special character must occur at least once you can replace with your special characters
12 | (?=\\S+$) # no whitespace allowed in the entire string
13 | .{4,} # anything, at least six places though
14 | $ # end-of-string
15 | */
16 | const val PASSWORD_VALIDATION_REGEX = "^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!@#$%^&+=])(?=\\S+$).{4,}$"
17 |
18 | const val PASSWORD_VALIDATION_INFO =
19 | "A valid password must contain:" +
20 | "\nA number" +
21 | "\nA lowercase letter" +
22 | "\nA uppercase letter" +
23 | "\nA special character (!@#$%^&+=)" +
24 | "\nNo whitespace" +
25 | "\nAt least 6 characters"
26 |
27 |
28 | const val EMAIL_VALIDATION_REGEX = "^(.+)@(.+)\$"
29 |
30 | const val USERNAME_VALIDATION_REGEX = "^(?=\\S+$).{4,}$"
31 |
32 | const val USERNAME_VALIDATION_INFO =
33 | "A valid username must contain:" +
34 | "\nNo whitespace" +
35 | "\nAt least 6 characters"
36 |
37 | }
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/framework/presentation/components/CircularIndeterminateProgressBar.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.framework.presentation.components
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.ConstraintLayout
5 | import androidx.compose.foundation.layout.ConstraintSet
6 | import androidx.compose.foundation.layout.fillMaxHeight
7 | import androidx.compose.foundation.layout.fillMaxWidth
8 | import androidx.compose.material.CircularProgressIndicator
9 | import androidx.compose.material.MaterialTheme
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.graphics.Color
13 |
14 | /**
15 | * Center a circular indeterminate progress bar with optional vertical bias.
16 | */
17 | @Composable
18 | fun CircularIndeterminateProgressBar(isDisplayed: Boolean, verticalBias: Float){
19 | if(isDisplayed){
20 | ConstraintLayout(
21 | modifier = Modifier
22 | .fillMaxHeight()
23 | .fillMaxWidth(),
24 | ){
25 | val (progressBar) = createRefs()
26 | val topBias = createGuidelineFromTop(verticalBias)
27 | CircularProgressIndicator(
28 | modifier = Modifier
29 | .constrainAs(progressBar) {
30 | top.linkTo(topBias)
31 | end.linkTo(parent.end)
32 | start.linkTo(parent.start)
33 | },
34 | color = MaterialTheme.colors.secondary
35 | )
36 | }
37 |
38 | }
39 | }
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/business/domain/state/StateResource.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.business.domain.state
2 |
3 | import android.view.View
4 |
5 |
6 | data class StateMessage(val response: Response)
7 |
8 | data class Response(
9 | val message: String?,
10 | val uiComponentType: UIComponentType,
11 | val messageType: MessageType
12 | )
13 |
14 | sealed class UIComponentType{
15 |
16 | class Toast: UIComponentType()
17 |
18 | class Dialog: UIComponentType()
19 |
20 | class AreYouSureDialog(
21 | val callback: AreYouSureCallback
22 | ): UIComponentType()
23 |
24 | class SnackBar(
25 | val undoCallback: SnackbarUndoCallback? = null,
26 | val onDismissCallback: TodoCallback? = null
27 | ): UIComponentType()
28 |
29 | class None: UIComponentType()
30 | }
31 |
32 | sealed class MessageType{
33 |
34 | class Success: MessageType()
35 |
36 | class Error: MessageType()
37 |
38 | class Info: MessageType()
39 |
40 | class None: MessageType()
41 | }
42 |
43 |
44 | interface StateMessageCallback{
45 |
46 | fun removeMessageFromStack()
47 | }
48 |
49 |
50 | interface AreYouSureCallback {
51 |
52 | fun proceed()
53 |
54 | fun cancel()
55 | }
56 |
57 | interface SnackbarUndoCallback {
58 |
59 | fun undo()
60 | }
61 |
62 | class SnackbarUndoListener
63 | constructor(
64 | private val snackbarUndoCallback: SnackbarUndoCallback?
65 | ): View.OnClickListener {
66 |
67 | override fun onClick(v: View?) {
68 | snackbarUndoCallback?.undo()
69 | }
70 |
71 | }
72 |
73 |
74 | interface DialogInputCaptureCallback {
75 |
76 | fun onTextCaptured(text: String)
77 | }
78 |
79 | interface TodoCallback {
80 |
81 | fun execute()
82 | }
83 |
84 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/framework/datasource/network/SessionServiceImpl.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.framework.datasource.network
2 |
3 | import com.codingwithmitch.openchat.session.business.domain.model.AuthToken
4 | import com.codingwithmitch.openchat.session.framework.datasource.network.exceptions.AuthException
5 | import com.codingwithmitch.openchat.session.framework.datasource.network.mappers.SessionNetworkMapper
6 | import com.codingwithmitch.openchat.session.framework.datasource.network.retrofit.AuthRetrofitService
7 | import com.codingwithmitch.openchat.common.business.data.network.NetworkConstants.RESPONSE_CODE_ERROR
8 | import com.codingwithmitch.openchat.common.business.data.network.NetworkConstants.RESPONSE_CODE_SUCCESS
9 | import com.codingwithmitch.openchat.common.business.data.network.NetworkErrors
10 |
11 | class SessionServiceImpl
12 | constructor(
13 | private val authRetrofitService: AuthRetrofitService,
14 | private val mapper: SessionNetworkMapper,
15 | ): SessionService {
16 |
17 | /**
18 | * See this document for possible responses:
19 | * https://github.com/mitchtabian/Codingwithmitch-Chat/blob/token-authentication/account/api/documentation.md
20 | */
21 | override suspend fun login(email: String, password: String): AuthToken {
22 | val entity = authRetrofitService.login(email, password)
23 | return when(entity.responseCode){
24 |
25 | RESPONSE_CODE_SUCCESS -> {
26 | mapper.mapFromEntity(entity)
27 | }
28 |
29 | RESPONSE_CODE_ERROR -> {
30 | throw AuthException(entity.loginResponse?: NetworkErrors.NETWORK_ERROR_UNKNOWN)
31 | }
32 |
33 | else -> throw Exception(entity.loginResponse)
34 | }
35 | }
36 |
37 | }
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/framework/presentation/BaseMainFragment.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.framework.presentation
2 |
3 | import android.os.Bundle
4 | import android.view.View
5 | import androidx.fragment.app.Fragment
6 | import androidx.navigation.fragment.findNavController
7 | import com.codingwithmitch.openchat.R
8 | import com.codingwithmitch.openchat.session.framework.presentation.SessionManager
9 | import kotlinx.coroutines.ExperimentalCoroutinesApi
10 | import javax.inject.Inject
11 |
12 | /**
13 | * All fragments except [AuthFragment] will extend this class.
14 | *
15 | * This makes it simple to observe the Auth state. If the token becomes null then navigate
16 | * back to login screen.
17 | */
18 | @ExperimentalCoroutinesApi
19 | abstract class BaseMainFragment : Fragment(){
20 |
21 | @Inject
22 | lateinit var sessionManager: SessionManager
23 |
24 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
25 | super.onViewCreated(view, savedInstanceState)
26 | sessionManager.sessionState.observe(viewLifecycleOwner, {sessionState ->
27 | if(sessionState?.authToken == null){
28 |
29 | /**
30 | * Currently I can't think of a better way to do this. It's mostly great, other
31 | * than the SplashFragment shows for a second. But this is not a big deal.
32 | * Pros and cons...
33 | */
34 | findNavController()
35 | .createDeepLink()
36 | .setGraph(R.navigation.auth_graph)
37 | .setDestination(R.id.authFragment)
38 | .createPendingIntent()
39 | .send()
40 | }
41 | })
42 |
43 | }
44 | }
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/business/data/cache/CacheResponseHandler.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.business.data.cache
2 |
3 | import com.codingwithmitch.openchat.common.business.data.cache.CacheErrors.CACHE_DATA_NULL
4 | import com.codingwithmitch.openchat.common.business.domain.state.*
5 |
6 |
7 | abstract class CacheResponseHandler (
8 | private val response: CacheResult,
9 | private val stateEvent: StateEvent?
10 | ){
11 | suspend fun getResult(): DataState? {
12 |
13 | return when(response){
14 |
15 | is CacheResult.GenericError -> {
16 | DataState.error(
17 | response = Response(
18 | message = "${stateEvent?.errorInfo()}\n\nReason: ${response.errorMessage}",
19 | uiComponentType = UIComponentType.Dialog(),
20 | messageType = MessageType.Error()
21 | ),
22 | stateEvent = stateEvent
23 | )
24 | }
25 |
26 | is CacheResult.Success -> {
27 | if(response.value == null){
28 | DataState.error(
29 | response = Response(
30 | message = "${stateEvent?.errorInfo()}\n\nReason: ${CACHE_DATA_NULL}.",
31 | uiComponentType = UIComponentType.Dialog(),
32 | messageType = MessageType.Error()
33 | ),
34 | stateEvent = stateEvent
35 | )
36 | }
37 | else{
38 | handleSuccess(resultObj = response.value)
39 | }
40 | }
41 |
42 | }
43 | }
44 |
45 | abstract suspend fun handleSuccess(resultObj: Data): DataState?
46 |
47 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/splash/framework/presentation/SplashScreen.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.splash.framework.presentation
2 |
3 | import androidx.compose.foundation.Image
4 | import androidx.compose.foundation.background
5 | import androidx.compose.foundation.layout.*
6 | import androidx.compose.foundation.shape.RoundedCornerShape
7 | import androidx.compose.material.MaterialTheme
8 | import androidx.compose.material.Surface
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.graphics.Color
12 | import androidx.compose.ui.platform.ContextAmbient
13 | import androidx.compose.ui.res.imageResource
14 | import androidx.compose.ui.unit.dp
15 | import com.codingwithmitch.openchat.R
16 |
17 | @Composable
18 | fun SplashScreen(){
19 | ConstraintLayout(
20 | modifier = Modifier
21 | .fillMaxHeight()
22 | .fillMaxWidth()
23 | .background(color = MaterialTheme.colors.primary)
24 | ){
25 | val (icon) = createRefs()
26 | Surface(
27 | modifier = Modifier
28 | .width(80.dp)
29 | .height(80.dp)
30 | .constrainAs(icon) {
31 | top.linkTo(parent.top)
32 | bottom.linkTo(parent.bottom)
33 | end.linkTo(parent.end)
34 | start.linkTo(parent.start)
35 | },
36 | shape = RoundedCornerShape(ContextAmbient.current.resources.getDimension(R.dimen.default_corner_radius)),
37 | color = Color.White,
38 | elevation = ContextAmbient.current.resources.getDimension(R.dimen.default_elevation).dp,
39 | ) {
40 | Image(
41 | asset = imageResource(id = R.drawable.logo_250_250_transparent),
42 | modifier = Modifier
43 | .padding(4.dp),
44 |
45 | )
46 | }
47 | }
48 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/framework/presentation/state/TextFieldState.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.framework.presentation.state
2 |
3 | import androidx.compose.runtime.getValue
4 | import androidx.compose.runtime.mutableStateOf
5 | import androidx.compose.runtime.setValue
6 |
7 | /**
8 | * I got the idea for this from:
9 | * https://github.com/android/compose-samples/blob/e1ab50d935/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/TextFieldState.kt
10 | *
11 | * Function:
12 | * 1. Hold the field value
13 | * 2. Encapsulate the field validation
14 | * 3. Encapsulate the field errors
15 | **/
16 |
17 | abstract class TextFieldState(){
18 |
19 | var text: String by mutableStateOf("")
20 |
21 | // Was the text field ever focused
22 | var wasFocused: Boolean by mutableStateOf(false)
23 |
24 | // Is it currently focused
25 | var isFocused: Boolean by mutableStateOf(false)
26 |
27 | // Helper needed for determining if errors should be shown
28 | // (if currently focused -> disable. if not focused -> enable.
29 | private var displayErrors: Boolean by mutableStateOf(false)
30 |
31 | fun isErrors() = !isValid() && displayErrors
32 |
33 | open fun onFocusChange(focused: Boolean) {
34 | isFocused = focused
35 | if (focused) {
36 | wasFocused = true
37 | }
38 | }
39 |
40 | fun checkEnableShowErrors() {
41 | // only show errors if the text was at least once focused
42 | if (wasFocused) {
43 | displayErrors = true
44 | }
45 | }
46 |
47 | /**
48 | * Was this field ever focused?
49 | * if not, show error msg.
50 | */
51 | fun validate() {
52 | if(!wasFocused){
53 | wasFocused = true
54 | displayErrors = true
55 | }
56 | }
57 |
58 | abstract fun getLabel(): String
59 |
60 | abstract fun isValid(): Boolean
61 |
62 | abstract fun getErrorMessage(): String
63 |
64 | }
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
11 |
12 | #FF64B5F6
13 | #FF42A5F5
14 | #FF2196F3
15 | #FF1E88E5
16 | #FF1976D2
17 | #FF1565C0
18 |
19 | #FFFFB74D
20 | #FFFFA726
21 | #FFFF9800
22 | #FFFB8C00
23 | #FFF57C00
24 | #FFEF6C00
25 |
26 | #FFF2F2F2
27 | #FFE2E2E2
28 | #FFD2D2D2
29 | #FFC2C2C2
30 | #FFB2B2B2
31 | #FFA2A2A2
32 |
33 | #FF999999
34 | #FF888888
35 | #FF777777
36 | #FF666666
37 | #FF555555
38 |
39 | #444444
40 | #333333
41 | #222222
42 | #111111
43 | #000000
44 |
45 | #2196F3
46 |
47 | #B00020
48 | #EF5350
49 |
50 | #673AB7
51 | #5E35B1
52 | #512DA8
53 | #4527A0
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/business/interactors/Logout.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.business.interactors
2 |
3 | import androidx.datastore.DataStore
4 | import androidx.datastore.preferences.Preferences
5 | import androidx.datastore.preferences.edit
6 | import com.codingwithmitch.openchat.session.business.data.cache.AuthCacheDataSource
7 | import com.codingwithmitch.openchat.common.business.domain.state.*
8 | import com.codingwithmitch.openchat.common.business.domain.util.safeCacheCall
9 | import com.codingwithmitch.openchat.session.framework.datasource.datastore.SessionPreferences.KEY_ACCOUNT_PK
10 | import com.codingwithmitch.openchat.session.framework.presentation.SessionState
11 | import kotlinx.coroutines.Dispatchers.IO
12 | import kotlinx.coroutines.flow.Flow
13 | import kotlinx.coroutines.flow.flow
14 | import javax.inject.Named
15 |
16 | const val LOGOUT_SUCCESS = "com.codingwithmitch.openchat.session.business.interactors.LOGOUT_SUCCESS"
17 |
18 | class Logout (
19 | private val cacheDataSource: AuthCacheDataSource,
20 | @Named private val authPreferences: DataStore,
21 | ){
22 |
23 | fun execute(stateEvent: StateEvent): Flow?> = flow {
24 |
25 | // Delete token from cache
26 | safeCacheCall(IO){
27 | cacheDataSource.deleteTokens()
28 | }
29 |
30 | emit(
31 | DataState.data(
32 | response = Response(
33 | message = LOGOUT_SUCCESS,
34 | uiComponentType = UIComponentType.None(),
35 | messageType = MessageType.Info()
36 | ),
37 | data = SessionState(),
38 | stateEvent = stateEvent
39 | )
40 | )
41 |
42 | // Remove [Account.pk] from Datastore
43 | clearAccountPkFromPreferences()
44 | }
45 |
46 | private suspend fun clearAccountPkFromPreferences(){
47 | authPreferences.edit { preferences ->
48 | preferences[KEY_ACCOUNT_PK] = -1
49 | }
50 | }
51 | }
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/auth/framework/presentation/state/AuthBundle.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.auth.framework.presentation.state
2 |
3 | import android.os.Parcelable
4 | import com.codingwithmitch.openchat.auth.framework.presentation.navigation.AuthScreen
5 | import com.codingwithmitch.openchat.auth.framework.presentation.navigation.toAuthScreen
6 | import com.codingwithmitch.openchat.auth.framework.presentation.state.AuthViewState.*
7 | import com.codingwithmitch.openchat.auth.framework.presentation.state.AuthViewState.CreatePasswordState.*
8 | import kotlinx.android.parcel.Parcelize
9 |
10 | /**
11 | * For saving and restoring state after process death.
12 | */
13 |
14 | @Parcelize
15 | class AuthBundle (
16 | val loginEmail: String = "",
17 | val loginPassword: String = "",
18 | val passwordResetEmail: String = "",
19 | val createEmail: String = "",
20 | val createUsername: String = "",
21 | val createPassword1: String = "",
22 | val createPassword2: String = "",
23 | val screenName: String = AuthScreen.Login.name()
24 | ) : Parcelable
25 |
26 |
27 | fun AuthBundle.restoreViewState(): AuthViewState {
28 | return AuthViewState(
29 | loginEmailState = LoginEmailState(loginEmail),
30 | loginPasswordState = LoginPasswordState(loginPassword),
31 | passwordResetEmailState = PasswordResetEmailState(passwordResetEmail),
32 | createEmailState = CreateEmailState(createEmail),
33 | createUsernameState = CreateUsernameState(createUsername),
34 | createPasswordState = CreatePasswordState(
35 | Password1State(createPassword1),
36 | Password2State(createPassword2),
37 | ),
38 | screen = toAuthScreen(screenName)
39 | )
40 | }
41 |
42 | fun AuthViewState.toAuthBundle(): AuthBundle {
43 | return AuthBundle(
44 | loginEmail = loginEmailState.text,
45 | loginPassword = loginPasswordState.text,
46 | passwordResetEmail = passwordResetEmailState.text,
47 | createEmail = createEmailState.text,
48 | createUsername = createUsernameState.text,
49 | createPassword1 = createPasswordState.password1.text,
50 | createPassword2 = createPasswordState.password2.text,
51 | screenName = screen.name(),
52 | )
53 | }
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/framework/datasource/cache/mappers/AuthCacheMapper.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.framework.datasource.cache.mappers
2 |
3 | import com.codingwithmitch.openchat.session.business.domain.model.AuthToken
4 | import com.codingwithmitch.openchat.session.framework.datasource.cache.model.AuthTokenCacheEntity
5 | import com.codingwithmitch.openchat.common.business.domain.util.DateUtil
6 | import com.codingwithmitch.openchat.common.business.domain.util.EntityMapper
7 | import com.codingwithmitch.openchat.common.business.data.cache.exceptions.CacheException
8 |
9 | const val TOKEN_TIMESTAMP_NULL_EXCEPTION = "Token timestamp cannot be null."
10 |
11 | class AuthCacheMapper
12 | constructor(
13 | private val dateUtil: DateUtil
14 | ): EntityMapper {
15 |
16 | override fun mapFromEntity(entity: AuthTokenCacheEntity): AuthToken {
17 |
18 | return dateUtil.dateLongToDate(entity.timestamp)?.let { timestamp ->
19 | AuthToken(
20 | accountId = entity.account_id,
21 | token = entity.token,
22 | timestamp = timestamp
23 | )
24 | }?: throw NullPointerException(TOKEN_TIMESTAMP_NULL_EXCEPTION)
25 | }
26 |
27 | override fun mapToEntity(domainModel: AuthToken): AuthTokenCacheEntity {
28 | if(domainModel.accountId == null){
29 | throw CacheException("Account id must not be null.")
30 | }
31 | if(domainModel.token == null){
32 | throw CacheException("Token must not be null.")
33 | }
34 | if(domainModel.timestamp == null){
35 | throw CacheException("Timestamp must not be null.")
36 | }
37 | return AuthTokenCacheEntity(
38 | account_id = domainModel.accountId!!,
39 | token = domainModel.token!!,
40 | timestamp = dateUtil.dateToLong(domainModel.timestamp!!)
41 | )
42 | }
43 |
44 | fun mapFromEntityList(entities: List): List{
45 | return entities.map { mapFromEntity(it) }
46 | }
47 |
48 | fun mapToEntityList(domainModels: List): List{
49 | return domainModels.map { mapToEntity(it) }
50 |
51 | }
52 | }
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/framework/datasource/cache/AuthTokenDaoServiceImpl.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.framework.datasource.cache
2 |
3 | import com.codingwithmitch.openchat.account.business.domain.model.Account
4 | import com.codingwithmitch.openchat.account.framework.datasource.cache.mappers.AccountCacheMapper
5 | import com.codingwithmitch.openchat.session.business.domain.model.AuthToken
6 | import com.codingwithmitch.openchat.account.framework.datasource.cache.database.AccountDao
7 | import com.codingwithmitch.openchat.session.framework.datasource.cache.database.AuthTokenDao
8 | import com.codingwithmitch.openchat.session.framework.datasource.cache.mappers.AuthCacheMapper
9 | import com.codingwithmitch.openchat.session.framework.datasource.cache.model.AuthTokenCacheEntity
10 | import com.codingwithmitch.openchat.common.business.domain.util.DateUtil
11 | import com.codingwithmitch.openchat.common.business.data.cache.exceptions.CacheException
12 |
13 | class AuthTokenDaoServiceImpl
14 | constructor(
15 | private val authTokenDao: AuthTokenDao,
16 | private val accountDao: AccountDao,
17 | private val authCacheMapper: AuthCacheMapper,
18 | private val accountCacheMapper: AccountCacheMapper,
19 | private val dateUtil: DateUtil,
20 | ): AuthTokenDaoService {
21 |
22 | override suspend fun getAuthTokens(): List {
23 | return authCacheMapper.mapFromEntityList(authTokenDao.getAuthToken())
24 | }
25 |
26 | override suspend fun insertToken(authToken: AuthToken): Long {
27 | if(authToken.accountId == null){
28 | throw CacheException("Account id must not be null.")
29 | }
30 | if(authToken.token == null){
31 | throw CacheException("Token must not be null.")
32 | }
33 | return authTokenDao.insertToken(
34 | AuthTokenCacheEntity(
35 | account_id = authToken.accountId!!,
36 | token = authToken.token!!,
37 | timestamp = dateUtil.generateTimestamp().time,
38 | )
39 | )
40 | }
41 |
42 | override suspend fun insertAccount(account: Account): Long {
43 | return accountDao.insertAccount(accountCacheMapper.mapToEntity(account))
44 | }
45 |
46 | override suspend fun deleteTokens() {
47 | return authTokenDao.deleteTokens()
48 | }
49 |
50 | }
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/framework/presentation/components/EmailInputField.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.framework.presentation.components
2 |
3 | import androidx.compose.foundation.text.KeyboardOptions
4 | import androidx.compose.material.Icon
5 | import androidx.compose.material.Text
6 | import androidx.compose.material.TextField
7 | import androidx.compose.material.icons.Icons
8 | import androidx.compose.material.icons.filled.Email
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.focus.ExperimentalFocus
12 | import androidx.compose.ui.focus.FocusState
13 | import androidx.compose.ui.focusObserver
14 | import androidx.compose.ui.text.input.ImeAction
15 | import androidx.compose.ui.text.input.KeyboardType
16 | import com.codingwithmitch.openchat.common.framework.presentation.state.TextEmailState
17 |
18 | @ExperimentalFocus
19 | @Composable
20 | fun EmailInputField(
21 | emailState: TextEmailState,
22 | onEmailChanged: (String) -> Unit,
23 | modifier: Modifier,
24 | imeAction: ImeAction = ImeAction.Next,
25 | onImeAction: () -> Unit,
26 | ){
27 |
28 | TextField(
29 | value = emailState.text,
30 | onValueChange = {
31 | onEmailChanged(it)
32 | },
33 | label = {
34 | Text(text = emailState.getLabel())
35 | },
36 | modifier = modifier.focusObserver { focusState ->
37 | val focused = focusState == FocusState.Active
38 | emailState.onFocusChange(focused)
39 | if (!focused) {
40 | emailState.checkEnableShowErrors()
41 | }
42 | },
43 | keyboardOptions = KeyboardOptions(
44 | keyboardType = KeyboardType.Email,
45 | imeAction = imeAction,
46 | ),
47 | leadingIcon = {Icon(Icons.Filled.Email)},
48 | onImeActionPerformed = { action, softKeyboardController ->
49 | if (action == ImeAction.Next || action == ImeAction.Done) {
50 | softKeyboardController?.hideSoftwareKeyboard()
51 | }
52 | onImeAction()
53 | },
54 | isErrorValue = emailState.isErrors()
55 | )
56 | if(emailState.isErrors()) TextFieldError(textError = emailState.getErrorMessage())
57 | }
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @if "%DEBUG%" == "" @echo off
2 | @rem ##########################################################################
3 | @rem
4 | @rem Gradle startup script for Windows
5 | @rem
6 | @rem ##########################################################################
7 |
8 | @rem Set local scope for the variables with windows NT shell
9 | if "%OS%"=="Windows_NT" setlocal
10 |
11 | set DIRNAME=%~dp0
12 | if "%DIRNAME%" == "" set DIRNAME=.
13 | set APP_BASE_NAME=%~n0
14 | set APP_HOME=%DIRNAME%
15 |
16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
17 | set DEFAULT_JVM_OPTS=
18 |
19 | @rem Find java.exe
20 | if defined JAVA_HOME goto findJavaFromJavaHome
21 |
22 | set JAVA_EXE=java.exe
23 | %JAVA_EXE% -version >NUL 2>&1
24 | if "%ERRORLEVEL%" == "0" goto init
25 |
26 | echo.
27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
28 | echo.
29 | echo Please set the JAVA_HOME variable in your environment to match the
30 | echo location of your Java installation.
31 |
32 | goto fail
33 |
34 | :findJavaFromJavaHome
35 | set JAVA_HOME=%JAVA_HOME:"=%
36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
37 |
38 | if exist "%JAVA_EXE%" goto init
39 |
40 | echo.
41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
42 | echo.
43 | echo Please set the JAVA_HOME variable in your environment to match the
44 | echo location of your Java installation.
45 |
46 | goto fail
47 |
48 | :init
49 | @rem Get command-line arguments, handling Windows variants
50 |
51 | if not "%OS%" == "Windows_NT" goto win9xME_args
52 |
53 | :win9xME_args
54 | @rem Slurp the command line arguments.
55 | set CMD_LINE_ARGS=
56 | set _SKIP=2
57 |
58 | :win9xME_args_slurp
59 | if "x%~1" == "x" goto execute
60 |
61 | set CMD_LINE_ARGS=%*
62 |
63 | :execute
64 | @rem Setup the command line
65 |
66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
67 |
68 | @rem Execute Gradle
69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
70 |
71 | :end
72 | @rem End local scope for the variables with windows NT shell
73 | if "%ERRORLEVEL%"=="0" goto mainEnd
74 |
75 | :fail
76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
77 | rem the _cmd.exe /c_ return code!
78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
79 | exit /b 1
80 |
81 | :mainEnd
82 | if "%OS%"=="Windows_NT" endlocal
83 |
84 | :omega
85 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/framework/presentation/components/UsernameInputField.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.framework.presentation.components
2 |
3 | import androidx.compose.foundation.text.KeyboardOptions
4 | import androidx.compose.material.Icon
5 | import androidx.compose.material.Text
6 | import androidx.compose.material.TextField
7 | import androidx.compose.material.icons.Icons
8 | import androidx.compose.material.icons.filled.AccountCircle
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.focus.ExperimentalFocus
12 | import androidx.compose.ui.focus.FocusState
13 | import androidx.compose.ui.focusObserver
14 | import androidx.compose.ui.text.input.ImeAction
15 | import androidx.compose.ui.text.input.KeyboardType
16 | import com.codingwithmitch.openchat.common.framework.presentation.state.TextUsernameState
17 |
18 | @ExperimentalFocus
19 | @Composable
20 | fun UsernameInputField(
21 | usernameState: TextUsernameState,
22 | onUsernameChanged: (String) -> Unit,
23 | modifier: Modifier,
24 | imeAction: ImeAction = ImeAction.Next,
25 | onImeAction: () -> Unit,
26 | ){
27 |
28 | TextField(
29 | value = usernameState.text,
30 | onValueChange = {
31 | onUsernameChanged(it)
32 | },
33 | label = {
34 | Text(text = usernameState.getLabel())
35 | },
36 | modifier = modifier.focusObserver { focusState ->
37 | val focused = focusState == FocusState.Active
38 | usernameState.onFocusChange(focused)
39 | if (!focused) {
40 | usernameState.checkEnableShowErrors()
41 | }
42 | },
43 | keyboardOptions = KeyboardOptions(
44 | keyboardType = KeyboardType.Text,
45 | imeAction = imeAction,
46 | ),
47 | leadingIcon = {Icon(Icons.Filled.AccountCircle)},
48 | onImeActionPerformed = { action, softKeyboardController ->
49 | if (action == ImeAction.Next || action == ImeAction.Done) {
50 | softKeyboardController?.hideSoftwareKeyboard()
51 | }
52 | onImeAction()
53 | },
54 | isErrorValue = usernameState.isErrors()
55 | )
56 | if(usernameState.isErrors()) TextFieldError(textError = usernameState.getErrorMessage())
57 | }
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/business/data/network/ApiResponseHandler.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.business.data.network
2 |
3 | import com.codingwithmitch.openchat.common.business.data.network.NetworkErrors.NETWORK_DATA_NULL
4 | import com.codingwithmitch.openchat.common.business.data.network.NetworkErrors.NETWORK_ERROR
5 | import com.codingwithmitch.openchat.common.business.data.network.NetworkErrors.NETWORK_ERROR_UNKNOWN
6 | import com.codingwithmitch.openchat.common.business.domain.state.*
7 |
8 | abstract class ApiResponseHandler (
9 | private val response: ApiResult,
10 | private val stateEvent: StateEvent?
11 | ){
12 |
13 | suspend fun getResult(): DataState? {
14 |
15 | return when(response){
16 |
17 | is ApiResult.GenericError -> {
18 | DataState.error(
19 | response = Response(
20 | message = "${stateEvent?.errorInfo()}\n\nReason: ${response.errorMessage.toString()}",
21 | uiComponentType = UIComponentType.Dialog(),
22 | messageType = MessageType.Error()
23 | ),
24 | stateEvent = stateEvent
25 | )
26 | }
27 |
28 | is ApiResult.NetworkError -> {
29 | DataState.error(
30 | response = Response(
31 | message = "${stateEvent?.errorInfo()}\n\nReason: ${response.errorMessage?: NETWORK_ERROR_UNKNOWN}",
32 | uiComponentType = UIComponentType.Dialog(),
33 | messageType = MessageType.Error()
34 | ),
35 | stateEvent = stateEvent
36 | )
37 | }
38 |
39 | is ApiResult.Success -> {
40 | if(response.value == null){
41 | DataState.error(
42 | response = Response(
43 | message = "${stateEvent?.errorInfo()}\n\nReason: ${NETWORK_DATA_NULL}.",
44 | uiComponentType = UIComponentType.Dialog(),
45 | messageType = MessageType.Error()
46 | ),
47 | stateEvent = stateEvent
48 | )
49 | }
50 | else{
51 | handleSuccess(resultObj = response.value)
52 | }
53 | }
54 |
55 | }
56 | }
57 |
58 | abstract suspend fun handleSuccess(resultObj: Data): DataState?
59 |
60 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/di/SessionCacheModule.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.di
2 |
3 | import com.codingwithmitch.openchat.account.framework.datasource.cache.mappers.AccountCacheMapper
4 | import com.codingwithmitch.openchat.session.business.data.cache.AuthCacheDataSource
5 | import com.codingwithmitch.openchat.session.business.data.cache.AuthCacheDataSourceImpl
6 | import com.codingwithmitch.openchat.session.framework.datasource.cache.AuthTokenDaoService
7 | import com.codingwithmitch.openchat.session.framework.datasource.cache.AuthTokenDaoServiceImpl
8 | import com.codingwithmitch.openchat.account.framework.datasource.cache.database.AccountDao
9 | import com.codingwithmitch.openchat.session.framework.datasource.cache.database.AuthTokenDao
10 | import com.codingwithmitch.openchat.common.framework.datasource.cache.database.AppDatabase
11 | import com.codingwithmitch.openchat.session.framework.datasource.cache.mappers.AuthCacheMapper
12 | import com.codingwithmitch.openchat.common.business.domain.util.DateUtil
13 | import dagger.Module
14 | import dagger.Provides
15 | import dagger.hilt.InstallIn
16 | import dagger.hilt.android.components.ApplicationComponent
17 | import javax.inject.Singleton
18 |
19 | @Module
20 | @InstallIn(ApplicationComponent::class)
21 | object SessionCacheModule {
22 |
23 | @Singleton
24 | @Provides
25 | fun provideCacheMapper(dateUtil: DateUtil): AuthCacheMapper{
26 | return AuthCacheMapper(dateUtil)
27 | }
28 |
29 | @Singleton
30 | @Provides
31 | fun provideAuthDao(db: AppDatabase): AuthTokenDao {
32 | return db.authDao()
33 | }
34 |
35 | @Singleton
36 | @Provides
37 | fun provideAuthDaoService(
38 | authTokenDao: AuthTokenDao,
39 | accountDao: AccountDao,
40 | mapper: AuthCacheMapper,
41 | accountMapper: AccountCacheMapper,
42 | dateUtil: DateUtil,
43 | ): AuthTokenDaoService {
44 | return AuthTokenDaoServiceImpl(
45 | authTokenDao = authTokenDao,
46 | accountDao = accountDao,
47 | authCacheMapper = mapper,
48 | accountCacheMapper = accountMapper,
49 | dateUtil = dateUtil
50 | )
51 | }
52 |
53 | @Singleton
54 | @Provides
55 | fun provideAuthCacheDataSource(
56 | authTokenDaoService: AuthTokenDaoService,
57 | ): AuthCacheDataSource{
58 | return AuthCacheDataSourceImpl(authTokenDaoService)
59 | }
60 |
61 | }
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/framework/presentation/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.framework.presentation.theme
2 |
3 | import androidx.compose.foundation.isSystemInDarkTheme
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.foundation.layout.fillMaxHeight
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.material.MaterialTheme
8 | import androidx.compose.material.darkColors
9 | import androidx.compose.material.lightColors
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.graphics.Color
13 | import com.codingwithmitch.openchat.common.framework.presentation.components.CircularIndeterminateProgressBar
14 | import com.codingwithmitch.openchat.splash.framework.presentation.SplashScreen
15 |
16 |
17 | private val LightThemeColors = lightColors(
18 | primary = Blue600,
19 | primaryVariant = Blue400,
20 | onPrimary = Black5,
21 | secondary = Orange800,
22 | secondaryVariant = Orange400,
23 | onSecondary = Color.Black,
24 | error = RedErrorDark,
25 | onError = RedErrorLight,
26 | background = Blue600,
27 | onBackground = Color.White,
28 | surface = Color.White,
29 | onSurface = Black5,
30 |
31 | )
32 |
33 | private val DarkThemeColors = darkColors(
34 | primary = Blue700,
35 | primaryVariant = Color.White,
36 | onPrimary = Color.White,
37 | secondary = Orange300,
38 | onSecondary = Orange800,
39 | error = RedErrorLight,
40 | background = Color.Black,
41 | onBackground = Color.White,
42 | surface = Black3,
43 | onSurface = Color.White,
44 | )
45 |
46 | @Composable
47 | fun AppTheme(
48 | darkTheme: Boolean = isSystemInDarkTheme(),
49 | progressBarIsDisplayed: Boolean = false,
50 | content: @Composable () -> Unit
51 | ) {
52 | MaterialTheme(
53 | colors = if (darkTheme) DarkThemeColors else LightThemeColors,
54 | typography = RobotoTypography,
55 | shapes = AppShapes
56 | ){
57 | Box(modifier = Modifier
58 | .fillMaxWidth()
59 | .fillMaxHeight()
60 | ){
61 | content()
62 | CircularIndeterminateProgressBar(isDisplayed = progressBarIsDisplayed, 0.2f)
63 | }
64 | }
65 | }
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/business/domain/state/StateEventManager.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.business.domain.state
2 |
3 | import androidx.lifecycle.LiveData
4 | import androidx.lifecycle.MutableLiveData
5 | import com.codingwithmitch.openchat.EspressoIdlingResource
6 | import com.codingwithmitch.openchat.common.business.domain.util.printLogD
7 | import kotlinx.coroutines.ExperimentalCoroutinesApi
8 | import kotlinx.coroutines.flow.MutableStateFlow
9 | import kotlinx.coroutines.flow.StateFlow
10 |
11 | /**
12 | * - Keeps track of active StateEvents in DataChannelManager
13 | * - Keeps track of whether the progress bar should show or not based on a boolean
14 | * value in each StateEvent (shouldDisplayProgressBar)
15 | */
16 | @ExperimentalCoroutinesApi
17 | class StateEventManager {
18 |
19 | private val activeStateEvents: HashMap = HashMap()
20 |
21 | private val _shouldDisplayProgressBar: MutableStateFlow = MutableStateFlow(false)
22 |
23 | val shouldDisplayProgressBar: StateFlow
24 | get() = _shouldDisplayProgressBar
25 |
26 | fun getActiveJobNames(): MutableSet{
27 | return activeStateEvents.keys
28 | }
29 |
30 | fun clearActiveStateEventCounter(){
31 | printLogD("DCM", "Clear active state events")
32 | EspressoIdlingResource.clear()
33 | activeStateEvents.clear()
34 | syncNumActiveStateEvents()
35 | }
36 |
37 | fun addStateEvent(stateEvent: StateEvent){
38 | EspressoIdlingResource.increment()
39 | activeStateEvents.put(stateEvent.eventName(), stateEvent)
40 | syncNumActiveStateEvents()
41 | }
42 |
43 | fun removeStateEvent(stateEvent: StateEvent?){
44 | printLogD("DCM sem", "remove state event: ${stateEvent?.eventName()}")
45 | stateEvent?.let {
46 | EspressoIdlingResource.decrement()
47 | }
48 | activeStateEvents.remove(stateEvent?.eventName())
49 | syncNumActiveStateEvents()
50 | }
51 |
52 | fun isStateEventActive(stateEvent: StateEvent): Boolean{
53 | printLogD("DCM sem", "is state event active? " +
54 | "${activeStateEvents.containsKey(stateEvent.eventName())}")
55 | return activeStateEvents.containsKey(stateEvent.eventName())
56 | }
57 |
58 | private fun syncNumActiveStateEvents(){
59 | var shouldDisplayProgressBar = false
60 | for(stateEvent in activeStateEvents.values){
61 | if(stateEvent.shouldDisplayProgressBar()){
62 | shouldDisplayProgressBar = true
63 | }
64 | }
65 | printLogD("SEM", "progress bar: ${shouldDisplayProgressBar}")
66 | _shouldDisplayProgressBar.value = shouldDisplayProgressBar
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/business/domain/state/MessageStack.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.business.domain.state
2 |
3 | import androidx.lifecycle.LiveData
4 | import androidx.lifecycle.MutableLiveData
5 | import com.codingwithmitch.openchat.common.business.domain.util.printLogD
6 | import kotlinx.android.parcel.IgnoredOnParcel
7 | import kotlinx.coroutines.ExperimentalCoroutinesApi
8 | import kotlinx.coroutines.flow.MutableStateFlow
9 | import kotlinx.coroutines.flow.StateFlow
10 |
11 | const val MESSAGE_STACK_BUNDLE_KEY = "com.codingwithmitch.openapi.util.MessageStack"
12 |
13 | @ExperimentalCoroutinesApi
14 | class MessageStack: ArrayList() {
15 |
16 | @IgnoredOnParcel
17 | private val _stateMessage: MutableStateFlow = MutableStateFlow(null)
18 |
19 | @IgnoredOnParcel
20 | val stateMessage: StateFlow
21 | get() = _stateMessage
22 |
23 | fun isStackEmpty(): Boolean{
24 | return size == 0
25 | }
26 |
27 | fun getNumStateMessages(): Int{
28 | return size
29 | }
30 |
31 | override fun addAll(elements: Collection): Boolean {
32 | for(element in elements){
33 | add(element)
34 | }
35 | return true // always return true. We don't care about result bool.
36 | }
37 |
38 | override fun add(element: StateMessage): Boolean {
39 | if(this.contains(element)){ // prevent duplicate errors added to stack
40 | return false
41 | }
42 | val transaction = super.add(element)
43 | if(this.size == 1){
44 | setStateMessage(stateMessage = element)
45 | }
46 | return transaction
47 | }
48 |
49 | override fun removeAt(index: Int): StateMessage {
50 | try{
51 | val transaction = super.removeAt(index)
52 | if(this.size > 0){
53 | setStateMessage(stateMessage = this[0])
54 | }
55 | else{
56 | printLogD("MessageStack", "stack is empty: ")
57 | setStateMessage(null)
58 | }
59 | return transaction
60 | }catch (e: IndexOutOfBoundsException){
61 | setStateMessage(null)
62 | e.printStackTrace()
63 | }
64 | return StateMessage( // this does nothing
65 | Response(
66 | message = "does nothing",
67 | uiComponentType = UIComponentType.None(),
68 | messageType = MessageType.None()
69 | )
70 | )
71 | }
72 |
73 | private fun setStateMessage(stateMessage: StateMessage?){
74 | _stateMessage.value = stateMessage
75 | }
76 | }
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/di/SessionModule.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.di
2 |
3 | import android.content.Context
4 | import androidx.datastore.DataStore
5 | import androidx.datastore.preferences.Preferences
6 | import androidx.datastore.preferences.createDataStore
7 | import com.codingwithmitch.openchat.session.business.data.cache.AuthCacheDataSource
8 | import com.codingwithmitch.openchat.session.business.data.network.AuthNetworkDataSource
9 | import com.codingwithmitch.openchat.session.business.interactors.Login
10 | import com.codingwithmitch.openchat.session.business.interactors.Logout
11 | import com.codingwithmitch.openchat.session.framework.presentation.SessionManager
12 | import com.codingwithmitch.openchat.session.business.interactors.CheckAuthToken
13 | import dagger.Module
14 | import dagger.Provides
15 | import dagger.hilt.InstallIn
16 | import dagger.hilt.android.components.ApplicationComponent
17 | import dagger.hilt.android.qualifiers.ApplicationContext
18 | import kotlinx.coroutines.ExperimentalCoroutinesApi
19 | import javax.inject.Named
20 | import javax.inject.Singleton
21 |
22 | @ExperimentalCoroutinesApi
23 | @Module
24 | @InstallIn(ApplicationComponent::class)
25 | object SessionModule {
26 |
27 | @Singleton
28 | @Provides
29 | @Named("auth_preferences")
30 | fun provideAuthDatastore(@ApplicationContext app: Context): DataStore{
31 | return app.createDataStore(name = "auth_preferences")
32 | }
33 |
34 | @Singleton
35 | @Provides
36 | fun provideLoginUseCase(
37 | cacheDataSource: AuthCacheDataSource,
38 | networkDataSource: AuthNetworkDataSource,
39 | @Named("auth_preferences") dataStore: DataStore,
40 | ): Login {
41 | return Login(cacheDataSource, networkDataSource, dataStore)
42 | }
43 |
44 | @Singleton
45 | @Provides
46 | fun provideLogoutUseCase(
47 | cacheDataSource: AuthCacheDataSource,
48 | @Named("auth_preferences") dataStore: DataStore,
49 | ): Logout {
50 | return Logout(cacheDataSource, dataStore)
51 | }
52 |
53 | @Singleton
54 | @Provides
55 | fun provideCheckAuthTokenUseCase(
56 | cacheDataSource: AuthCacheDataSource,
57 | ): CheckAuthToken {
58 | return CheckAuthToken(cacheDataSource)
59 | }
60 |
61 | @Singleton
62 | @Provides
63 | fun provideSessionManager(
64 | logout: Logout,
65 | login: Login,
66 | checkAuthToken: CheckAuthToken,
67 | @Named("auth_preferences") dataStore: DataStore,
68 | ): SessionManager {
69 | return SessionManager(
70 | logout, login, checkAuthToken, dataStore
71 | )
72 | }
73 |
74 |
75 | }
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/business/interactors/CheckAuthToken.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.business.interactors
2 |
3 | import com.codingwithmitch.openchat.session.business.data.cache.AuthCacheDataSource
4 | import com.codingwithmitch.openchat.session.business.domain.model.AuthToken
5 | import com.codingwithmitch.openchat.common.business.data.cache.CacheResponseHandler
6 | import com.codingwithmitch.openchat.common.business.domain.state.*
7 | import com.codingwithmitch.openchat.common.business.domain.util.safeCacheCall
8 | import com.codingwithmitch.openchat.session.framework.presentation.SessionState
9 | import kotlinx.coroutines.Dispatchers.IO
10 | import kotlinx.coroutines.flow.Flow
11 | import kotlinx.coroutines.flow.flow
12 |
13 | const val NO_AUTH_TOKEN_FOUND = "No AuthToken found."
14 |
15 | class CheckAuthToken (
16 | private val cacheDataSource: AuthCacheDataSource
17 | ){
18 |
19 | suspend fun execute(stateEvent: StateEvent, accountPk: Int): Flow?> = flow{
20 |
21 | val cacheResult = safeCacheCall(IO){
22 | cacheDataSource.getTokens()
23 | }
24 |
25 | val response = object: CacheResponseHandler?>(
26 | response = cacheResult,
27 | stateEvent = stateEvent,
28 | ){
29 | override suspend fun handleSuccess(resultObj: List?): DataState? {
30 | // should only be a single token. But find the one that matches the account pk
31 | if(resultObj != null){
32 | var token: AuthToken? = null
33 | for (authToken in resultObj){
34 | if(authToken.accountId == accountPk){
35 | token = authToken
36 | }
37 | }
38 | if(token != null){
39 | return DataState.data(
40 | response = null,
41 | data = SessionState(
42 | authToken = token
43 | ),
44 | stateEvent = stateEvent
45 | )
46 | }
47 |
48 | }
49 | return DataState.data(
50 | response = Response(
51 | message = NO_AUTH_TOKEN_FOUND,
52 | uiComponentType = UIComponentType.None(),
53 | messageType = MessageType.Info()
54 | ),
55 | data = null,
56 | stateEvent = stateEvent
57 | )
58 | }
59 | }.getResult()
60 |
61 | emit(response)
62 | }
63 | }
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/di/SessionNetworkModule.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.di
2 |
3 | import com.codingwithmitch.openchat.session.business.data.network.AuthNetworkDataSource
4 | import com.codingwithmitch.openchat.session.business.data.network.AuthNetworkDataSourceImpl
5 | import com.codingwithmitch.openchat.session.framework.datasource.network.SessionService
6 | import com.codingwithmitch.openchat.session.framework.datasource.network.SessionServiceImpl
7 | import com.codingwithmitch.openchat.session.framework.datasource.network.mappers.SessionNetworkMapper
8 | import com.codingwithmitch.openchat.session.framework.datasource.network.retrofit.AuthRetrofitService
9 | import com.codingwithmitch.openchat.common.business.domain.util.DateUtil
10 | import com.google.gson.GsonBuilder
11 | import dagger.Module
12 | import dagger.Provides
13 | import dagger.hilt.InstallIn
14 | import dagger.hilt.android.components.ApplicationComponent
15 | import retrofit2.Retrofit
16 | import retrofit2.converter.gson.GsonConverterFactory
17 | import javax.inject.Named
18 | import javax.inject.Singleton
19 |
20 | @Module
21 | @InstallIn(ApplicationComponent::class)
22 | object SessionNetworkModule {
23 |
24 |
25 | @Singleton
26 | @Provides
27 | fun provideNetworkMapper(dateUtil: DateUtil): SessionNetworkMapper {
28 | return SessionNetworkMapper(dateUtil)
29 | }
30 |
31 | @Singleton
32 | @Provides
33 | @Named("auth_retrofit")
34 | fun provideAuthRetrofit(): Retrofit.Builder {
35 | return Retrofit.Builder()
36 | .baseUrl("https://open-chat.xyz/api/account/")
37 | .addConverterFactory(GsonConverterFactory.create(GsonBuilder().create()))
38 | }
39 |
40 | @Singleton
41 | @Provides
42 | fun provideAuthRetrofitService(
43 | @Named("auth_retrofit") retrofit: Retrofit.Builder
44 | ): AuthRetrofitService {
45 | return retrofit
46 | .build()
47 | .create(AuthRetrofitService::class.java)
48 | }
49 |
50 | @Singleton
51 | @Provides
52 | fun provideSessionService(
53 | authRetrofit: AuthRetrofitService,
54 | mapper: SessionNetworkMapper,
55 | ): SessionService {
56 | return SessionServiceImpl(
57 | authRetrofitService = authRetrofit,
58 | mapper = mapper,
59 | )
60 | }
61 |
62 | @Singleton
63 | @Provides
64 | fun provideNetworkDataSource(
65 | sessionService: SessionService
66 | ): AuthNetworkDataSource{
67 | return AuthNetworkDataSourceImpl(sessionService)
68 | }
69 |
70 | }
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | import Application
2 | import Versions
3 | import Build
4 | import dependencies.Dependencies
5 |
6 | plugins {
7 | id 'com.android.application'
8 | id 'kotlin-android'
9 | id 'kotlin-android-extensions'
10 | id 'kotlin-kapt'
11 | id 'dagger.hilt.android.plugin'
12 | }
13 |
14 | android {
15 | compileSdkVersion Versions.compilesdk
16 |
17 | defaultConfig {
18 | applicationId Application.applicationId
19 | minSdkVersion Versions.minsdk
20 | targetSdkVersion Versions.targetsdk
21 | versionCode Build.version_code
22 | versionName Build.version_name
23 |
24 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
25 | }
26 |
27 | buildFeatures {
28 | compose true
29 | }
30 |
31 | buildTypes {
32 | release {
33 | minifyEnabled false
34 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
35 | }
36 | }
37 | compileOptions {
38 | sourceCompatibility JavaVersion.VERSION_1_8
39 | targetCompatibility JavaVersion.VERSION_1_8
40 | }
41 | kotlinOptions {
42 | jvmTarget = Versions.jvmTarget
43 | useIR = true
44 | }
45 | composeOptions {
46 | kotlinCompilerVersion Versions.kotlin_compiler_version
47 | kotlinCompilerExtensionVersion Versions.kotlin_compiler_extension_version
48 | }
49 |
50 | }
51 |
52 | dependencies {
53 |
54 | implementation Dependencies.kotlin_std_lib
55 | implementation Dependencies.kotlin_core_ktx
56 | implementation Dependencies.app_compat
57 | implementation Dependencies.material
58 | implementation Dependencies.constraint_layout
59 |
60 | implementation Dependencies.compose_ui
61 | implementation Dependencies.compose_ui_tooling
62 | implementation Dependencies.compose_foundation
63 | implementation Dependencies.compose_material
64 | implementation Dependencies.compose_material_icons_core
65 | implementation Dependencies.compose_material_icons_extended
66 | implementation Dependencies.compose_livedata
67 |
68 | implementation Dependencies.hilt_android
69 | implementation Dependencies.hilt_lifecycle_viewmodel
70 | implementation Dependencies.navigation_ktx
71 | implementation Dependencies.navigation_ui
72 | kapt AnnotationProcessing.hilt_compiler
73 | kapt AnnotationProcessing.hilt_viewmodel_compiler
74 |
75 | implementation Dependencies.glide
76 | implementation Dependencies.glide_accompanist
77 | kapt AnnotationProcessing.glide
78 |
79 | implementation Dependencies.retrofit
80 | implementation AnnotationProcessing.retrofit_gson
81 |
82 | implementation Dependencies.material_dialogs_core
83 |
84 | implementation Dependencies.leak_canary
85 |
86 | implementation Dependencies.room_runtime
87 | implementation Dependencies.room_ktx
88 | kapt AnnotationProcessing.room_compiler
89 |
90 | implementation Dependencies.datastore
91 |
92 | // implementation Dependencies.viewmodel_saved_state
93 | // implementation Dependencies.viewmodel_livedata_ktx
94 | }
95 |
96 | kapt {
97 | correctErrorTypes true
98 | }
99 |
100 |
101 |
102 |
103 |
104 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/splash/framework/presentation/SplashFragment.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.splash.framework.presentation
2 |
3 | import android.os.Bundle
4 | import android.view.LayoutInflater
5 | import android.view.View
6 | import android.view.ViewGroup
7 | import androidx.compose.runtime.collectAsState
8 | import androidx.compose.runtime.getValue
9 | import androidx.compose.ui.platform.ComposeView
10 | import androidx.fragment.app.Fragment
11 | import androidx.lifecycle.lifecycleScope
12 | import androidx.navigation.fragment.findNavController
13 | import com.codingwithmitch.openchat.R
14 | import com.codingwithmitch.openchat.common.framework.presentation.theme.AppTheme
15 | import com.codingwithmitch.openchat.session.framework.presentation.SessionManager
16 | import com.codingwithmitch.openchat.session.business.interactors.NO_AUTH_TOKEN_FOUND
17 | import dagger.hilt.android.AndroidEntryPoint
18 | import kotlinx.coroutines.ExperimentalCoroutinesApi
19 | import kotlinx.coroutines.delay
20 | import kotlinx.coroutines.launch
21 | import javax.inject.Inject
22 |
23 | @ExperimentalCoroutinesApi
24 | @AndroidEntryPoint
25 | class SplashFragment : Fragment() {
26 |
27 | @Inject
28 | lateinit var sessionManager: SessionManager
29 |
30 | override fun onCreateView(
31 | inflater: LayoutInflater,
32 | container: ViewGroup?,
33 | savedInstanceState: Bundle?
34 | ): View? {
35 | return inflater.inflate(
36 | R.layout.compose_view, container, false
37 | ).apply {
38 | findViewById(R.id.compose_view).setContent {
39 |
40 | val progressBarState by sessionManager.shouldDisplayProgressBar.collectAsState()
41 |
42 | val stateMessageState by sessionManager.stateMessage.collectAsState()
43 |
44 | // There is no token found, redirect to login screen.
45 | if(stateMessageState?.response?.message?.equals(NO_AUTH_TOKEN_FOUND) == true){
46 | onNavigateToAuthFragment()
47 | }
48 | AppTheme(
49 | darkTheme = false,
50 | progressBarIsDisplayed = progressBarState,
51 | ){
52 | SplashScreen()
53 | }
54 | }
55 | }
56 | }
57 |
58 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
59 | super.onViewCreated(view, savedInstanceState)
60 | /**
61 | * Limit the time SplashFragment is visible. If jobs take longer than 2 seconds
62 | * redirect to LoginScreen.
63 | */
64 | lifecycleScope.launch {
65 | delay(2000)
66 | onNavigateToAuthFragment()
67 | }
68 | sessionManager.sessionState.observe(viewLifecycleOwner, {sessionState ->
69 | if(sessionState?.authToken != null){
70 | onNavigateToAuthFragment()
71 | }
72 | })
73 |
74 | sessionManager.checkAuthToken()
75 | }
76 |
77 | private fun onNavigateToAuthFragment(){
78 | sessionManager.clearAllStateMessages()
79 | findNavController().navigate(R.id.action_splashFragment_to_authFragment)
80 | }
81 | }
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/auth/framework/presentation/state/AuthViewState.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.auth.framework.presentation.state
2 |
3 | import com.codingwithmitch.openchat.auth.framework.presentation.navigation.AuthScreen
4 | import com.codingwithmitch.openchat.common.framework.presentation.state.TextEmailState
5 | import com.codingwithmitch.openchat.common.framework.presentation.state.TextFieldState
6 | import com.codingwithmitch.openchat.common.framework.presentation.state.TextPasswordState
7 | import com.codingwithmitch.openchat.common.framework.presentation.state.TextUsernameState
8 |
9 | const val BUNDLE_KEY_AUTH_VIEWSTATE = "com.codingwithmitch.openchat.auth.framework.presentation.state.AuthViewState"
10 |
11 | class AuthViewState(
12 |
13 | // LoginScreen
14 | var loginEmailState: LoginEmailState = LoginEmailState(""),
15 | var loginPasswordState: LoginPasswordState = LoginPasswordState(""),
16 |
17 | // PasswordResetScreen
18 | var passwordResetEmailState: PasswordResetEmailState = PasswordResetEmailState(""),
19 |
20 | // CreateAccountScreen
21 | var createEmailState: CreateEmailState = CreateEmailState(""),
22 | var createUsernameState: CreateUsernameState = CreateUsernameState(""),
23 | var createPasswordState: CreatePasswordState = CreatePasswordState(
24 | CreatePasswordState.Password1State(""),
25 | CreatePasswordState.Password2State("")
26 | ),
27 |
28 | // Manage navigation
29 | var screen: AuthScreen = AuthScreen.Login
30 | ){
31 |
32 | /**
33 | * LoginScreen variables
34 | */
35 | class LoginEmailState(
36 | value: String
37 | ): TextEmailState(value)
38 |
39 | class LoginPasswordState(
40 | value: String,
41 | showPassword: Boolean = false,
42 | ): TextPasswordState(value, showPassword)
43 |
44 |
45 | /**
46 | * PasswordResetScreen variables
47 | */
48 | class PasswordResetEmailState(
49 | value: String
50 | ): TextEmailState(value)
51 |
52 |
53 | /**
54 | * CreateAccountScreen variables
55 | */
56 | class CreateEmailState(
57 | value: String
58 | ): TextEmailState(value)
59 |
60 | class CreateUsernameState(
61 | value: String
62 | ): TextUsernameState(value)
63 |
64 | class CreatePasswordState(
65 | val password1: Password1State,
66 | val password2: Password2State
67 | ): TextFieldState(){
68 |
69 | init {
70 | // force errors to be enabled if `isValid` fails
71 | wasFocused = true
72 | checkEnableShowErrors()
73 | }
74 |
75 | class Password1State(
76 | value: String,
77 | showPassword: Boolean = false,
78 | ): TextPasswordState(value, showPassword)
79 |
80 |
81 | class Password2State(
82 | value: String,
83 | showPassword: Boolean = false,
84 | ): TextPasswordState(value, showPassword){
85 |
86 | override fun getLabel(): String {
87 | return "Confirm password"
88 | }
89 | }
90 |
91 | override fun getLabel(): String {
92 | return "NONE" // Not used
93 | }
94 |
95 | override fun isValid(): Boolean {
96 | return password1.text == password2.text
97 | }
98 |
99 | override fun getErrorMessage(): String {
100 | return "Passwords must match."
101 | }
102 |
103 |
104 | }
105 |
106 | }
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/framework/presentation/components/PasswordInputField.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.framework.presentation.components
2 |
3 | import androidx.compose.foundation.text.KeyboardOptions
4 | import androidx.compose.material.Icon
5 | import androidx.compose.material.IconButton
6 | import androidx.compose.material.Text
7 | import androidx.compose.material.TextField
8 | import androidx.compose.material.icons.Icons
9 | import androidx.compose.material.icons.filled.Lock
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.focus.ExperimentalFocus
13 | import androidx.compose.ui.focus.FocusState
14 | import androidx.compose.ui.focusObserver
15 | import androidx.compose.ui.res.vectorResource
16 | import androidx.compose.ui.text.input.ImeAction
17 | import androidx.compose.ui.text.input.KeyboardType
18 | import androidx.compose.ui.text.input.PasswordVisualTransformation
19 | import androidx.compose.ui.text.input.VisualTransformation
20 | import com.codingwithmitch.openchat.R
21 | import com.codingwithmitch.openchat.common.framework.presentation.state.TextFieldState
22 |
23 | @ExperimentalFocus
24 | @Composable
25 | fun PasswordInputField(
26 | passwordState: TextFieldState,
27 | onPasswordChange: (String) -> Unit,
28 | modifier: Modifier,
29 | imeAction: ImeAction = ImeAction.Done,
30 | onImeAction: () -> Unit,
31 | showPassword: Boolean,
32 | onShowPasswordChange: (Boolean) -> Unit,
33 | ) {
34 | TextField(
35 | value = passwordState.text,
36 | onValueChange = {
37 | onPasswordChange(it)
38 | },
39 | label = {
40 | Text(text = passwordState.getLabel())
41 | },
42 | visualTransformation = if (showPassword) {
43 | VisualTransformation.None
44 | } else {
45 | PasswordVisualTransformation()
46 | },
47 | modifier = modifier.focusObserver { focusState ->
48 | val focused = focusState == FocusState.Active
49 | passwordState.onFocusChange(focused)
50 | if (!focused) {
51 | passwordState.checkEnableShowErrors()
52 | }
53 | },
54 | keyboardOptions = KeyboardOptions(
55 | keyboardType = KeyboardType.Text,
56 | imeAction = imeAction,
57 | ),
58 | leadingIcon = { Icon(Icons.Filled.Lock) },
59 | onImeActionPerformed = { action, softKeyboardController ->
60 | if (action == ImeAction.Done || action == ImeAction.Next) {
61 | softKeyboardController?.hideSoftwareKeyboard()
62 | }
63 | onImeAction()
64 | },
65 | trailingIcon = {
66 | if (showPassword) {
67 | IconButton(onClick = {
68 | onShowPasswordChange(!showPassword)
69 | }) {
70 | Icon(asset = vectorResource(id = R.drawable.ic_baseline_visibility_on_24))
71 | }
72 | } else {
73 | IconButton(onClick = {
74 | onShowPasswordChange(!showPassword)
75 | }) {
76 | Icon(asset = vectorResource(id = R.drawable.ic_baseline_visibility_off_24))
77 | }
78 | }
79 | },
80 | isErrorValue = passwordState.isErrors(),
81 | )
82 | if(passwordState.isErrors()) TextFieldError(textError = passwordState.getErrorMessage())
83 |
84 | }
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/main/framework/presentation/main/MainFragment.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.main.framework.presentation.main
2 |
3 | import android.os.Bundle
4 | import android.view.LayoutInflater
5 | import android.view.View
6 | import android.view.ViewGroup
7 | import androidx.activity.OnBackPressedCallback
8 | import androidx.compose.foundation.layout.Column
9 | import androidx.compose.material.Button
10 | import androidx.compose.material.MaterialTheme
11 | import androidx.compose.material.Text
12 | import androidx.compose.ui.platform.ComposeView
13 | import androidx.lifecycle.lifecycleScope
14 | import com.codingwithmitch.openchat.R
15 | import com.codingwithmitch.openchat.common.framework.presentation.BaseMainFragment
16 | import com.codingwithmitch.openchat.common.framework.presentation.theme.AppTheme
17 | import com.codingwithmitch.openchat.session.framework.presentation.SessionStateEvent
18 | import dagger.hilt.android.AndroidEntryPoint
19 | import kotlinx.coroutines.ExperimentalCoroutinesApi
20 | import kotlinx.coroutines.launch
21 |
22 | /**
23 | * Placeholder until I figure how the backend stuff is going to work
24 | */
25 |
26 | @ExperimentalCoroutinesApi
27 | @AndroidEntryPoint
28 | class MainFragment : BaseMainFragment() {
29 |
30 | override fun onCreateView(
31 | inflater: LayoutInflater,
32 | container: ViewGroup?,
33 | savedInstanceState: Bundle?
34 | ): View? {
35 | return inflater.inflate(
36 | R.layout.compose_view, container, false
37 | ).apply {
38 | findViewById(R.id.compose_view).setContent {
39 | AppTheme(
40 | darkTheme = false,
41 | ){
42 | Column() {
43 | Text(
44 | text = "MAIN",
45 | style = MaterialTheme.typography.subtitle2
46 | )
47 | Button(
48 | onClick = {
49 | lifecycleScope.launch {
50 | executeLogout()
51 | }
52 | },
53 |
54 | ) {
55 | Text(
56 | text = "Logout",
57 | style = MaterialTheme.typography.button
58 | )
59 | }
60 | }
61 |
62 | }
63 | }
64 | }
65 | }
66 |
67 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
68 | super.onViewCreated(view, savedInstanceState)
69 | initBackPressDispatcher()
70 | }
71 |
72 | private fun executeLogout(){
73 | sessionManager.setStateEvent(SessionStateEvent.LogoutEvent())
74 | }
75 |
76 | private fun initBackPressDispatcher(){
77 | activity?.let { activity ->
78 | activity.onBackPressedDispatcher.addCallback(viewLifecycleOwner, object : OnBackPressedCallback(true) {
79 | override fun handleOnBackPressed() {
80 | /**
81 | * Work-around for known memory leak issue:
82 | * https://issuetracker.google.com/issues/139738913
83 | */
84 | if (activity.isTaskRoot) {
85 | activity.finishAfterTransition()
86 | }
87 | else {
88 | activity.onBackPressed()
89 | }
90 | }
91 | })
92 | }
93 | }
94 | }
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/business/interactors/Login.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.business.interactors
2 |
3 | import androidx.datastore.DataStore
4 | import androidx.datastore.preferences.Preferences
5 | import androidx.datastore.preferences.edit
6 | import com.codingwithmitch.openchat.account.business.domain.model.Account
7 | import com.codingwithmitch.openchat.session.business.data.cache.AuthCacheDataSource
8 | import com.codingwithmitch.openchat.session.business.data.network.AuthNetworkDataSource
9 | import com.codingwithmitch.openchat.session.business.domain.model.AuthToken
10 | import com.codingwithmitch.openchat.common.business.data.network.ApiResponseHandler
11 | import com.codingwithmitch.openchat.common.business.domain.state.DataState
12 | import com.codingwithmitch.openchat.common.business.domain.state.StateEvent
13 | import com.codingwithmitch.openchat.common.business.domain.util.printLogD
14 | import com.codingwithmitch.openchat.common.business.domain.util.safeApiCall
15 | import com.codingwithmitch.openchat.common.business.domain.util.safeCacheCall
16 | import com.codingwithmitch.openchat.session.framework.datasource.datastore.SessionPreferences.KEY_ACCOUNT_PK
17 | import com.codingwithmitch.openchat.session.framework.presentation.SessionState
18 | import com.codingwithmitch.openchat.util.DEBUG
19 | import kotlinx.coroutines.Dispatchers.IO
20 | import kotlinx.coroutines.delay
21 | import kotlinx.coroutines.flow.Flow
22 | import kotlinx.coroutines.flow.flow
23 | import javax.inject.Named
24 |
25 | class Login (
26 | private val cacheDataSource: AuthCacheDataSource,
27 | private val networkDataSource: AuthNetworkDataSource,
28 | @Named private val authPreferences: DataStore,
29 | ){
30 |
31 | suspend fun execute(
32 | stateEvent: StateEvent,
33 | email: String,
34 | password: String
35 | ): Flow?> = flow {
36 |
37 | val networkResult = safeApiCall(IO){
38 | networkDataSource.login(email, password)
39 | }
40 |
41 | if(DEBUG) delay(2000)
42 |
43 | val response = object: ApiResponseHandler(
44 | response = networkResult,
45 | stateEvent = stateEvent
46 | ){
47 | override suspend fun handleSuccess(resultObj: AuthToken): DataState? {
48 | printLogD("Login", "handleSUCESS: ${resultObj}")
49 | return DataState.data(
50 | response = null,
51 | data = SessionState(authToken = resultObj),
52 | stateEvent = stateEvent
53 | )
54 | }
55 | }.getResult()
56 |
57 | emit(response)
58 |
59 | // Cache the Account information and token.
60 | response?.data?.authToken?.let { authToken ->
61 | authToken.accountId?.let { pk ->
62 |
63 | // cache the [Account]
64 | saveAccountToCache(pk = pk, email = email)
65 |
66 | // Cache the [AuthToken]
67 | saveAuthTokenToCache(authToken = authToken)
68 |
69 | // Save [Account.pk] to Datastore for auto-login.
70 | saveAccountPkToPreferences(pk)
71 | }
72 | }
73 | }
74 |
75 | /**
76 | * Save the account to the cache. Email and PK must match server.
77 | */
78 | private suspend fun saveAccountToCache(pk: Int, email: String){
79 | safeCacheCall(IO){
80 | cacheDataSource.insertAccount(Account(id = pk, email = email))
81 | }
82 | }
83 |
84 | /**
85 | * Save the auth token to cache
86 | * 1. Enables auto-login next time app launches.
87 | * 2. Token can be easily accessed for using in request headers to server.
88 | *
89 | * WARNING:
90 | * AccountEntity must exist for this user to save the token since there is a
91 | * Foreign key relationship in [AuthTokenCacheEntity]
92 | */
93 | private suspend fun saveAuthTokenToCache(authToken: AuthToken){
94 | // delete old token. (There should only be one)
95 | safeCacheCall(IO){
96 | cacheDataSource.deleteTokens()
97 | }
98 |
99 | // save the new token to the cache
100 | safeCacheCall(IO){
101 | cacheDataSource.insertToken(authToken)
102 | }
103 | }
104 |
105 | private suspend fun saveAccountPkToPreferences(accountPk: Int){
106 | authPreferences.edit { preferences ->
107 | preferences[KEY_ACCOUNT_PK] = accountPk
108 | }
109 | }
110 | }
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/business/domain/state/DataChannelManager.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.business.domain.state
2 |
3 | import com.codingwithmitch.openchat.common.business.domain.util.cLog
4 | import com.codingwithmitch.openchat.common.business.domain.util.printLogD
5 | import kotlinx.coroutines.*
6 | import kotlinx.coroutines.Dispatchers.IO
7 | import kotlinx.coroutines.Dispatchers.Main
8 | import kotlinx.coroutines.flow.Flow
9 | import kotlinx.coroutines.flow.launchIn
10 | import kotlinx.coroutines.flow.onEach
11 |
12 | @ExperimentalCoroutinesApi
13 | abstract class DataChannelManager {
14 |
15 | private var channelScope: CoroutineScope? = null
16 | private val stateEventManager: StateEventManager = StateEventManager()
17 |
18 | val messageStack = MessageStack()
19 |
20 | val shouldDisplayProgressBar = stateEventManager.shouldDisplayProgressBar
21 |
22 | fun setupChannel(){
23 | cancelJobs()
24 | }
25 |
26 | abstract suspend fun handleNewData(data: ViewState)
27 |
28 | fun launchJob(
29 | stateEvent: StateEvent,
30 | jobFunction: Flow?>
31 | ){
32 | printLogD("DCM", "launching job: ${stateEvent}")
33 | if(canExecuteNewStateEvent(stateEvent)){
34 | addStateEvent(stateEvent)
35 | jobFunction
36 | .onEach { dataState ->
37 | dataState?.let { dState ->
38 | withContext(Main){
39 | dataState.data?.let { data ->
40 | handleNewData(data)
41 | }
42 | dataState.stateMessage?.let { stateMessage ->
43 | handleNewStateMessage(stateMessage)
44 | }
45 | dataState.stateEvent?.let { stateEvent ->
46 | removeStateEvent(stateEvent)
47 | }
48 | }
49 | }
50 | }
51 | .launchIn(getChannelScope())
52 | }
53 | else{
54 | cLog("DCM", "Tried to launch a new job when\nA) There were " +
55 | "state message(s) in the stack.\nOR\nB) The job is already active.\n" +
56 | "State message count: ${messageStack.getNumStateMessages()}\n" +
57 | "Active job(s): ${getActiveJobs()}")
58 | }
59 | }
60 |
61 | private fun canExecuteNewStateEvent(stateEvent: StateEvent): Boolean{
62 | // If a job is already active, do not allow duplication
63 | if(isJobAlreadyActive(stateEvent)){
64 | return false
65 | }
66 | // if a dialog is showing, do not allow new StateEvents
67 | if(!isMessageStackEmpty()){
68 | return false
69 | }
70 | return true
71 | }
72 |
73 | fun isMessageStackEmpty(): Boolean {
74 | return messageStack.isStackEmpty()
75 | }
76 |
77 | private fun handleNewStateMessage(stateMessage: StateMessage){
78 | appendStateMessage(stateMessage)
79 | }
80 |
81 | private fun appendStateMessage(stateMessage: StateMessage) {
82 | messageStack.add(stateMessage)
83 | }
84 |
85 | fun clearStateMessage(index: Int = 0){
86 | printLogD("DataChannelManager", "clear state message")
87 | messageStack.removeAt(index)
88 | }
89 |
90 | fun clearAllStateMessages() {
91 | printLogD("DCM", "Clearing all State Messages.")
92 | messageStack.clear()
93 | }
94 |
95 | fun printStateMessages(){
96 | for(message in messageStack){
97 | printLogD("DCM", "${message.response.message}")
98 | }
99 | }
100 |
101 | // for debugging
102 | fun getActiveJobs() = stateEventManager.getActiveJobNames()
103 |
104 | fun clearActiveStateEventCounter()
105 | = stateEventManager.clearActiveStateEventCounter()
106 |
107 | fun addStateEvent(stateEvent: StateEvent)
108 | = stateEventManager.addStateEvent(stateEvent)
109 |
110 | fun removeStateEvent(stateEvent: StateEvent?)
111 | = stateEventManager.removeStateEvent(stateEvent)
112 |
113 | private fun isStateEventActive(stateEvent: StateEvent)
114 | = stateEventManager.isStateEventActive(stateEvent)
115 |
116 | fun isJobAlreadyActive(stateEvent: StateEvent): Boolean {
117 | return isStateEventActive(stateEvent)
118 | }
119 |
120 | fun getChannelScope(): CoroutineScope {
121 | return channelScope?: setupNewChannelScope(CoroutineScope(IO))
122 | }
123 |
124 | private fun setupNewChannelScope(coroutineScope: CoroutineScope): CoroutineScope{
125 | channelScope = coroutineScope
126 | return channelScope as CoroutineScope
127 | }
128 |
129 | fun cancelJobs(){
130 | if(channelScope != null){
131 | if(channelScope?.isActive == true){
132 | channelScope?.cancel()
133 | }
134 | channelScope = null
135 | }
136 | clearActiveStateEventCounter()
137 | }
138 |
139 | }
140 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/auth/framework/presentation/AuthFragment.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.auth.framework.presentation
2 |
3 | import android.os.Bundle
4 | import android.view.LayoutInflater
5 | import android.view.View
6 | import android.view.ViewGroup
7 | import androidx.activity.OnBackPressedCallback
8 | import androidx.compose.material.MaterialTheme
9 | import androidx.compose.material.Surface
10 | import androidx.compose.runtime.*
11 | import androidx.compose.ui.focus.ExperimentalFocus
12 | import androidx.compose.ui.platform.ComposeView
13 | import androidx.fragment.app.Fragment
14 | import androidx.fragment.app.viewModels
15 | import androidx.navigation.fragment.findNavController
16 | import com.codingwithmitch.openchat.R
17 | import com.codingwithmitch.openchat.common.framework.presentation.BaseApplication
18 | import com.codingwithmitch.openchat.auth.framework.presentation.navigation.AuthScreen
19 | import com.codingwithmitch.openchat.auth.framework.presentation.screens.CreateAccountScreen
20 | import com.codingwithmitch.openchat.auth.framework.presentation.screens.LoginScreen
21 | import com.codingwithmitch.openchat.auth.framework.presentation.screens.PasswordResetScreen
22 | import com.codingwithmitch.openchat.common.framework.presentation.theme.AppTheme
23 | import com.codingwithmitch.openchat.session.framework.presentation.SessionManager
24 | import dagger.hilt.android.AndroidEntryPoint
25 | import kotlinx.coroutines.ExperimentalCoroutinesApi
26 | import javax.inject.Inject
27 |
28 | @ExperimentalFocus
29 | @ExperimentalCoroutinesApi
30 | @AndroidEntryPoint
31 | class AuthFragment: Fragment() {
32 |
33 | private val viewModel: AuthViewModel by viewModels()
34 |
35 | @Inject
36 | lateinit var sessionManager: SessionManager
37 |
38 | override fun onCreateView(
39 | inflater: LayoutInflater,
40 | container: ViewGroup?,
41 | savedInstanceState: Bundle?
42 | ): View? {
43 | return inflater.inflate(
44 | R.layout.compose_view, container, false
45 | ).apply {
46 | findViewById(R.id.compose_view).setContent {
47 | // val progressBarState by viewModel.shouldDisplayProgressBar.collectAsState()
48 | // val stateMessageState by viewModel.stateMessage.collectAsState()
49 |
50 | val progressBarState by sessionManager.shouldDisplayProgressBar.collectAsState()
51 |
52 | val stateMessageState by sessionManager.stateMessage.collectAsState()
53 |
54 | AppTheme(
55 | darkTheme = !(activity?.application as BaseApplication).isLight,
56 | progressBarIsDisplayed = progressBarState,
57 | ) {
58 | val viewState by viewModel.viewState.collectAsState()
59 | val screen = viewState.screen
60 |
61 | Surface(
62 | color = MaterialTheme.colors.background
63 | ) {
64 | when(screen){
65 | is AuthScreen.Login -> {
66 | LoginScreen(viewModel = viewModel)
67 | }
68 | is AuthScreen.PasswordReset -> {
69 | PasswordResetScreen(viewModel = viewModel)
70 | }
71 | is AuthScreen.CreateAccount -> {
72 | CreateAccountScreen(viewModel = viewModel)
73 | }
74 | }
75 | }
76 | }
77 | }
78 | }
79 | }
80 |
81 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
82 | super.onViewCreated(view, savedInstanceState)
83 | initBackPressDispatcher()
84 |
85 | sessionManager.sessionState.observe(viewLifecycleOwner, {sessionState ->
86 | if(sessionState?.authToken != null){
87 | findNavController().navigate(R.id.action_authFragment_to_mainFragment)
88 | }
89 | })
90 | }
91 |
92 | private fun initBackPressDispatcher(){
93 | activity?.let { activity ->
94 | activity.onBackPressedDispatcher.addCallback(viewLifecycleOwner, object : OnBackPressedCallback(true) {
95 | override fun handleOnBackPressed() {
96 | if(!viewModel.onBack()){
97 | isEnabled = false
98 |
99 | /**
100 | * Work-around for known memory leak issue:
101 | * https://issuetracker.google.com/issues/139738913
102 | */
103 | if (activity.isTaskRoot) {
104 | activity.finishAfterTransition()
105 | }
106 | else {
107 | activity.onBackPressed()
108 | }
109 | }
110 | }
111 | })
112 | }
113 | }
114 |
115 | }
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/business/domain/util/AsyncExtensions.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.business.domain.util
2 |
3 | import com.codingwithmitch.openchat.common.business.data.cache.CacheConstants.CACHE_TIMEOUT
4 | import com.codingwithmitch.openchat.common.business.data.cache.CacheErrors.CACHE_ERROR_TIMEOUT
5 | import com.codingwithmitch.openchat.common.business.data.cache.CacheErrors.CACHE_ERROR_UNKNOWN
6 | import com.codingwithmitch.openchat.common.business.data.cache.CacheResult
7 | import com.codingwithmitch.openchat.common.business.data.cache.exceptions.CacheException
8 | import com.codingwithmitch.openchat.common.business.data.network.ApiResult
9 | import com.codingwithmitch.openchat.common.business.data.network.NetworkConstants.NETWORK_TIMEOUT
10 | import com.codingwithmitch.openchat.common.business.data.network.NetworkErrors.NETWORK_ERROR_TIMEOUT
11 | import com.codingwithmitch.openchat.common.business.data.network.NetworkErrors.NETWORK_ERROR_UNKNOWN
12 | import com.codingwithmitch.openchat.common.business.data.network.exceptions.NetworkException
13 | import com.codingwithmitch.openchat.common.business.data.util.GenericErrors.ERROR_UNKNOWN
14 | import com.codingwithmitch.openchat.util.DEBUG
15 | import kotlinx.coroutines.CoroutineDispatcher
16 | import kotlinx.coroutines.TimeoutCancellationException
17 | import kotlinx.coroutines.withContext
18 | import kotlinx.coroutines.withTimeout
19 | import retrofit2.HttpException
20 | import java.io.IOException
21 |
22 | /**
23 | * Reference: https://medium.com/@douglas.iacovelli/how-to-handle-errors-with-retrofit-and-coroutines-33e7492a912
24 | */
25 |
26 | suspend fun safeApiCall(
27 | dispatcher: CoroutineDispatcher,
28 | apiCall: suspend () -> T?
29 | ): ApiResult {
30 | return withContext(dispatcher) {
31 | try {
32 | // throws TimeoutCancellationException
33 | withTimeout(NETWORK_TIMEOUT){
34 | ApiResult.Success(apiCall.invoke())
35 | }
36 | } catch (throwable: Throwable) {
37 | if(DEBUG){
38 | throwable.printStackTrace()
39 | }
40 | when (throwable) {
41 | is NetworkException -> {
42 | cLog(className = "SafeApiCall: NETWORK EXCEPTION", message = throwable.message?:NETWORK_ERROR_UNKNOWN)
43 | ApiResult.NetworkError(throwable.message?:NETWORK_ERROR_UNKNOWN)
44 | }
45 | is TimeoutCancellationException -> {
46 | cLog(className = "SafeApiCall", message = NETWORK_ERROR_TIMEOUT)
47 | val code = 408 // timeout error code
48 | ApiResult.GenericError(code, NETWORK_ERROR_TIMEOUT)
49 | }
50 | is IOException -> {
51 | cLog(className = "SafeApiCall", message = throwable.message?:NETWORK_ERROR_UNKNOWN)
52 | ApiResult.NetworkError(throwable.message?:NETWORK_ERROR_UNKNOWN)
53 | }
54 | is HttpException -> {
55 | val code = throwable.code()
56 | val errorResponse = convertErrorBody(throwable)
57 | cLog(className = "SafeApiCall", message = errorResponse)
58 | ApiResult.GenericError(
59 | code,
60 | errorResponse
61 | )
62 | }
63 | else -> {
64 | cLog(className = "SafeApiCall", message = NETWORK_ERROR_UNKNOWN)
65 | ApiResult.GenericError(
66 | null,
67 | NETWORK_ERROR_UNKNOWN
68 | )
69 | }
70 | }
71 | }
72 | }
73 | }
74 |
75 |
76 | suspend fun safeCacheCall(
77 | dispatcher: CoroutineDispatcher,
78 | cacheCall: suspend () -> T?
79 | ): CacheResult {
80 | return withContext(dispatcher) {
81 | try {
82 | // throws TimeoutCancellationException
83 | withTimeout(CACHE_TIMEOUT){
84 | CacheResult.Success(cacheCall.invoke())
85 | }
86 | } catch (throwable: Throwable) {
87 | cLog("SafeCacheCall", "${throwable.printStackTrace()}")
88 | when (throwable) {
89 | is CacheException -> {
90 | cLog("SafeCacheCall: CacheException: ", throwable.message?: CACHE_ERROR_UNKNOWN)
91 | CacheResult.GenericError(throwable.message?: CACHE_ERROR_UNKNOWN)
92 | }
93 |
94 | is TimeoutCancellationException -> {
95 | cLog("SafeCacheCall: TimeoutCancellationException: ", CACHE_ERROR_TIMEOUT)
96 | CacheResult.GenericError(CACHE_ERROR_TIMEOUT)
97 | }
98 | else -> {
99 | cLog("SafeCacheCall: ELSE: ", CACHE_ERROR_UNKNOWN)
100 | CacheResult.GenericError(CACHE_ERROR_UNKNOWN)
101 | }
102 | }
103 | }
104 | }
105 | }
106 |
107 | private fun convertErrorBody(throwable: HttpException): String? {
108 | return try {
109 | throwable.response()?.errorBody()?.string()
110 | } catch (exception: Exception) {
111 | ERROR_UNKNOWN
112 | }
113 | }
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/common/framework/presentation/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.common.framework.presentation.theme
2 |
3 | import androidx.compose.material.Typography
4 | import androidx.compose.ui.graphics.Color
5 | import androidx.compose.ui.text.TextStyle
6 | import androidx.compose.ui.text.font.FontWeight
7 | import androidx.compose.ui.text.font.font
8 | import androidx.compose.ui.text.font.fontFamily
9 | import androidx.compose.ui.unit.sp
10 | import com.codingwithmitch.openchat.R
11 |
12 | private val Ubuntu = fontFamily(
13 | font(R.font.ubuntu_light, FontWeight.W300),
14 | font(R.font.ubuntu_regular, FontWeight.W400),
15 | font(R.font.ubuntu_medium, FontWeight.W500),
16 | font(R.font.ubuntu_bold, FontWeight.W600)
17 | )
18 |
19 | private val Roboto = fontFamily(
20 | font(R.font.roboto_light, FontWeight.W300),
21 | font(R.font.roboto_regular, FontWeight.W400),
22 | font(R.font.roboto_medium, FontWeight.W500),
23 | font(R.font.roboto_bold, FontWeight.W600)
24 | )
25 |
26 | val RobotoTypography = Typography(
27 | h1 = TextStyle(
28 | fontFamily = Roboto,
29 | fontWeight = FontWeight.W500,
30 | fontSize = 30.sp,
31 | ),
32 | h2 = TextStyle(
33 | fontFamily = Roboto,
34 | fontWeight = FontWeight.W500,
35 | fontSize = 24.sp,
36 | ),
37 | h3 = TextStyle(
38 | fontFamily = Roboto,
39 | fontWeight = FontWeight.W500,
40 | fontSize = 20.sp,
41 | ),
42 | h4 = TextStyle(
43 | fontFamily = Roboto,
44 | fontWeight = FontWeight.W400,
45 | fontSize = 18.sp,
46 | ),
47 | h5 = TextStyle(
48 | fontFamily = Roboto,
49 | fontWeight = FontWeight.W400,
50 | fontSize = 16.sp,
51 | ),
52 | h6 = TextStyle(
53 | fontFamily = Roboto,
54 | fontWeight = FontWeight.W400,
55 | fontSize = 14.sp,
56 | ),
57 | subtitle1 = TextStyle(
58 | fontFamily = Roboto,
59 | fontWeight = FontWeight.W500,
60 | fontSize = 16.sp,
61 | ),
62 | subtitle2 = TextStyle(
63 | fontFamily = Roboto,
64 | fontWeight = FontWeight.W400,
65 | fontSize = 14.sp,
66 | ),
67 | body1 = TextStyle(
68 | fontFamily = Roboto,
69 | fontWeight = FontWeight.Normal,
70 | fontSize = 16.sp
71 | ),
72 | body2 = TextStyle(
73 | fontFamily = Roboto,
74 | fontSize = 14.sp
75 | ),
76 | button = TextStyle(
77 | fontFamily = Roboto,
78 | fontWeight = FontWeight.W400,
79 | fontSize = 15.sp,
80 | color = Color.White
81 | ),
82 | caption = TextStyle(
83 | fontFamily = Roboto,
84 | fontWeight = FontWeight.Normal,
85 | fontSize = 12.sp
86 | ),
87 | overline = TextStyle(
88 | fontFamily = Roboto,
89 | fontWeight = FontWeight.W400,
90 | fontSize = 12.sp
91 | )
92 | )
93 |
94 | val UbuntuAppTypography = Typography(
95 | h1 = TextStyle(
96 | fontFamily = Ubuntu,
97 | fontWeight = FontWeight.W600,
98 | fontSize = 30.sp,
99 | ),
100 | h2 = TextStyle(
101 | fontFamily = Ubuntu,
102 | fontWeight = FontWeight.W600,
103 | fontSize = 24.sp,
104 | ),
105 | h3 = TextStyle(
106 | fontFamily = Ubuntu,
107 | fontWeight = FontWeight.W600,
108 | fontSize = 20.sp,
109 | ),
110 | h4 = TextStyle(
111 | fontFamily = Ubuntu,
112 | fontWeight = FontWeight.W500,
113 | fontSize = 18.sp,
114 | ),
115 | h5 = TextStyle(
116 | fontFamily = Ubuntu,
117 | fontWeight = FontWeight.W500,
118 | fontSize = 16.sp,
119 | ),
120 | h6 = TextStyle(
121 | fontFamily = Ubuntu,
122 | fontWeight = FontWeight.W500,
123 | fontSize = 14.sp,
124 | ),
125 | subtitle1 = TextStyle(
126 | fontFamily = Ubuntu,
127 | fontWeight = FontWeight.W600,
128 | fontSize = 16.sp,
129 | ),
130 | subtitle2 = TextStyle(
131 | fontFamily = Ubuntu,
132 | fontWeight = FontWeight.W500,
133 | fontSize = 14.sp,
134 | ),
135 | body1 = TextStyle(
136 | fontFamily = Ubuntu,
137 | fontWeight = FontWeight.Normal,
138 | fontSize = 16.sp
139 | ),
140 | body2 = TextStyle(
141 | fontFamily = Ubuntu,
142 | fontSize = 14.sp
143 | ),
144 | button = TextStyle(
145 | fontFamily = Ubuntu,
146 | fontWeight = FontWeight.W500,
147 | fontSize = 14.sp,
148 | color = Color.White
149 | ),
150 | caption = TextStyle(
151 | fontFamily = Ubuntu,
152 | fontWeight = FontWeight.Normal,
153 | fontSize = 12.sp
154 | ),
155 | overline = TextStyle(
156 | fontFamily = Ubuntu,
157 | fontWeight = FontWeight.W500,
158 | fontSize = 12.sp
159 | )
160 | )
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | ##############################################################################
4 | ##
5 | ## Gradle start up script for UN*X
6 | ##
7 | ##############################################################################
8 |
9 | # Attempt to set APP_HOME
10 | # Resolve links: $0 may be a link
11 | PRG="$0"
12 | # Need this for relative symlinks.
13 | while [ -h "$PRG" ] ; do
14 | ls=`ls -ld "$PRG"`
15 | link=`expr "$ls" : '.*-> \(.*\)$'`
16 | if expr "$link" : '/.*' > /dev/null; then
17 | PRG="$link"
18 | else
19 | PRG=`dirname "$PRG"`"/$link"
20 | fi
21 | done
22 | SAVED="`pwd`"
23 | cd "`dirname \"$PRG\"`/" >/dev/null
24 | APP_HOME="`pwd -P`"
25 | cd "$SAVED" >/dev/null
26 |
27 | APP_NAME="Gradle"
28 | APP_BASE_NAME=`basename "$0"`
29 |
30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
31 | DEFAULT_JVM_OPTS=""
32 |
33 | # Use the maximum available, or set MAX_FD != -1 to use that value.
34 | MAX_FD="maximum"
35 |
36 | warn () {
37 | echo "$*"
38 | }
39 |
40 | die () {
41 | echo
42 | echo "$*"
43 | echo
44 | exit 1
45 | }
46 |
47 | # OS specific support (must be 'true' or 'false').
48 | cygwin=false
49 | msys=false
50 | darwin=false
51 | nonstop=false
52 | case "`uname`" in
53 | CYGWIN* )
54 | cygwin=true
55 | ;;
56 | Darwin* )
57 | darwin=true
58 | ;;
59 | MINGW* )
60 | msys=true
61 | ;;
62 | NONSTOP* )
63 | nonstop=true
64 | ;;
65 | esac
66 |
67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
68 |
69 | # Determine the Java command to use to start the JVM.
70 | if [ -n "$JAVA_HOME" ] ; then
71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
72 | # IBM's JDK on AIX uses strange locations for the executables
73 | JAVACMD="$JAVA_HOME/jre/sh/java"
74 | else
75 | JAVACMD="$JAVA_HOME/bin/java"
76 | fi
77 | if [ ! -x "$JAVACMD" ] ; then
78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
79 |
80 | Please set the JAVA_HOME variable in your environment to match the
81 | location of your Java installation."
82 | fi
83 | else
84 | JAVACMD="java"
85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
86 |
87 | Please set the JAVA_HOME variable in your environment to match the
88 | location of your Java installation."
89 | fi
90 |
91 | # Increase the maximum file descriptors if we can.
92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
93 | MAX_FD_LIMIT=`ulimit -H -n`
94 | if [ $? -eq 0 ] ; then
95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
96 | MAX_FD="$MAX_FD_LIMIT"
97 | fi
98 | ulimit -n $MAX_FD
99 | if [ $? -ne 0 ] ; then
100 | warn "Could not set maximum file descriptor limit: $MAX_FD"
101 | fi
102 | else
103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
104 | fi
105 | fi
106 |
107 | # For Darwin, add options to specify how the application appears in the dock
108 | if $darwin; then
109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
110 | fi
111 |
112 | # For Cygwin, switch paths to Windows format before running java
113 | if $cygwin ; then
114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
116 | JAVACMD=`cygpath --unix "$JAVACMD"`
117 |
118 | # We build the pattern for arguments to be converted via cygpath
119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
120 | SEP=""
121 | for dir in $ROOTDIRSRAW ; do
122 | ROOTDIRS="$ROOTDIRS$SEP$dir"
123 | SEP="|"
124 | done
125 | OURCYGPATTERN="(^($ROOTDIRS))"
126 | # Add a user-defined pattern to the cygpath arguments
127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
129 | fi
130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
131 | i=0
132 | for arg in "$@" ; do
133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
135 |
136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
138 | else
139 | eval `echo args$i`="\"$arg\""
140 | fi
141 | i=$((i+1))
142 | done
143 | case $i in
144 | (0) set -- ;;
145 | (1) set -- "$args0" ;;
146 | (2) set -- "$args0" "$args1" ;;
147 | (3) set -- "$args0" "$args1" "$args2" ;;
148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
154 | esac
155 | fi
156 |
157 | # Escape application args
158 | save () {
159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
160 | echo " "
161 | }
162 | APP_ARGS=$(save "$@")
163 |
164 | # Collect all arguments for the java command, following the shell quoting and substitution rules
165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
166 |
167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
169 | cd "$(dirname "$0")"
170 | fi
171 |
172 | exec "$JAVACMD" "$@"
173 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
15 |
20 |
25 |
30 |
35 |
40 |
45 |
50 |
55 |
60 |
65 |
70 |
75 |
80 |
85 |
90 |
95 |
100 |
105 |
110 |
115 |
120 |
125 |
130 |
135 |
140 |
145 |
150 |
155 |
160 |
165 |
170 |
171 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/session/framework/presentation/SessionManager.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.session.framework.presentation
2 |
3 | import androidx.datastore.DataStore
4 | import androidx.datastore.preferences.Preferences
5 | import androidx.lifecycle.LiveData
6 | import androidx.lifecycle.MutableLiveData
7 | import com.codingwithmitch.openchat.session.business.domain.model.AuthToken
8 | import com.codingwithmitch.openchat.common.business.data.util.GenericErrors
9 | import com.codingwithmitch.openchat.common.business.domain.state.*
10 | import com.codingwithmitch.openchat.session.framework.datasource.datastore.SessionPreferences.KEY_ACCOUNT_PK
11 | import com.codingwithmitch.openchat.session.framework.presentation.SessionStateEvent.*
12 | import com.codingwithmitch.openchat.session.business.interactors.CheckAuthToken
13 | import com.codingwithmitch.openchat.session.business.interactors.Login
14 | import com.codingwithmitch.openchat.session.business.interactors.Logout
15 | import kotlinx.coroutines.CoroutineScope
16 | import kotlinx.coroutines.Dispatchers.IO
17 | import kotlinx.coroutines.Dispatchers.Main
18 | import kotlinx.coroutines.ExperimentalCoroutinesApi
19 | import kotlinx.coroutines.flow.*
20 | import kotlinx.coroutines.launch
21 | import kotlinx.coroutines.withContext
22 | import javax.inject.Inject
23 | import javax.inject.Named
24 | import javax.inject.Singleton
25 |
26 | @ExperimentalCoroutinesApi
27 | @Singleton
28 | class SessionManager
29 | @Inject
30 | constructor(
31 | private val logoutUseCase: Logout,
32 | private val loginUseCase: Login,
33 | private val checkAuthTokenUseCase: CheckAuthToken,
34 | @Named("auth_preferences") private val authPreferences: DataStore,
35 | ) {
36 |
37 | private val _sessionState: MutableLiveData = MutableLiveData()
38 |
39 | val sessionState: LiveData get() = _sessionState
40 |
41 | private var sessionScope: CoroutineScope? = null
42 |
43 | /**
44 | * ----------------------------------------------------------
45 | * GENERIC FOR BASE VIEWMODEL?!
46 | * ----------------------------------------------------------
47 | */
48 | val dataChannelManager: DataChannelManager
49 | = object: DataChannelManager(){
50 |
51 | override suspend fun handleNewData(data: SessionState) {
52 | this@SessionManager.handleNewData(data)
53 | }
54 | }
55 |
56 | init {
57 | setupChannel()
58 | }
59 |
60 | val shouldDisplayProgressBar: StateFlow
61 | = dataChannelManager.shouldDisplayProgressBar
62 |
63 | val stateMessage: StateFlow
64 | get() = dataChannelManager.messageStack.stateMessage
65 |
66 | fun setupChannel() = dataChannelManager.setupChannel()
67 |
68 | suspend fun handleNewData(data: SessionState){
69 | data.authToken?.let { authToken ->
70 | onLoginSuccess(authToken = authToken)
71 | }
72 | }
73 |
74 | fun emitStateMessageEvent(
75 | stateMessage: StateMessage,
76 | stateEvent: StateEvent
77 | ) = flow{
78 | emit(
79 | DataState.error(
80 | response = stateMessage.response,
81 | stateEvent = stateEvent
82 | )
83 | )
84 | }
85 |
86 | fun emitInvalidStateEvent(stateEvent: StateEvent) = flow {
87 | emit(
88 | DataState.error(
89 | response = Response(
90 | message = GenericErrors.INVALID_STATE_EVENT,
91 | uiComponentType = UIComponentType.None(),
92 | messageType = MessageType.Error()
93 | ),
94 | stateEvent = stateEvent
95 | )
96 | )
97 | }
98 |
99 | fun clearStateMessage(index: Int = 0){
100 | dataChannelManager.clearStateMessage(index)
101 | }
102 |
103 | fun clearActiveStateEvents() = dataChannelManager.clearActiveStateEventCounter()
104 |
105 | fun clearAllStateMessages() = dataChannelManager.clearAllStateMessages()
106 |
107 | fun printStateMessages() = dataChannelManager.printStateMessages()
108 |
109 | fun cancelActiveJobs() = dataChannelManager.cancelJobs()
110 |
111 | fun launchJob(
112 | stateEvent: StateEvent,
113 | jobFunction: Flow?>
114 | ) = dataChannelManager.launchJob(stateEvent, jobFunction)
115 |
116 |
117 | fun setStateEvent(stateEvent: SessionStateEvent){
118 | getSessionManagerScope().launch {
119 | val job: Flow?> = when(stateEvent){
120 |
121 | is LogoutEvent -> {
122 | onLogout()
123 | logoutUseCase.execute(stateEvent = stateEvent)
124 | }
125 | is LoginEvent -> {
126 | loginUseCase.execute(
127 | stateEvent = stateEvent,
128 | email = stateEvent.email,
129 | password = stateEvent.password
130 | )
131 | }
132 |
133 | is CheckAuthTokenEvent -> {
134 | checkAuthTokenUseCase.execute(
135 | stateEvent = stateEvent,
136 | accountPk = stateEvent.accountPk
137 | )
138 | }
139 | }
140 | launchJob(stateEvent, job)
141 | }
142 | }
143 |
144 | /**
145 | * ----------------------------------------------------------
146 | * GENERIC FOR BASE VIEWMODEL?!
147 | * ----------------------------------------------------------
148 | */
149 |
150 | fun checkAuthToken(){
151 | authPreferences.data.onEach { preferences ->
152 | preferences.get(KEY_ACCOUNT_PK)?.let { pk ->
153 | setStateEvent(CheckAuthTokenEvent(pk))
154 | }
155 | }.launchIn(getSessionManagerScope())
156 |
157 | }
158 |
159 | suspend fun onLoginSuccess(authToken: AuthToken){
160 | withContext(Main){
161 | _sessionState.value = SessionState(authToken)
162 | }
163 | }
164 |
165 | private suspend fun onLogout(){
166 | withContext(Main){
167 | _sessionState.value = SessionState(authToken = null)
168 | }
169 | }
170 |
171 | private fun getSessionManagerScope(): CoroutineScope{
172 | if(sessionScope == null){
173 | sessionScope = CoroutineScope(IO)
174 | }
175 | return sessionScope as CoroutineScope
176 | }
177 | }
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/auth/framework/presentation/screens/PasswordResetScreen.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.auth.framework.presentation.screens
2 |
3 | import androidx.compose.foundation.ScrollableColumn
4 | import androidx.compose.foundation.layout.*
5 | import androidx.compose.foundation.shape.RoundedCornerShape
6 | import androidx.compose.material.Button
7 | import androidx.compose.material.Card
8 | import androidx.compose.material.MaterialTheme
9 | import androidx.compose.material.Text
10 | import androidx.compose.runtime.*
11 | import androidx.compose.ui.Alignment
12 | import androidx.compose.ui.Modifier
13 | import androidx.compose.ui.focus
14 | import androidx.compose.ui.focus.ExperimentalFocus
15 | import androidx.compose.ui.focus.FocusRequester
16 | import androidx.compose.ui.focusRequester
17 | import androidx.compose.ui.platform.ContextAmbient
18 | import androidx.compose.ui.text.input.ImeAction
19 | import androidx.compose.ui.unit.Dp
20 | import androidx.compose.ui.unit.dp
21 | import com.codingwithmitch.openchat.R
22 | import com.codingwithmitch.openchat.auth.framework.presentation.AuthViewModel
23 | import com.codingwithmitch.openchat.auth.framework.presentation.state.AuthViewState.*
24 | import com.codingwithmitch.openchat.common.framework.presentation.components.EmailInputField
25 | import kotlinx.coroutines.ExperimentalCoroutinesApi
26 |
27 | @ExperimentalFocus
28 | @ExperimentalCoroutinesApi
29 | @Composable
30 | fun PasswordResetScreen(
31 | viewModel: AuthViewModel,
32 | ){
33 | val viewState by viewModel.viewState.collectAsState()
34 |
35 | val emailState = viewState.passwordResetEmailState
36 | var passwordResetExecuted by mutableStateOf(false)
37 |
38 | val defaultPadding = ContextAmbient.current.resources.getDimension(R.dimen.default_padding).dp
39 | val defaultElevation = ContextAmbient.current.resources.getDimension(R.dimen.default_elevation).dp
40 | val smallPadding = ContextAmbient.current.resources.getDimension(R.dimen.small_padding).dp
41 | val mediumPadding = ContextAmbient.current.resources.getDimension(R.dimen.medium_padding).dp
42 | val smallCornerRadius = ContextAmbient.current.resources.getDimension(R.dimen.small_corner_radius)
43 |
44 | ConstraintLayout(
45 | modifier = Modifier
46 | .fillMaxSize()
47 | ) {
48 | val (card) = createRefs()
49 | Card(
50 | modifier = Modifier
51 | .fillMaxWidth()
52 | .padding(mediumPadding)
53 | .constrainAs(card) {
54 | top.linkTo(parent.top)
55 | bottom.linkTo(parent.bottom)
56 | end.linkTo(parent.end)
57 | start.linkTo(parent.start)
58 | }
59 | .focus(),
60 | shape = RoundedCornerShape(smallCornerRadius),
61 | elevation = defaultElevation,
62 | ) {
63 | ScrollableColumn() {
64 | if(!passwordResetExecuted){
65 | PasswordResetFields(
66 | viewModel = viewModel,
67 | emailState = emailState,
68 | smallPadding = smallPadding,
69 | mediumPadding = mediumPadding,
70 | onSendPasswordResetEmail = viewModel::onSendPasswordResetEmail,
71 | )
72 | }
73 | else{
74 | PasswordResetSuccess(
75 | viewModel = viewModel,
76 | email = emailState.text,
77 | smallPadding = smallPadding,
78 | mediumPadding = mediumPadding,
79 | )
80 | }
81 | }
82 | }
83 | }
84 | }
85 |
86 | @ExperimentalCoroutinesApi
87 | @ExperimentalFocus
88 | @Composable
89 | fun PasswordResetFields(
90 | viewModel: AuthViewModel,
91 | emailState: PasswordResetEmailState,
92 | smallPadding: Dp,
93 | mediumPadding: Dp,
94 | onSendPasswordResetEmail: () -> Unit,
95 | ){
96 | val focusRequester = remember { FocusRequester() }
97 |
98 | Column(
99 | modifier = Modifier
100 | .padding(
101 | top = mediumPadding,
102 | bottom = mediumPadding,
103 | start = smallPadding,
104 | end = smallPadding
105 | ),
106 | ){
107 | PasswordResetEmailField(
108 | emailState = emailState,
109 | onEmailChanged = viewModel::onPasswordResetEmailChanged,
110 | focusRequester = focusRequester
111 | )
112 | Spacer(modifier = Modifier.preferredHeight(smallPadding))
113 | Button(
114 | modifier = Modifier
115 | .align(Alignment.End)
116 | .focusRequester(focusRequester)
117 | .focus(), // Make this button "focusable"
118 | onClick = {
119 | onSendPasswordResetEmail()
120 | focusRequester.requestFocus()
121 | },
122 | )
123 | {
124 | Text(
125 | text = "Reset",
126 | style = MaterialTheme.typography.button
127 | )
128 | }
129 | }
130 | }
131 |
132 |
133 | @ExperimentalFocus
134 | @Composable
135 | fun PasswordResetEmailField(
136 | emailState: PasswordResetEmailState,
137 | onEmailChanged: (String) -> Unit,
138 | focusRequester: FocusRequester,
139 | ){
140 | EmailInputField(
141 | emailState = emailState,
142 | onEmailChanged = onEmailChanged,
143 | modifier = Modifier
144 | .fillMaxWidth(),
145 | imeAction = ImeAction.Next,
146 | onImeAction = {
147 | focusRequester.requestFocus()
148 | },
149 | )
150 | }
151 |
152 | @ExperimentalCoroutinesApi
153 | @Composable
154 | fun PasswordResetSuccess(
155 | viewModel: AuthViewModel,
156 | email: String,
157 | smallPadding: Dp,
158 | mediumPadding: Dp,
159 |
160 | ){
161 | var doesEmailExist = true
162 | Column(
163 | modifier = Modifier
164 | .padding(
165 | top = mediumPadding,
166 | bottom = mediumPadding,
167 | start = smallPadding,
168 | end = smallPadding
169 | ),
170 | ){
171 | if(doesEmailExist){
172 | Text(
173 | modifier = Modifier.fillMaxWidth(),
174 | text = "We sent an email to ${email}.\nCheck your inbox for instructions on how to reset your password."
175 | )
176 | }
177 | else{
178 | Text(
179 | modifier = Modifier.fillMaxWidth(),
180 | text = "The email ${email} does not exist on our servers."
181 | )
182 | }
183 | Spacer(modifier = Modifier.preferredHeight(smallPadding))
184 | Button(
185 | modifier = Modifier
186 | .fillMaxWidth(),
187 | onClick = {
188 | viewModel.onBack()
189 | },
190 |
191 | ) {
192 | Text(
193 | text = "Ok",
194 | style = MaterialTheme.typography.button
195 | )
196 | }
197 | }
198 | }
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/auth/framework/presentation/screens/CreateAccountScreen.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.auth.framework.presentation.screens
2 |
3 | import androidx.compose.foundation.ScrollableColumn
4 | import androidx.compose.foundation.layout.*
5 | import androidx.compose.foundation.shape.RoundedCornerShape
6 | import androidx.compose.material.Button
7 | import androidx.compose.material.Card
8 | import androidx.compose.material.MaterialTheme
9 | import androidx.compose.material.Text
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.runtime.collectAsState
12 | import androidx.compose.runtime.getValue
13 | import androidx.compose.runtime.remember
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.focus
16 | import androidx.compose.ui.focus.ExperimentalFocus
17 | import androidx.compose.ui.focus.FocusRequester
18 | import androidx.compose.ui.focusRequester
19 | import androidx.compose.ui.platform.ContextAmbient
20 | import androidx.compose.ui.text.input.ImeAction
21 | import androidx.compose.ui.unit.Dp
22 | import androidx.compose.ui.unit.dp
23 | import com.codingwithmitch.openchat.R
24 | import com.codingwithmitch.openchat.auth.framework.presentation.AuthViewModel
25 | import com.codingwithmitch.openchat.auth.framework.presentation.state.AuthViewState.*
26 | import com.codingwithmitch.openchat.common.framework.presentation.components.EmailInputField
27 | import com.codingwithmitch.openchat.common.framework.presentation.components.PasswordInputField
28 | import com.codingwithmitch.openchat.common.framework.presentation.components.TextFieldError
29 | import com.codingwithmitch.openchat.common.framework.presentation.components.UsernameInputField
30 | import kotlinx.coroutines.ExperimentalCoroutinesApi
31 |
32 | @ExperimentalFocus
33 | @ExperimentalCoroutinesApi
34 | @Composable
35 | fun CreateAccountScreen(
36 | viewModel: AuthViewModel,
37 | ){
38 | val viewState by viewModel.viewState.collectAsState()
39 |
40 | val emailState = viewState.createEmailState
41 | val usernameState = viewState.createUsernameState
42 | val passwordState = viewState.createPasswordState
43 |
44 | val defaultPadding = ContextAmbient.current.resources.getDimension(R.dimen.default_padding).dp
45 | val defaultElevation = ContextAmbient.current.resources.getDimension(R.dimen.default_elevation).dp
46 | val smallPadding = ContextAmbient.current.resources.getDimension(R.dimen.small_padding).dp
47 | val mediumPadding = ContextAmbient.current.resources.getDimension(R.dimen.medium_padding).dp
48 | val smallCornerRadius = ContextAmbient.current.resources.getDimension(R.dimen.small_corner_radius)
49 |
50 | ConstraintLayout(
51 | modifier = Modifier
52 | .fillMaxSize()
53 | ) {
54 | val (card) = createRefs()
55 | Card(
56 | modifier = Modifier
57 | .fillMaxWidth()
58 | .padding(mediumPadding)
59 | .constrainAs(card) {
60 | top.linkTo(parent.top)
61 | bottom.linkTo(parent.bottom)
62 | end.linkTo(parent.end)
63 | start.linkTo(parent.start)
64 | }
65 | .focus(),
66 | shape = RoundedCornerShape(smallCornerRadius),
67 | elevation = defaultElevation,
68 | ) {
69 | ScrollableColumn() {
70 | CreateAccountFields(
71 | emailState = emailState,
72 | onEmailChanged = viewModel::onCreateEmailChanged,
73 | usernameState = usernameState,
74 | onUsernameChanged = viewModel::onCreateUsernameChanged,
75 | passwordState = passwordState,
76 | onPassword1Changed = viewModel::onPassword1Changed,
77 | onShowPassword1Changed = viewModel::setShowPassword1,
78 | onPassword2Changed = viewModel::onPassword2Changed,
79 | onShowPassword2Changed = viewModel::setShowPassword2,
80 | smallPadding = smallPadding,
81 | mediumPadding = mediumPadding,
82 | onAttemptCreateAccount = viewModel::onAttemptCreateAccount
83 | )
84 | }
85 | }
86 | }
87 | }
88 |
89 |
90 | @ExperimentalFocus
91 | @ExperimentalCoroutinesApi
92 | @Composable
93 | fun CreateAccountFields(
94 | emailState: CreateEmailState,
95 | onEmailChanged: (String) -> Unit,
96 | usernameState: CreateUsernameState,
97 | onUsernameChanged: (String) -> Unit,
98 | passwordState: CreatePasswordState,
99 | onPassword1Changed: (String) -> Unit,
100 | onShowPassword1Changed: (Boolean) -> Unit,
101 | onPassword2Changed: (String) -> Unit,
102 | onShowPassword2Changed: (Boolean) -> Unit,
103 | smallPadding: Dp,
104 | mediumPadding: Dp,
105 | onAttemptCreateAccount: () -> Unit,
106 | ){
107 | val usernameFocusRequester = remember { FocusRequester() }
108 | val password1FocusRequester = remember { FocusRequester() }
109 | val password2FocusRequester = remember { FocusRequester() }
110 | val createAccountFocusRequester = remember { FocusRequester() }
111 |
112 | val showPassword1 = passwordState.password1.showPassword
113 | val showPassword2 = passwordState.password2.showPassword
114 |
115 | Column(
116 | modifier = Modifier
117 | .padding(
118 | top = mediumPadding,
119 | bottom = mediumPadding,
120 | start = smallPadding,
121 | end = smallPadding
122 | ),
123 | ){
124 | EmailInputField(
125 | emailState = emailState,
126 | onEmailChanged = onEmailChanged,
127 | modifier = Modifier
128 | .fillMaxWidth(),
129 | imeAction = ImeAction.Next,
130 | onImeAction = {
131 | usernameFocusRequester.requestFocus()
132 | },
133 | )
134 | Spacer(modifier = Modifier.preferredHeight(smallPadding))
135 | UsernameInputField(
136 | usernameState = usernameState,
137 | onUsernameChanged = onUsernameChanged,
138 | modifier = Modifier
139 | .fillMaxWidth()
140 | .focusRequester(usernameFocusRequester),
141 | imeAction = ImeAction.Next,
142 | onImeAction = {
143 | password1FocusRequester.requestFocus()
144 | },
145 | )
146 | Spacer(modifier = Modifier.preferredHeight(smallPadding))
147 | PasswordInputField(
148 | passwordState = passwordState.password1,
149 | onPasswordChange = onPassword1Changed,
150 | modifier = Modifier
151 | .fillMaxWidth()
152 | .focusRequester(password1FocusRequester)
153 | ,
154 | imeAction = ImeAction.Next,
155 | onImeAction = {
156 | password2FocusRequester.requestFocus()
157 | },
158 | showPassword = showPassword1,
159 | onShowPasswordChange = onShowPassword1Changed
160 | )
161 | Spacer(modifier = Modifier.preferredHeight(smallPadding))
162 | PasswordInputField(
163 | passwordState = passwordState.password2,
164 | onPasswordChange = onPassword2Changed,
165 | modifier = Modifier
166 | .fillMaxWidth()
167 | .focusRequester(password2FocusRequester)
168 | ,
169 | imeAction = ImeAction.Done,
170 | onImeAction = {
171 | createAccountFocusRequester.requestFocus()
172 | },
173 | showPassword = showPassword2,
174 | onShowPasswordChange = onShowPassword2Changed
175 | )
176 | if(passwordState.isErrors()) TextFieldError(textError = passwordState.getErrorMessage())
177 | Spacer(modifier = Modifier.preferredHeight(mediumPadding))
178 | Button(
179 | modifier = Modifier
180 | .fillMaxWidth()
181 | .focusRequester(createAccountFocusRequester)
182 | .focus(), // Make this button "focusable"
183 | onClick = {
184 | createAccountFocusRequester.requestFocus()
185 | onAttemptCreateAccount()
186 | },
187 |
188 | ) {
189 | Text(
190 | text = "Create Account",
191 | style = MaterialTheme.typography.button
192 | )
193 | }
194 | }
195 | }
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 |
217 |
218 |
219 |
220 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/openchat/auth/framework/presentation/AuthViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.openchat.auth.framework.presentation
2 |
3 | import androidx.hilt.Assisted
4 | import androidx.hilt.lifecycle.ViewModelInject
5 | import androidx.lifecycle.SavedStateHandle
6 | import androidx.lifecycle.ViewModel
7 | import com.codingwithmitch.openchat.auth.framework.presentation.navigation.AuthScreen
8 | import com.codingwithmitch.openchat.auth.framework.presentation.state.*
9 | import com.codingwithmitch.openchat.auth.framework.presentation.state.AuthViewState.*
10 | import com.codingwithmitch.openchat.auth.framework.presentation.state.AuthViewState.CreatePasswordState.*
11 | import com.codingwithmitch.openchat.common.business.domain.util.printLogD
12 | import com.codingwithmitch.openchat.session.framework.presentation.SessionManager
13 | import com.codingwithmitch.openchat.session.framework.presentation.SessionStateEvent.*
14 | import kotlinx.coroutines.ExperimentalCoroutinesApi
15 | import kotlinx.coroutines.flow.MutableStateFlow
16 | import kotlinx.coroutines.flow.StateFlow
17 |
18 |
19 | @ExperimentalCoroutinesApi
20 | class AuthViewModel
21 | @ViewModelInject
22 | constructor(
23 | @Assisted private val savedStateHandle: SavedStateHandle,
24 | private val sessionManager: SessionManager,
25 | ): ViewModel(){
26 |
27 | private val _viewState: MutableStateFlow = MutableStateFlow(AuthViewState())
28 |
29 | val viewState: StateFlow get() = _viewState
30 |
31 | init {
32 | // Restore the ViewState after process death
33 | savedStateHandle.get(BUNDLE_KEY_AUTH_VIEWSTATE)?.let { bundle ->
34 | val viewState = bundle.restoreViewState()
35 | setViewState(viewState)
36 | navigateTo(viewState.screen)
37 | }
38 | }
39 |
40 | fun navigateTo(screen: AuthScreen) {
41 | val new = buildNewViewState(screen = screen)
42 | setViewState(new)
43 | }
44 |
45 | fun onBack(): Boolean {
46 | val wasHandled = _viewState.value.screen != AuthScreen.Login
47 | navigateTo(AuthScreen.Login)
48 | return wasHandled
49 | }
50 |
51 | fun setViewState(viewState: AuthViewState){
52 | _viewState.value = viewState
53 | }
54 |
55 | private fun getCurrentViewState(): AuthViewState {
56 | return _viewState.value
57 | }
58 |
59 | fun onLoginEmailChanged(email: String){
60 | val new = buildNewViewState(loginEmailState = LoginEmailState(email))
61 | setViewState(new)
62 | }
63 |
64 | fun onLoginPasswordChanged(password: String){
65 | val showPasswordValue = _viewState.value.loginPasswordState.showPassword
66 | val new = buildNewViewState(loginPasswordState = LoginPasswordState(password, showPasswordValue))
67 | setViewState(new)
68 | }
69 |
70 | fun setShowLoginPassword(showPassword: Boolean){
71 | val password = _viewState.value.loginPasswordState.text
72 | val new = buildNewViewState(loginPasswordState = LoginPasswordState(password, showPassword))
73 | setViewState(new)
74 | }
75 |
76 | fun onCreateEmailChanged(email: String){
77 | val new = buildNewViewState(createEmailState = CreateEmailState(email))
78 | setViewState(new)
79 | }
80 |
81 | fun onCreateUsernameChanged(username: String){
82 | val new = buildNewViewState(createUsernameState = CreateUsernameState(username))
83 | setViewState(new)
84 | }
85 |
86 | fun onPassword1Changed(password: String){
87 | val showPassword1Value = _viewState.value.createPasswordState.password1.showPassword
88 | val password2State = _viewState.value.createPasswordState.password2
89 | val new = buildNewViewState(
90 | createPasswordState = CreatePasswordState(
91 | password1 = Password1State(password, showPassword1Value),
92 | password2 = password2State
93 | )
94 | )
95 | setViewState(new)
96 | }
97 |
98 | fun onPassword2Changed(password: String){
99 | val showPassword2Value = _viewState.value.createPasswordState.password2.showPassword
100 | val password1State = _viewState.value.createPasswordState.password1
101 | val new = buildNewViewState(
102 | createPasswordState = CreatePasswordState(
103 | password1 = password1State,
104 | password2 = Password2State(password, showPassword2Value)
105 | )
106 | )
107 | setViewState(new)
108 | }
109 |
110 | fun setShowPassword1(showPassword: Boolean){
111 | val password1Value = _viewState.value.createPasswordState.password1.text
112 | val password2State = _viewState.value.createPasswordState.password2
113 | val new = buildNewViewState(
114 | createPasswordState = CreatePasswordState(
115 | password1 = Password1State(password1Value, showPassword),
116 | password2 = password2State
117 | )
118 | )
119 | setViewState(new)
120 | }
121 |
122 | fun setShowPassword2(showPassword: Boolean){
123 | val password2Value = _viewState.value.createPasswordState.password2.text
124 | val password1State = _viewState.value.createPasswordState.password1
125 | val new = buildNewViewState(
126 | createPasswordState = CreatePasswordState(
127 | password1 = password1State,
128 | password2 = Password2State(password2Value, showPassword)
129 | )
130 | )
131 | setViewState(new)
132 | }
133 |
134 | fun onPasswordResetEmailChanged(email: String){
135 | val new = buildNewViewState(passwordResetEmailState = PasswordResetEmailState(email))
136 | setViewState(new)
137 | }
138 |
139 | fun onSendPasswordResetEmail(){
140 | val emailState = _viewState.value.passwordResetEmailState
141 | emailState.validate()
142 | if (!emailState.isErrors()) {
143 | // TODO("Fire reset password StateEvent")
144 | onPasswordResetEmailChanged("")
145 | }
146 | }
147 |
148 | fun onAttemptLogin(email: String, password: String){
149 | val emailState = _viewState.value.loginEmailState
150 | val passwordState = _viewState.value.loginPasswordState
151 | emailState.validate()
152 | passwordState.validate()
153 | printLogD("AuthViewmodel", "ATTEMPTING LOGIN")
154 | if(!emailState.isErrors() && !passwordState.isErrors()){
155 | sessionManager.setStateEvent(LoginEvent(email, password))
156 | }
157 | }
158 |
159 | fun onAttemptCreateAccount(){
160 | val emailState = _viewState.value.createEmailState
161 | val usernameState = _viewState.value.createUsernameState
162 | val password1State = _viewState.value.createPasswordState.password1
163 | val password2State = _viewState.value.createPasswordState.password2
164 | emailState.validate()
165 | usernameState.validate()
166 | password1State.validate()
167 | password2State.validate()
168 | if(!emailState.isErrors()
169 | && !usernameState.isErrors()
170 | && !password1State.isErrors()
171 | && !password2State.isErrors()
172 | ){
173 | // TODO("Attempt Create new account")
174 | }
175 | }
176 |
177 | private fun buildNewViewState(
178 | loginEmailState: LoginEmailState? = null,
179 | loginPasswordState: LoginPasswordState? = null,
180 | passwordResetEmailState: PasswordResetEmailState? = null,
181 | createEmailState: CreateEmailState? = null,
182 | createUsernameState: CreateUsernameState? = null,
183 | createPasswordState: CreatePasswordState? = null,
184 | screen: AuthScreen? = null,
185 | ): AuthViewState{
186 | val current = getCurrentViewState()
187 | val new = AuthViewState(
188 | loginEmailState = loginEmailState?: current.loginEmailState,
189 | loginPasswordState = loginPasswordState?: current.loginPasswordState,
190 | passwordResetEmailState = passwordResetEmailState?: current.passwordResetEmailState,
191 | createEmailState = createEmailState?: current.createEmailState,
192 | createUsernameState = createUsernameState?: current.createUsernameState,
193 | createPasswordState = createPasswordState?: current.createPasswordState,
194 | screen = screen?: current.screen,
195 | )
196 | savedStateHandle.set(BUNDLE_KEY_AUTH_VIEWSTATE, new.toAuthBundle())
197 | return new
198 | }
199 | }
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 |
--------------------------------------------------------------------------------