├── 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 | 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 | 9 | 10 | 14 | 15 | 19 | 20 | 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 | --------------------------------------------------------------------------------