├── app
├── .gitignore
├── src
│ ├── main
│ │ ├── res
│ │ │ ├── values
│ │ │ │ ├── strings.xml
│ │ │ │ ├── ic_launcher_background.xml
│ │ │ │ ├── themes.xml
│ │ │ │ └── colors.xml
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ ├── ic_launcher_round.webp
│ │ │ │ └── ic_launcher_foreground.webp
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ ├── ic_launcher_round.webp
│ │ │ │ └── ic_launcher_foreground.webp
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ ├── ic_launcher_round.webp
│ │ │ │ └── ic_launcher_foreground.webp
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ ├── ic_launcher_round.webp
│ │ │ │ └── ic_launcher_foreground.webp
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ ├── ic_launcher_round.webp
│ │ │ │ └── ic_launcher_foreground.webp
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── xml
│ │ │ │ ├── backup_rules.xml
│ │ │ │ └── data_extraction_rules.xml
│ │ │ └── drawable
│ │ │ │ ├── ic_launcher_foreground.xml
│ │ │ │ └── ic_launcher_background.xml
│ │ ├── ic_launcher-playstore.png
│ │ ├── java
│ │ │ └── nl
│ │ │ │ └── jovmit
│ │ │ │ └── androiddevs
│ │ │ │ ├── AndroidDevsApp.kt
│ │ │ │ ├── MainActivity.kt
│ │ │ │ ├── MainAppViewModel.kt
│ │ │ │ └── MainApp.kt
│ │ └── AndroidManifest.xml
│ ├── androidTest
│ │ └── java
│ │ │ └── nl
│ │ │ └── jovmit
│ │ │ └── androiddevs
│ │ │ ├── CustomTestRunner.kt
│ │ │ ├── LoginScreenRobot.kt
│ │ │ └── LoginTest.kt
│ └── test
│ │ └── java
│ │ └── nl
│ │ └── jovmit
│ │ └── androiddevs
│ │ └── DetectLogoutSignalTest.kt
├── proguard-rules.pro
└── build.gradle.kts
├── domain
└── auth
│ ├── .gitignore
│ ├── consumer-rules.pro
│ ├── src
│ ├── main
│ │ └── java
│ │ │ └── nl
│ │ │ └── jovmit
│ │ │ └── androiddevs
│ │ │ └── domain
│ │ │ └── auth
│ │ │ ├── data
│ │ │ ├── User.kt
│ │ │ ├── AuthMapper.kt
│ │ │ └── AuthResult.kt
│ │ │ ├── AuthRepository.kt
│ │ │ ├── AuthModule.kt
│ │ │ └── RemoteAuthRepository.kt
│ └── test
│ │ └── java
│ │ └── nl
│ │ └── jovmit
│ │ └── androiddevs
│ │ └── base
│ │ └── auth
│ │ ├── InMemoryAuthTest.kt
│ │ ├── AuthContractTest.kt
│ │ └── HttpAuthTest.kt
│ ├── proguard-rules.pro
│ └── build.gradle.kts
├── shared
├── ui
│ ├── .gitignore
│ ├── consumer-rules.pro
│ ├── src
│ │ ├── main
│ │ │ ├── res
│ │ │ │ ├── font
│ │ │ │ │ ├── opensans_bold.ttf
│ │ │ │ │ ├── opensans_regular.ttf
│ │ │ │ │ └── opensans_semibold.ttf
│ │ │ │ ├── values
│ │ │ │ │ └── strings.xml
│ │ │ │ └── drawable
│ │ │ │ │ ├── imperfect_circle_shape.xml
│ │ │ │ │ └── logo_android_devs.xml
│ │ │ └── java
│ │ │ │ └── nl
│ │ │ │ └── jovmit
│ │ │ │ └── androiddevs
│ │ │ │ └── shared
│ │ │ │ └── ui
│ │ │ │ ├── datetime
│ │ │ │ ├── AppDateTimeFormat.kt
│ │ │ │ ├── DateTimeFormatterUsage.kt
│ │ │ │ └── ZonedAppDateTimeFormat.kt
│ │ │ │ ├── extensions
│ │ │ │ └── SavedStateHandle.kt
│ │ │ │ ├── validation
│ │ │ │ ├── PasswordValidator.kt
│ │ │ │ └── EmailValidator.kt
│ │ │ │ ├── theme
│ │ │ │ ├── AppFont.kt
│ │ │ │ ├── Color.kt
│ │ │ │ ├── AppDesignSystem.kt
│ │ │ │ └── AppTheme.kt
│ │ │ │ ├── DispatcherModule.kt
│ │ │ │ └── composables
│ │ │ │ ├── PreviewClips.kt
│ │ │ │ ├── ImperfectCircleShape.kt
│ │ │ │ ├── EmailInput.kt
│ │ │ │ ├── Buttons.kt
│ │ │ │ ├── PasswordInput.kt
│ │ │ │ └── TextInput.kt
│ │ └── test
│ │ │ └── java
│ │ │ └── nl
│ │ │ └── jovmit
│ │ │ └── androiddevs
│ │ │ └── shared
│ │ │ └── ui
│ │ │ └── validation
│ │ │ └── CredentialsValidationTest.kt
│ ├── proguard-rules.pro
│ └── build.gradle.kts
├── database
│ ├── .gitignore
│ ├── consumer-rules.pro
│ ├── src
│ │ ├── main
│ │ │ ├── sqldelight
│ │ │ │ └── nl
│ │ │ │ │ └── jovmit
│ │ │ │ │ └── androiddevs
│ │ │ │ │ └── core
│ │ │ │ │ └── database
│ │ │ │ │ └── UserEntity.sq
│ │ │ └── java
│ │ │ │ └── nl
│ │ │ │ └── jovmit
│ │ │ │ └── androiddevs
│ │ │ │ └── core
│ │ │ │ └── database
│ │ │ │ └── DatabaseModule.kt
│ │ └── test
│ │ │ └── java
│ │ │ └── nl
│ │ │ └── jovmit
│ │ │ └── androiddevs
│ │ │ └── core
│ │ │ └── database
│ │ │ └── ExampleUnitTest.kt
│ ├── proguard-rules.pro
│ └── build.gradle.kts
└── network
│ ├── .gitignore
│ ├── consumer-rules.pro
│ ├── src
│ ├── main
│ │ └── java
│ │ │ └── nl
│ │ │ └── jovmit
│ │ │ └── androiddevs
│ │ │ └── core
│ │ │ └── network
│ │ │ ├── LoginData.kt
│ │ │ ├── SignUpData.kt
│ │ │ ├── AuthService.kt
│ │ │ ├── AuthResponse.kt
│ │ │ ├── LogoutSignal.kt
│ │ │ ├── ExpiredTokenInterceptor.kt
│ │ │ └── NetworkModule.kt
│ └── test
│ │ └── java
│ │ └── nl
│ │ └── jovmit
│ │ └── androiddevs
│ │ └── core
│ │ └── network
│ │ ├── AuthTokenExpiryTest.kt
│ │ └── StubRequest.kt
│ ├── proguard-rules.pro
│ └── build.gradle.kts
├── testutils
├── .gitignore
├── consumer-rules.pro
├── src
│ └── main
│ │ └── java
│ │ └── nl
│ │ └── jovmit
│ │ └── androiddevs
│ │ ├── domain
│ │ └── auth
│ │ │ ├── data
│ │ │ └── UserBuilder.kt
│ │ │ └── InMemoryAuthRepository.kt
│ │ └── testutils
│ │ ├── CoroutineTestExtension.kt
│ │ └── CoroutineExtensions.kt
├── proguard-rules.pro
└── build.gradle.kts
├── feature
├── login
│ ├── .gitignore
│ ├── consumer-rules.pro
│ ├── src
│ │ ├── main
│ │ │ └── java
│ │ │ │ └── nl
│ │ │ │ └── jovmit
│ │ │ │ └── androiddevs
│ │ │ │ └── feature
│ │ │ │ └── login
│ │ │ │ ├── LoginActions.kt
│ │ │ │ ├── LoginScreenState.kt
│ │ │ │ ├── LoginNavigation.kt
│ │ │ │ └── LoginViewModel.kt
│ │ └── test
│ │ │ └── java
│ │ │ └── nl
│ │ │ └── jovmit
│ │ │ └── androiddevs
│ │ │ └── feature
│ │ │ └── login
│ │ │ ├── LoginScreenshotTest.kt
│ │ │ ├── LoginScreenStateTest.kt
│ │ │ └── LoginTest.kt
│ ├── proguard-rules.pro
│ └── build.gradle.kts
├── signup
│ ├── .gitignore
│ ├── consumer-rules.pro
│ ├── src
│ │ ├── main
│ │ │ └── java
│ │ │ │ └── nl
│ │ │ │ └── jovmit
│ │ │ │ └── androiddevs
│ │ │ │ └── feature
│ │ │ │ └── signup
│ │ │ │ ├── SignUpNavigation.kt
│ │ │ │ ├── state
│ │ │ │ └── SignUpScreenState.kt
│ │ │ │ ├── SignUpViewModel.kt
│ │ │ │ └── SignUpScreen.kt
│ │ └── test
│ │ │ └── java
│ │ │ └── nl
│ │ │ └── jovmit
│ │ │ └── androiddevs
│ │ │ └── feature
│ │ │ └── signup
│ │ │ ├── SignUpStatesDeliveryTest.kt
│ │ │ ├── SignUpScreenStateTest.kt
│ │ │ └── SignUpTest.kt
│ ├── proguard-rules.pro
│ └── build.gradle.kts
├── timeline
│ ├── .gitignore
│ ├── consumer-rules.pro
│ ├── src
│ │ ├── main
│ │ │ └── java
│ │ │ │ └── nl
│ │ │ │ └── jovmit
│ │ │ │ └── androiddevs
│ │ │ │ └── feature
│ │ │ │ └── timeline
│ │ │ │ ├── TimelineViewModel.kt
│ │ │ │ ├── TimelineNavigation.kt
│ │ │ │ └── TimelineScreen.kt
│ │ └── test
│ │ │ └── java
│ │ │ └── nl
│ │ │ └── jovmit
│ │ │ └── androiddevs
│ │ │ └── feature
│ │ │ └── timeline
│ │ │ └── ExampleUnitTest.kt
│ ├── proguard-rules.pro
│ └── build.gradle.kts
├── welcome
│ ├── .gitignore
│ ├── consumer-rules.pro
│ ├── src
│ │ ├── test
│ │ │ ├── snapshots
│ │ │ │ └── images
│ │ │ │ │ └── nl.jovmit.androiddevs.feature.welcome_WelcomeScreenTest_defaultWelcomeScreen.png
│ │ │ └── java
│ │ │ │ └── nl
│ │ │ │ └── jovmit
│ │ │ │ └── androiddevs
│ │ │ │ └── feature
│ │ │ │ └── welcome
│ │ │ │ └── WelcomeScreenTest.kt
│ │ └── main
│ │ │ └── java
│ │ │ └── nl
│ │ │ └── jovmit
│ │ │ └── androiddevs
│ │ │ └── feature
│ │ │ └── welcome
│ │ │ ├── WelcomeNavigation.kt
│ │ │ └── WelcomeScreen.kt
│ ├── proguard-rules.pro
│ └── build.gradle.kts
└── postdetails
│ ├── .gitignore
│ ├── consumer-rules.pro
│ ├── src
│ ├── main
│ │ └── java
│ │ │ └── nl
│ │ │ └── jovmit
│ │ │ └── androiddevs
│ │ │ └── feature
│ │ │ └── postdetails
│ │ │ ├── PostDetailsScreenState.kt
│ │ │ ├── PostDetailsNavigation.kt
│ │ │ ├── PostDetailsViewModel.kt
│ │ │ └── PostDetailsScreen.kt
│ └── test
│ │ └── java
│ │ └── nl
│ │ └── jovmit
│ │ └── androiddevs
│ │ └── feature
│ │ └── postdetails
│ │ └── ExampleUnitTest.kt
│ ├── proguard-rules.pro
│ └── build.gradle.kts
├── whatsNew
└── whatsnew-en-US
├── .gitattributes
├── gradle
├── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
└── libs.versions.toml
├── .gitignore
├── README.md
├── .github
└── workflows
│ ├── pull_request.yml
│ └── release.yml
├── settings.gradle.kts
├── gradle.properties
├── gradlew.bat
└── gradlew
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/domain/auth/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/shared/ui/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/shared/ui/consumer-rules.pro:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/testutils/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/testutils/consumer-rules.pro:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/domain/auth/consumer-rules.pro:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/feature/login/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/feature/login/consumer-rules.pro:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/feature/signup/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/feature/signup/consumer-rules.pro:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/feature/timeline/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/feature/welcome/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/shared/database/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/shared/network/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/shared/network/consumer-rules.pro:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/feature/postdetails/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/feature/postdetails/consumer-rules.pro:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/feature/timeline/consumer-rules.pro:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/feature/welcome/consumer-rules.pro:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/shared/database/consumer-rules.pro:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/whatsNew/whatsnew-en-US:
--------------------------------------------------------------------------------
1 | Initial Release
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | **/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text
2 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Android Devs
3 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitrejcevski/android-devs-app/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/ic_launcher-playstore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitrejcevski/android-devs-app/HEAD/app/src/main/ic_launcher-playstore.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitrejcevski/android-devs-app/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitrejcevski/android-devs-app/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitrejcevski/android-devs-app/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/shared/ui/src/main/res/font/opensans_bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitrejcevski/android-devs-app/HEAD/shared/ui/src/main/res/font/opensans_bold.ttf
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitrejcevski/android-devs-app/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitrejcevski/android-devs-app/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/shared/ui/src/main/res/font/opensans_regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitrejcevski/android-devs-app/HEAD/shared/ui/src/main/res/font/opensans_regular.ttf
--------------------------------------------------------------------------------
/shared/ui/src/main/res/font/opensans_semibold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitrejcevski/android-devs-app/HEAD/shared/ui/src/main/res/font/opensans_semibold.ttf
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitrejcevski/android-devs-app/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitrejcevski/android-devs-app/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitrejcevski/android-devs-app/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitrejcevski/android-devs-app/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitrejcevski/android-devs-app/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitrejcevski/android-devs-app/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitrejcevski/android-devs-app/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitrejcevski/android-devs-app/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitrejcevski/android-devs-app/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitrejcevski/android-devs-app/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
--------------------------------------------------------------------------------
/app/src/main/res/values/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #F6D756
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea
5 | .DS_Store
6 | /build
7 | /captures
8 | .externalNativeBuild
9 | .cxx
10 | local.properties
11 | #Kotlin 2.0
12 | .kotlin/
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/java/nl/jovmit/androiddevs/AndroidDevsApp.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs
2 |
3 | import android.app.Application
4 | import dagger.hilt.android.HiltAndroidApp
5 |
6 | @HiltAndroidApp
7 | class AndroidDevsApp : Application()
--------------------------------------------------------------------------------
/domain/auth/src/main/java/nl/jovmit/androiddevs/domain/auth/data/User.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.domain.auth.data
2 |
3 | data class User(
4 | val userId: String,
5 | val email: String,
6 | val about: String
7 | )
8 |
--------------------------------------------------------------------------------
/shared/ui/src/main/java/nl/jovmit/androiddevs/shared/ui/datetime/AppDateTimeFormat.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.shared.ui.datetime
2 |
3 | interface AppDateTimeFormat {
4 |
5 | fun toDateTime(zonedDateTime: String): String
6 | }
--------------------------------------------------------------------------------
/feature/welcome/src/test/snapshots/images/nl.jovmit.androiddevs.feature.welcome_WelcomeScreenTest_defaultWelcomeScreen.png:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:3923105af84161c402b7b88c3af9fa4e17663d25dae170e511429444efd9a218
3 | size 29671
4 |
--------------------------------------------------------------------------------
/feature/login/src/main/java/nl/jovmit/androiddevs/feature/login/LoginActions.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.login
2 |
3 | internal interface LoginActions {
4 | fun updateEmail(newValue: String)
5 | fun updatePassword(newValue: String)
6 | fun login()
7 | }
--------------------------------------------------------------------------------
/feature/postdetails/src/main/java/nl/jovmit/androiddevs/feature/postdetails/PostDetailsScreenState.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.postdetails
2 |
3 | data class PostDetailsScreenState(
4 | val isLoading: Boolean = false,
5 | val title: String = ""
6 | )
7 |
--------------------------------------------------------------------------------
/shared/network/src/main/java/nl/jovmit/androiddevs/core/network/LoginData.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.core.network
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | @Serializable
6 | data class LoginData(
7 | val email: String,
8 | val password: String
9 | )
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Thu May 30 20:09:06 CEST 2024
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Android Devs App
2 |
3 | This project is used for training purposes for the Android Devs Community.
4 | You can join the community at no cost here: https://www.skool.com/android-devs
5 |
6 | The project is supposed to be showcasing new tools and technologies, best practices in terms of Android development.
--------------------------------------------------------------------------------
/shared/network/src/main/java/nl/jovmit/androiddevs/core/network/SignUpData.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.core.network
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | @Serializable
6 | data class SignUpData(
7 | val email: String,
8 | val password: String,
9 | val about: String
10 | )
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/domain/auth/src/main/java/nl/jovmit/androiddevs/domain/auth/data/AuthMapper.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.domain.auth.data
2 |
3 | import nl.jovmit.androiddevs.core.network.AuthResponse
4 |
5 | fun AuthResponse.UserData.toDomain(): User {
6 | return User(
7 | userId = id,
8 | email = email,
9 | about = about
10 | )
11 | }
--------------------------------------------------------------------------------
/shared/ui/src/main/java/nl/jovmit/androiddevs/shared/ui/extensions/SavedStateHandle.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.shared.ui.extensions
2 |
3 | import androidx.lifecycle.SavedStateHandle
4 |
5 | inline fun SavedStateHandle.update(
6 | key: String,
7 | block: (T) -> T
8 | ) {
9 | requireNotNull(get(key)).let(block).also { set(key, it) }
10 | }
--------------------------------------------------------------------------------
/domain/auth/src/main/java/nl/jovmit/androiddevs/domain/auth/AuthRepository.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.domain.auth
2 |
3 | import nl.jovmit.androiddevs.domain.auth.data.AuthResult
4 |
5 | interface AuthRepository {
6 |
7 | suspend fun login(email: String, password: String): AuthResult
8 |
9 | suspend fun signUp(email: String, password: String, about: String): AuthResult
10 | }
--------------------------------------------------------------------------------
/shared/network/src/main/java/nl/jovmit/androiddevs/core/network/AuthService.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.core.network
2 |
3 | import retrofit2.http.Body
4 | import retrofit2.http.POST
5 |
6 | interface AuthService {
7 |
8 | @POST("/signUp")
9 | suspend fun signUp(@Body signUpData: SignUpData): AuthResponse
10 |
11 | @POST("/auth/login")
12 | suspend fun login(@Body loginData: LoginData): AuthResponse
13 | }
--------------------------------------------------------------------------------
/shared/ui/src/main/java/nl/jovmit/androiddevs/shared/ui/validation/PasswordValidator.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.shared.ui.validation
2 |
3 | class PasswordValidator {
4 |
5 | fun validatePassword(password: String): Boolean {
6 | val value = password.trim()
7 | return value.count() > 7 &&
8 | value.any { it.isUpperCase() } &&
9 | value.any { it.isDigit() }
10 | }
11 | }
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
--------------------------------------------------------------------------------
/shared/network/src/main/java/nl/jovmit/androiddevs/core/network/AuthResponse.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.core.network
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | @Serializable
6 | data class AuthResponse(
7 | val token: String,
8 | val userData: UserData
9 | ) {
10 |
11 | @Serializable
12 | data class UserData(
13 | val id: String,
14 | val email: String,
15 | val about: String
16 | )
17 | }
--------------------------------------------------------------------------------
/shared/database/src/main/sqldelight/nl/jovmit/androiddevs/core/database/UserEntity.sq:
--------------------------------------------------------------------------------
1 | CREATE TABLE UserEntity (
2 | uid TEXT PRIMARY KEY NOT NULL,
3 | email TEXT NOT NULL,
4 | about TEXT NOT NULL
5 | );
6 |
7 | fetchAll:
8 | SELECT * FROM UserEntity;
9 |
10 | addUser:
11 | INSERT INTO UserEntity(uid, email, about)
12 | VALUES (?, ?, ?);
13 |
14 | addFullUser:
15 | INSERT INTO UserEntity(uid, email, about)
16 | VALUES ?;
17 |
18 | deleteUser:
19 | DELETE FROM UserEntity WHERE uid IN :uid;
--------------------------------------------------------------------------------
/domain/auth/src/main/java/nl/jovmit/androiddevs/domain/auth/data/AuthResult.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.domain.auth.data
2 |
3 | sealed class AuthResult {
4 |
5 | data class Success(
6 | val authToken: String,
7 | val user: User
8 | ) : AuthResult()
9 |
10 | data object BackendError : AuthResult()
11 |
12 | data object IncorrectCredentials: AuthResult()
13 |
14 | data object ExistingUserError : AuthResult()
15 |
16 | data object OfflineError : AuthResult()
17 | }
18 |
--------------------------------------------------------------------------------
/shared/ui/src/main/java/nl/jovmit/androiddevs/shared/ui/theme/AppFont.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.shared.ui.theme
2 |
3 | import androidx.compose.ui.text.font.Font
4 | import androidx.compose.ui.text.font.FontFamily
5 | import androidx.compose.ui.text.font.FontWeight
6 | import nl.jovmit.androiddevs.shared.ui.R
7 |
8 | val OpenSans = FontFamily(
9 | Font(R.font.opensans_regular, FontWeight.Normal),
10 | Font(R.font.opensans_semibold, FontWeight.SemiBold),
11 | Font(R.font.opensans_bold, FontWeight.Bold)
12 | )
--------------------------------------------------------------------------------
/feature/login/src/main/java/nl/jovmit/androiddevs/feature/login/LoginScreenState.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.login
2 |
3 | import android.os.Parcelable
4 | import kotlinx.parcelize.Parcelize
5 |
6 | @Parcelize
7 | data class LoginScreenState(
8 | val email: String = "",
9 | val password: String = "",
10 | val loggedInUser: String? = null,
11 | val wrongCredentials: Boolean = false,
12 | val isWrongEmailFormat: Boolean = false,
13 | val isBadPasswordFormat: Boolean = false
14 | ) : Parcelable
15 |
--------------------------------------------------------------------------------
/feature/timeline/src/main/java/nl/jovmit/androiddevs/feature/timeline/TimelineViewModel.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.timeline
2 |
3 | import androidx.lifecycle.ViewModel
4 | import dagger.hilt.android.lifecycle.HiltViewModel
5 | import nl.jovmit.androiddevs.core.network.LogoutSignal
6 | import javax.inject.Inject
7 |
8 | @HiltViewModel
9 | class TimelineViewModel @Inject constructor(
10 | private val logoutSignal: LogoutSignal
11 | ) : ViewModel() {
12 |
13 | fun doLogout() {
14 | logoutSignal.onLoggedOut()
15 | }
16 | }
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/shared/ui/src/main/java/nl/jovmit/androiddevs/shared/ui/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.shared.ui.theme
2 |
3 | import androidx.compose.ui.graphics.Color
4 |
5 | val LightGray = Color(0xFFB9B8B8)
6 | val DarkGray = Color(0xFF8A8A8A)
7 |
8 | val Purple80 = Color(0xFFD0BCFF)
9 | val PurpleGrey80 = Color(0xFFCCC2DC)
10 | val Pink80 = Color(0xFFEFB8C8)
11 |
12 | val Purple40 = Color(0xFF6650a4)
13 | val PurpleGrey40 = Color(0xFF625b71)
14 | val Pink40 = Color(0xFF7D5260)
15 |
16 | val LightRed = Color(0xFFFF1744)
17 | val DarkRed = Color(0xFFFF3D00)
--------------------------------------------------------------------------------
/feature/welcome/src/main/java/nl/jovmit/androiddevs/feature/welcome/WelcomeNavigation.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.welcome
2 |
3 | import androidx.navigation.NavGraphBuilder
4 | import androidx.navigation.compose.composable
5 |
6 | const val WELCOME_ROUTE = "welcome"
7 |
8 | fun NavGraphBuilder.welcomeScreen(
9 | onLogin: () -> Unit,
10 | onSignUp: () -> Unit
11 | ) {
12 | composable(WELCOME_ROUTE) {
13 | WelcomeScreen(
14 | onLogin = onLogin,
15 | onSignUp = onSignUp
16 | )
17 | }
18 | }
19 |
20 |
--------------------------------------------------------------------------------
/shared/network/src/main/java/nl/jovmit/androiddevs/core/network/LogoutSignal.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.core.network
2 |
3 | import kotlinx.coroutines.flow.MutableStateFlow
4 | import kotlinx.coroutines.flow.StateFlow
5 | import kotlinx.coroutines.flow.asStateFlow
6 | import kotlinx.coroutines.flow.update
7 |
8 | class LogoutSignal {
9 |
10 | private val _forcedLogout = MutableStateFlow(null)
11 | val forcedLogout: StateFlow = _forcedLogout.asStateFlow()
12 |
13 | fun onLoggedOut() {
14 | _forcedLogout.update { Unit }
15 | }
16 | }
--------------------------------------------------------------------------------
/shared/database/src/test/java/nl/jovmit/androiddevs/core/database/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.core.database
2 |
3 | import com.google.common.truth.Truth.assertThat
4 | import org.junit.jupiter.api.Test
5 |
6 | /**
7 | * Example local unit test, which will execute on the development machine (host).
8 | *
9 | * See [testing documentation](http://d.android.com/tools/testing).
10 | */
11 | class ExampleUnitTest {
12 |
13 | @Test
14 | fun addition_isCorrect() {
15 | val result = 2 + 2
16 | assertThat(result).isEqualTo(4)
17 | }
18 | }
--------------------------------------------------------------------------------
/shared/ui/src/main/java/nl/jovmit/androiddevs/shared/ui/validation/EmailValidator.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.shared.ui.validation
2 |
3 | import java.util.regex.Pattern
4 |
5 | class EmailValidator {
6 |
7 | private val emailPattern = Pattern.compile(EMAIL_REGEX)
8 |
9 | fun validateEmail(email: String): Boolean {
10 | return emailPattern.matcher(email).matches()
11 | }
12 |
13 | companion object {
14 | private const val EMAIL_REGEX = """[a-zA-Z0-9+._%\-]{1,64}@[a-zA-Z0-9][a-zA-Z0-9\-]{1,64}(\.[a-zA-Z0-9][a-zA-Z0-9\-]{1,25})"""
15 | }
16 | }
--------------------------------------------------------------------------------
/feature/timeline/src/test/java/nl/jovmit/androiddevs/feature/timeline/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.timeline
2 |
3 | import com.google.common.truth.Truth.assertThat
4 | import org.junit.jupiter.api.Test
5 |
6 | /**
7 | * Example local unit test, which will execute on the development machine (host).
8 | *
9 | * See [testing documentation](http://d.android.com/tools/testing).
10 | */
11 | class ExampleUnitTest {
12 |
13 | @Test
14 | fun addition_isCorrect() {
15 | val result = 2 + 2
16 | assertThat(result).isEqualTo(4)
17 | }
18 | }
--------------------------------------------------------------------------------
/.github/workflows/pull_request.yml:
--------------------------------------------------------------------------------
1 | name: Run test
2 |
3 | on:
4 | pull_request:
5 | branches: [main]
6 |
7 | jobs:
8 | test:
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - uses: actions/checkout@v4
13 |
14 | - name: Setup JDK 17
15 | uses: actions/setup-java@v4
16 | with:
17 | distribution: 'temurin'
18 | java-version: 17
19 | cache: 'gradle'
20 |
21 | - name: Grant execute permissions for gradlew
22 | run: chmod +x gradlew
23 |
24 | - name: Run unit tests
25 | run: ./gradlew clean testDebug
--------------------------------------------------------------------------------
/feature/postdetails/src/test/java/nl/jovmit/androiddevs/feature/postdetails/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.postdetails
2 |
3 | import com.google.common.truth.Truth.assertThat
4 | import org.junit.jupiter.api.Test
5 |
6 | /**
7 | * Example local unit test, which will execute on the development machine (host).
8 | *
9 | * See [testing documentation](http://d.android.com/tools/testing).
10 | */
11 | class ExampleUnitTest {
12 |
13 | @Test
14 | fun addition_isCorrect() {
15 | val result = 2 + 2
16 | assertThat(result).isEqualTo(4)
17 | }
18 | }
--------------------------------------------------------------------------------
/app/src/androidTest/java/nl/jovmit/androiddevs/CustomTestRunner.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs
2 |
3 | import android.app.Application
4 | import android.content.Context
5 | import androidx.test.runner.AndroidJUnitRunner
6 | import dagger.hilt.android.testing.HiltTestApplication
7 |
8 | class CustomTestRunner: AndroidJUnitRunner() {
9 |
10 | override fun newApplication(
11 | classLoader: ClassLoader?,
12 | className: String?,
13 | context: Context?
14 | ): Application {
15 | return super.newApplication(classLoader, HiltTestApplication::class.java.name, context)
16 | }
17 | }
--------------------------------------------------------------------------------
/shared/network/src/main/java/nl/jovmit/androiddevs/core/network/ExpiredTokenInterceptor.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.core.network
2 |
3 | import okhttp3.Interceptor
4 | import okhttp3.Response
5 | import java.net.HttpURLConnection
6 |
7 | class ExpiredTokenInterceptor(
8 | private val logoutSignal: LogoutSignal,
9 | ) : Interceptor {
10 |
11 | override fun intercept(chain: Interceptor.Chain): Response {
12 | val response = chain.proceed(chain.request())
13 | if (response.code == HttpURLConnection.HTTP_UNAUTHORIZED) {
14 | logoutSignal.onLoggedOut()
15 | }
16 | return response
17 | }
18 | }
--------------------------------------------------------------------------------
/shared/ui/src/main/java/nl/jovmit/androiddevs/shared/ui/DispatcherModule.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.shared.ui
2 |
3 | import dagger.Module
4 | import dagger.Provides
5 | import dagger.hilt.InstallIn
6 | import dagger.hilt.android.components.ViewModelComponent
7 | import dagger.hilt.android.scopes.ViewModelScoped
8 | import kotlinx.coroutines.CoroutineDispatcher
9 | import kotlinx.coroutines.Dispatchers
10 |
11 | @Module
12 | @InstallIn(ViewModelComponent::class)
13 | object DispatcherModule {
14 |
15 | @Provides
16 | @ViewModelScoped
17 | fun provideBackgroundDispatcher(): CoroutineDispatcher {
18 | return Dispatchers.IO
19 | }
20 | }
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/feature/signup/src/main/java/nl/jovmit/androiddevs/feature/signup/SignUpNavigation.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.signup
2 |
3 | import androidx.navigation.NavController
4 | import androidx.navigation.NavGraphBuilder
5 | import androidx.navigation.compose.composable
6 | import kotlinx.serialization.Serializable
7 |
8 | @Serializable
9 | private data object SignUpRoute
10 |
11 | fun NavGraphBuilder.signUpScreen(
12 | onNavigateUp: () -> Unit,
13 | ) {
14 | composable {
15 | SignUpScreen(
16 | onNavigateUp = onNavigateUp,
17 | )
18 | }
19 | }
20 |
21 | fun NavController.navigateToSignUp() {
22 | navigate(SignUpRoute)
23 | }
--------------------------------------------------------------------------------
/feature/timeline/src/main/java/nl/jovmit/androiddevs/feature/timeline/TimelineNavigation.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.timeline
2 |
3 | import androidx.navigation.NavController
4 | import androidx.navigation.NavGraphBuilder
5 | import androidx.navigation.compose.composable
6 |
7 | private const val TIMELINE_ROUTE = "timeline"
8 |
9 | fun NavGraphBuilder.timelineScreen(
10 | onItemClicked: (itemId: String) -> Unit
11 | ) {
12 | composable(TIMELINE_ROUTE) {
13 | TimelineScreen(
14 | onItemClicked = onItemClicked
15 | )
16 | }
17 | }
18 |
19 | fun NavController.navigateToTimeline() {
20 | navigate(TIMELINE_ROUTE) {
21 | popUpTo(0)
22 | }
23 | }
--------------------------------------------------------------------------------
/feature/signup/src/main/java/nl/jovmit/androiddevs/feature/signup/state/SignUpScreenState.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.signup.state
2 |
3 | import android.os.Parcelable
4 | import kotlinx.parcelize.Parcelize
5 |
6 | @Parcelize
7 | data class SignUpScreenState(
8 | val isLoading: Boolean = false,
9 | val email: String = "",
10 | val password: String = "",
11 | val about: String = "",
12 | val incorrectEmailFormat: Boolean = false,
13 | val incorrectPasswordFormat: Boolean = false,
14 | val isSignedUp: Boolean = false,
15 | val isExistingEmail: Boolean = false,
16 | val isBackendError: Boolean = false,
17 | val isOfflineError: Boolean = false
18 | ) : Parcelable
--------------------------------------------------------------------------------
/app/src/main/java/nl/jovmit/androiddevs/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.activity.enableEdgeToEdge
7 | import dagger.hilt.android.AndroidEntryPoint
8 | import nl.jovmit.androiddevs.shared.ui.theme.AppTheme
9 |
10 | @AndroidEntryPoint
11 | class MainActivity : ComponentActivity() {
12 |
13 | override fun onCreate(savedInstanceState: Bundle?) {
14 | super.onCreate(savedInstanceState)
15 | enableEdgeToEdge()
16 | setContent {
17 | AppTheme {
18 | MainApp()
19 | }
20 | }
21 | }
22 | }
--------------------------------------------------------------------------------
/testutils/src/main/java/nl/jovmit/androiddevs/domain/auth/data/UserBuilder.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.domain.auth.data
2 |
3 | class UserBuilder {
4 |
5 | private var userId: String = ""
6 | private var email: String = ""
7 | private var about: String = ""
8 |
9 | fun withUserId(userId: String) = apply {
10 | this.userId = userId
11 | }
12 |
13 | fun withEmail(email: String) = apply {
14 | this.email = email
15 | }
16 |
17 | fun withAbout(about: String) = apply {
18 | this.about = about
19 | }
20 |
21 | fun build(): User {
22 | return User(userId, email, about)
23 | }
24 |
25 | companion object {
26 | fun aUser() = UserBuilder()
27 | }
28 | }
--------------------------------------------------------------------------------
/feature/login/src/main/java/nl/jovmit/androiddevs/feature/login/LoginNavigation.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.login
2 |
3 | import androidx.navigation.NavController
4 | import androidx.navigation.NavGraphBuilder
5 | import androidx.navigation.compose.composable
6 |
7 | private const val LOGIN_ROUTE = "login"
8 |
9 | fun NavGraphBuilder.loginScreen(
10 | onLoggedIn: () -> Unit,
11 | onNavigateUp: () -> Unit
12 | ) {
13 | composable(LOGIN_ROUTE) {
14 | LoginScreen(
15 | onLoggedIn = onLoggedIn,
16 | onNavigateUp = onNavigateUp
17 | )
18 | }
19 | }
20 |
21 | fun NavController.navigateToLogin() {
22 | navigate(LOGIN_ROUTE) {
23 | launchSingleTop = true
24 | }
25 | }
--------------------------------------------------------------------------------
/feature/welcome/src/test/java/nl/jovmit/androiddevs/feature/welcome/WelcomeScreenTest.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.welcome
2 |
3 | import app.cash.paparazzi.DeviceConfig
4 | import app.cash.paparazzi.Paparazzi
5 | import nl.jovmit.androiddevs.shared.ui.theme.AppTheme
6 | import org.junit.Rule
7 | import org.junit.Test
8 |
9 | class WelcomeScreenTest {
10 |
11 | @get:Rule
12 | val paparazzi = Paparazzi(
13 | deviceConfig = DeviceConfig.PIXEL_5
14 | )
15 |
16 | @Test
17 | fun defaultWelcomeScreen() {
18 | paparazzi.snapshot {
19 | AppTheme {
20 | WelcomeScreen(
21 | onLogin = {},
22 | onSignUp = {}
23 | )
24 | }
25 | }
26 | }
27 | }
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google()
4 | mavenCentral()
5 | gradlePluginPortal()
6 | }
7 | }
8 | dependencyResolutionManagement {
9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
10 | repositories {
11 | google()
12 | mavenCentral()
13 | }
14 | }
15 |
16 | rootProject.name = "AndroidDevs"
17 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
18 | include(":app")
19 | include(":feature:welcome")
20 | include(":feature:signup")
21 | include(":feature:login")
22 | include(":feature:timeline")
23 | include(":feature:postdetails")
24 | include(":shared:ui")
25 | include(":shared:network")
26 | include(":shared:database")
27 | include(":domain:auth")
28 | include(":testutils")
29 |
--------------------------------------------------------------------------------
/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
--------------------------------------------------------------------------------
/domain/auth/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
--------------------------------------------------------------------------------
/feature/login/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
--------------------------------------------------------------------------------
/shared/ui/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
--------------------------------------------------------------------------------
/testutils/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
--------------------------------------------------------------------------------
/feature/signup/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
--------------------------------------------------------------------------------
/feature/timeline/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
--------------------------------------------------------------------------------
/feature/welcome/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
--------------------------------------------------------------------------------
/shared/database/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
--------------------------------------------------------------------------------
/shared/network/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
--------------------------------------------------------------------------------
/feature/postdetails/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
--------------------------------------------------------------------------------
/domain/auth/src/test/java/nl/jovmit/androiddevs/base/auth/InMemoryAuthTest.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.base.auth
2 |
3 | import nl.jovmit.androiddevs.domain.auth.AuthRepository
4 | import nl.jovmit.androiddevs.domain.auth.InMemoryAuthRepository
5 | import nl.jovmit.androiddevs.domain.auth.data.User
6 |
7 | class InMemoryAuthTest : AuthContractTest() {
8 |
9 | override fun authRepositoryWith(
10 | authToken: String,
11 | usersForPassword: Map>
12 | ): AuthRepository {
13 | return InMemoryAuthRepository(authToken, usersForPassword)
14 | }
15 |
16 | override fun unavailableAuthRepository(): AuthRepository {
17 | return InMemoryAuthRepository().apply { setUnavailable() }
18 | }
19 |
20 | override fun offlineAuthRepository(): AuthRepository {
21 | return InMemoryAuthRepository().apply { setOffline() }
22 | }
23 | }
--------------------------------------------------------------------------------
/shared/network/src/test/java/nl/jovmit/androiddevs/core/network/AuthTokenExpiryTest.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.core.network
2 |
3 | import com.google.common.truth.Truth.assertThat
4 | import org.junit.jupiter.api.Test
5 |
6 | class AuthTokenExpiryTest {
7 |
8 | private val logoutSignal = LogoutSignal()
9 |
10 | @Test
11 | fun callResponseWith401() {
12 | val request = StubRequest(401)
13 | val interceptor = ExpiredTokenInterceptor(logoutSignal)
14 |
15 | interceptor.intercept(request)
16 |
17 | assertThat(logoutSignal.forcedLogout.value).isEqualTo(Unit)
18 | }
19 |
20 | @Test
21 | fun callResponseWith200() {
22 | val request = StubRequest(200)
23 | val interceptor = ExpiredTokenInterceptor(logoutSignal)
24 |
25 | interceptor.intercept(request)
26 |
27 | assertThat(logoutSignal.forcedLogout.value).isNull()
28 | }
29 | }
--------------------------------------------------------------------------------
/shared/ui/src/main/java/nl/jovmit/androiddevs/shared/ui/datetime/DateTimeFormatterUsage.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.shared.ui.datetime
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.material3.Text
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.Modifier
8 | import androidx.compose.ui.tooling.preview.PreviewLightDark
9 | import nl.jovmit.androiddevs.shared.ui.theme.AppTheme
10 |
11 | @Composable
12 | @PreviewLightDark
13 | private fun TryOutDateTimeFormatter() {
14 | AppTheme {
15 | Column(modifier = Modifier.background(AppTheme.colorScheme.background)) {
16 | val formatter = AppDateTime.formatter
17 | val formatted = formatter.toDateTime("2025-01-08T15:17:13.791Z")
18 | Text(text = formatted, color = AppTheme.colorScheme.onBackground)
19 | }
20 | }
21 | }
--------------------------------------------------------------------------------
/app/src/main/java/nl/jovmit/androiddevs/MainAppViewModel.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import dagger.hilt.android.lifecycle.HiltViewModel
6 | import kotlinx.coroutines.flow.MutableSharedFlow
7 | import kotlinx.coroutines.flow.asSharedFlow
8 | import kotlinx.coroutines.flow.launchIn
9 | import kotlinx.coroutines.flow.onEach
10 | import nl.jovmit.androiddevs.core.network.LogoutSignal
11 | import javax.inject.Inject
12 |
13 | @HiltViewModel
14 | class MainAppViewModel @Inject constructor(
15 | private val loggedOutSignal: LogoutSignal
16 | ) : ViewModel() {
17 |
18 | private val _loggedOut = MutableSharedFlow()
19 | val loggedOut = _loggedOut.asSharedFlow()
20 |
21 | fun observeLoggedOut() {
22 | loggedOutSignal.forcedLogout.onEach {
23 | _loggedOut.emit(Unit)
24 | }.launchIn(viewModelScope)
25 | }
26 | }
--------------------------------------------------------------------------------
/app/src/test/java/nl/jovmit/androiddevs/DetectLogoutSignalTest.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs
2 |
3 | import com.google.common.truth.Truth.assertThat
4 | import kotlinx.coroutines.test.runTest
5 | import nl.jovmit.androiddevs.core.network.LogoutSignal
6 | import nl.jovmit.androiddevs.testutils.CoroutineTestExtension
7 | import nl.jovmit.androiddevs.testutils.collectSharedFlow
8 | import org.junit.jupiter.api.Test
9 | import org.junit.jupiter.api.extension.ExtendWith
10 |
11 | @ExtendWith(CoroutineTestExtension::class)
12 | class DetectLogoutSignalTest {
13 |
14 | private val loggedOutSignal = LogoutSignal()
15 |
16 | @Test
17 | fun logoutSignalDetected() = runTest {
18 | val viewModel = MainAppViewModel(loggedOutSignal).apply {
19 | observeLoggedOut()
20 | }
21 |
22 | val observedEvent = collectSharedFlow(viewModel.loggedOut) {
23 | loggedOutSignal.onLoggedOut()
24 | }
25 |
26 | assertThat(observedEvent).isEqualTo(Unit)
27 | }
28 | }
--------------------------------------------------------------------------------
/testutils/src/main/java/nl/jovmit/androiddevs/testutils/CoroutineTestExtension.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.testutils
2 |
3 | import kotlinx.coroutines.Dispatchers
4 | import kotlinx.coroutines.ExperimentalCoroutinesApi
5 | import kotlinx.coroutines.test.UnconfinedTestDispatcher
6 | import kotlinx.coroutines.test.resetMain
7 | import kotlinx.coroutines.test.setMain
8 | import org.junit.jupiter.api.extension.AfterAllCallback
9 | import org.junit.jupiter.api.extension.BeforeAllCallback
10 | import org.junit.jupiter.api.extension.ExtensionContext
11 |
12 | class CoroutineTestExtension : BeforeAllCallback, AfterAllCallback {
13 |
14 | @OptIn(ExperimentalCoroutinesApi::class)
15 | private val testDispatcher = UnconfinedTestDispatcher()
16 |
17 | @OptIn(ExperimentalCoroutinesApi::class)
18 | override fun beforeAll(context: ExtensionContext?) {
19 | Dispatchers.setMain(testDispatcher)
20 | }
21 |
22 | @OptIn(ExperimentalCoroutinesApi::class)
23 | override fun afterAll(context: ExtensionContext?) {
24 | Dispatchers.resetMain()
25 | }
26 | }
--------------------------------------------------------------------------------
/domain/auth/src/main/java/nl/jovmit/androiddevs/domain/auth/AuthModule.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.domain.auth
2 |
3 | import dagger.Binds
4 | import dagger.Module
5 | import dagger.hilt.InstallIn
6 | import dagger.hilt.components.SingletonComponent
7 | import nl.jovmit.androiddevs.domain.auth.data.AuthResult
8 | import nl.jovmit.androiddevs.domain.auth.data.User
9 | import javax.inject.Inject
10 | import javax.inject.Singleton
11 |
12 | @Module
13 | @InstallIn(SingletonComponent::class)
14 | abstract class AuthModule {
15 |
16 | @Binds
17 | @Singleton
18 | internal abstract fun bindAuthRepository(
19 | repository: DummyAuthRepo
20 | ): AuthRepository
21 |
22 | class DummyAuthRepo @Inject constructor() : AuthRepository {
23 | override suspend fun login(email: String, password: String): AuthResult {
24 | return AuthResult.Success("token", User("userId", email, "about"))
25 | }
26 | override suspend fun signUp(email: String, password: String, about: String): AuthResult {
27 | TODO("Not yet implemented")
28 | }
29 | }
30 | }
--------------------------------------------------------------------------------
/testutils/src/main/java/nl/jovmit/androiddevs/testutils/CoroutineExtensions.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.testutils
2 |
3 | import kotlinx.coroutines.CoroutineScope
4 | import kotlinx.coroutines.Dispatchers
5 | import kotlinx.coroutines.flow.SharedFlow
6 | import kotlinx.coroutines.flow.StateFlow
7 | import kotlinx.coroutines.flow.toCollection
8 | import kotlinx.coroutines.launch
9 |
10 | inline fun CoroutineScope.collectSharedFlow(
11 | flow: SharedFlow,
12 | block: () -> Unit
13 | ): T {
14 | val values = mutableListOf()
15 | val job = launch(Dispatchers.Unconfined) {
16 | flow.toCollection(values)
17 | }
18 | block()
19 | job.cancel()
20 | return values.first()
21 | }
22 |
23 | inline fun CoroutineScope.collectStateFlow(
24 | stateFlow: StateFlow,
25 | dropInitialValue: Boolean = true,
26 | action: () -> Unit
27 | ): List {
28 | val results = arrayListOf()
29 | val collectJob = launch(Dispatchers.Unconfined) {
30 | stateFlow.toCollection(results)
31 | }
32 | action()
33 | collectJob.cancel()
34 | return if (dropInitialValue) results.drop(1) else results
35 | }
--------------------------------------------------------------------------------
/shared/ui/src/main/java/nl/jovmit/androiddevs/shared/ui/datetime/ZonedAppDateTimeFormat.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.shared.ui.datetime
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.staticCompositionLocalOf
5 | import java.time.ZoneId
6 | import java.time.ZonedDateTime
7 | import java.time.format.DateTimeFormatter
8 |
9 | internal val LocalDateTimeFormat = staticCompositionLocalOf {
10 | passByFormat
11 | }
12 |
13 | internal val passByFormat = object : AppDateTimeFormat {
14 | override fun toDateTime(zonedDateTime: String): String {
15 | return zonedDateTime
16 | }
17 | }
18 |
19 | internal val appZonedDateTimeFormat = object : AppDateTimeFormat {
20 |
21 | override fun toDateTime(zonedDateTime: String): String {
22 | val date = ZonedDateTime.parse(zonedDateTime)
23 | .withZoneSameInstant(ZoneId.systemDefault())
24 | .toLocalDateTime()
25 | return DateTimeFormatter.ofPattern("DD MM yyyy").format(date)
26 | }
27 | }
28 |
29 | object AppDateTime {
30 |
31 | val formatter: AppDateTimeFormat
32 | @Composable get() = LocalDateTimeFormat.current
33 | }
--------------------------------------------------------------------------------
/shared/ui/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Android Devs
4 | Welcome!
5 | Sign Up
6 | Log in
7 | Bad email format
8 | Bad password. Type min 7 chars, uppercase and digit
9 | Invalid credentials. Try again
10 | App Logo
11 | Enter Email
12 | email@company.com
13 | Enter Password
14 | type min 8 chars
15 | Show Password
16 | Hide Password
17 | About
18 | bio
19 | OK
20 | Error
21 |
--------------------------------------------------------------------------------
/shared/database/src/main/java/nl/jovmit/androiddevs/core/database/DatabaseModule.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.core.database
2 |
3 | import android.content.Context
4 | import app.cash.sqldelight.db.SqlDriver
5 | import app.cash.sqldelight.driver.android.AndroidSqliteDriver
6 | import dagger.Module
7 | import dagger.Provides
8 | import dagger.hilt.InstallIn
9 | import dagger.hilt.android.qualifiers.ApplicationContext
10 | import dagger.hilt.components.SingletonComponent
11 | import javax.inject.Singleton
12 |
13 | @Module
14 | @InstallIn(SingletonComponent::class)
15 | object DatabaseModule {
16 |
17 | @Provides
18 | @Singleton
19 | fun provideSqlDriver(
20 | @ApplicationContext context: Context
21 | ): SqlDriver {
22 | return AndroidSqliteDriver(UsersDatabase.Schema, context, "users.db")
23 | }
24 |
25 | @Provides
26 | @Singleton
27 | fun provideUsersDatabase(
28 | driver: SqlDriver
29 | ): UsersDatabase {
30 | return UsersDatabase(driver)
31 | }
32 |
33 | @Provides
34 | @Singleton
35 | fun provideUsersQueries(
36 | database: UsersDatabase
37 | ): UserEntityQueries {
38 | return database.userEntityQueries
39 | }
40 | }
--------------------------------------------------------------------------------
/feature/login/src/test/java/nl/jovmit/androiddevs/feature/login/LoginScreenshotTest.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.login
2 |
3 | import app.cash.paparazzi.DeviceConfig
4 | import app.cash.paparazzi.Paparazzi
5 | import nl.jovmit.androiddevs.shared.ui.theme.AppTheme
6 | import org.junit.Rule
7 | import org.junit.Test
8 |
9 | class LoginScreenshotTest {
10 |
11 | @get:Rule
12 | val paparazzi = Paparazzi(
13 | deviceConfig = DeviceConfig.PIXEL_5
14 | )
15 |
16 | @Test
17 | fun defaultLoginScreen() {
18 | paparazzi.snapshot {
19 | AppTheme {
20 | LoginScreenContent(
21 | screenState = LoginScreenState(),
22 | loginActions = EmptyLoginActions(),
23 | onNavigateUp = {}
24 | )
25 | }
26 | }
27 | }
28 |
29 | private class EmptyLoginActions: LoginActions {
30 | override fun updateEmail(newValue: String) {
31 | TODO("Not yet implemented")
32 | }
33 |
34 | override fun updatePassword(newValue: String) {
35 | TODO("Not yet implemented")
36 | }
37 |
38 | override fun login() {
39 | TODO("Not yet implemented")
40 | }
41 | }
42 | }
--------------------------------------------------------------------------------
/shared/ui/src/main/res/drawable/imperfect_circle_shape.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
11 |
12 |
--------------------------------------------------------------------------------
/feature/postdetails/src/main/java/nl/jovmit/androiddevs/feature/postdetails/PostDetailsNavigation.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.postdetails
2 |
3 | import androidx.lifecycle.SavedStateHandle
4 | import androidx.navigation.NavController
5 | import androidx.navigation.NavGraphBuilder
6 | import androidx.navigation.compose.composable
7 | import kotlinx.serialization.Serializable
8 |
9 | @Serializable
10 | internal data class PostDetailsDestination(
11 | val postId: String
12 | ) {
13 |
14 | constructor(savedStateHandle: SavedStateHandle):
15 | this(requireNotNull(savedStateHandle.get("postId")))
16 |
17 | companion object {
18 | fun from(savedStateHandle: SavedStateHandle): PostDetailsDestination {
19 | val postId = requireNotNull(savedStateHandle.get("postId"))
20 | return PostDetailsDestination(postId)
21 | }
22 | }
23 | }
24 |
25 | fun NavGraphBuilder.postDetailsScreen(
26 | onNavigateUp: () -> Unit
27 | ) {
28 | composable { backStackEntry ->
29 | PostDetailsScreenContainer(onNavigateUp = onNavigateUp)
30 | }
31 | }
32 |
33 | fun NavController.navigateToPostDetails(
34 | postId: String,
35 | ) {
36 | navigate(PostDetailsDestination(postId))
37 | }
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
18 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/testutils/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.kotlin.android)
4 | }
5 |
6 | android {
7 | namespace = "nl.jovmit.androiddevs.testutils"
8 | compileSdk = libs.versions.compileSdkVersion.get().toInt()
9 |
10 | defaultConfig {
11 | minSdk = libs.versions.minSdkVersion.get().toInt()
12 |
13 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
14 | consumerProguardFiles("consumer-rules.pro")
15 | }
16 |
17 | buildTypes {
18 | release {
19 | isMinifyEnabled = false
20 | proguardFiles(
21 | getDefaultProguardFile("proguard-android-optimize.txt"),
22 | "proguard-rules.pro"
23 | )
24 | }
25 | }
26 |
27 | compileOptions {
28 | val javaVersion = libs.versions.javaVersion.get()
29 | sourceCompatibility = JavaVersion.toVersion(javaVersion)
30 | targetCompatibility = JavaVersion.toVersion(javaVersion)
31 | }
32 |
33 | kotlinOptions {
34 | jvmTarget = libs.versions.javaVersion.get()
35 | }
36 | }
37 |
38 | dependencies {
39 | api(projects.shared.network)
40 | api(projects.shared.database)
41 | api(projects.domain.auth)
42 | api(libs.bundles.unit.testing)
43 |
44 | testRuntimeOnly(libs.junit.jupiter.engine)
45 | }
--------------------------------------------------------------------------------
/shared/ui/src/test/java/nl/jovmit/androiddevs/shared/ui/validation/CredentialsValidationTest.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.shared.ui.validation
2 |
3 | import com.google.common.truth.Truth.assertThat
4 | import org.junit.jupiter.params.ParameterizedTest
5 | import org.junit.jupiter.params.provider.CsvSource
6 |
7 | class CredentialsValidationTest {
8 |
9 | @ParameterizedTest
10 | @CsvSource(
11 | "' ', false",
12 | "' a ', false",
13 | "' ab ', false",
14 | "'ab@c', false",
15 | "'abc@d', false",
16 | "'abc@de.f', false",
17 | "'abc@de.fe', true",
18 | )
19 | fun emailValidation(email: String, expected: Boolean) {
20 | val emailValidator = EmailValidator()
21 |
22 | val result = emailValidator.validateEmail(email)
23 |
24 | assertThat(result).isEqualTo(expected)
25 | }
26 |
27 | @ParameterizedTest
28 | @CsvSource(
29 | "' ', false",
30 | "' a ', false",
31 | "'ab ', false",
32 | "'short_1', false",
33 | "'password', false",
34 | "'pAssW0rd', true",
35 | "'abc@De.f1', true",
36 | )
37 | fun passwordValidation(password: String, expected: Boolean) {
38 | val passwordValidator = PasswordValidator()
39 |
40 | val result = passwordValidator.validatePassword(password)
41 |
42 | assertThat(result).isEqualTo(expected)
43 | }
44 | }
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
--------------------------------------------------------------------------------
/domain/auth/src/main/java/nl/jovmit/androiddevs/domain/auth/RemoteAuthRepository.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.domain.auth
2 |
3 | import nl.jovmit.androiddevs.core.network.AuthResponse
4 | import nl.jovmit.androiddevs.core.network.AuthService
5 | import nl.jovmit.androiddevs.core.network.LoginData
6 | import nl.jovmit.androiddevs.domain.auth.data.AuthResult
7 | import nl.jovmit.androiddevs.domain.auth.data.User
8 | import retrofit2.HttpException
9 | import java.io.IOException
10 |
11 | class RemoteAuthRepository(
12 | private val authService: AuthService
13 | ) : AuthRepository {
14 |
15 | override suspend fun login(email: String, password: String): AuthResult {
16 | return try {
17 | val response = authService.login(LoginData(email, password))
18 | val user = response.userData.toDomain()
19 | AuthResult.Success(response.token, user)
20 | } catch (httpException: HttpException) {
21 | backendErrorFor(httpException)
22 | } catch (offline: IOException) {
23 | AuthResult.OfflineError
24 | }
25 | }
26 |
27 | private fun backendErrorFor(httpException: HttpException): AuthResult {
28 | return if (httpException.code() == 401) {
29 | AuthResult.IncorrectCredentials
30 | } else {
31 | AuthResult.BackendError
32 | }
33 | }
34 |
35 | private fun AuthResponse.UserData.toDomain(): User {
36 | return User(
37 | userId = id,
38 | email = email,
39 | about = about
40 | )
41 | }
42 |
43 | override suspend fun signUp(email: String, password: String, about: String): AuthResult {
44 | TODO("Not yet implemented")
45 | }
46 | }
--------------------------------------------------------------------------------
/feature/signup/src/test/java/nl/jovmit/androiddevs/feature/signup/SignUpStatesDeliveryTest.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.signup
2 |
3 | import androidx.lifecycle.SavedStateHandle
4 | import com.google.common.truth.Truth.assertThat
5 | import kotlinx.coroutines.Dispatchers
6 | import kotlinx.coroutines.test.runTest
7 | import nl.jovmit.androiddevs.domain.auth.InMemoryAuthRepository
8 | import nl.jovmit.androiddevs.feature.signup.state.SignUpScreenState
9 | import nl.jovmit.androiddevs.testutils.CoroutineTestExtension
10 | import nl.jovmit.androiddevs.testutils.collectStateFlow
11 | import org.junit.jupiter.api.Test
12 | import org.junit.jupiter.api.extension.ExtendWith
13 |
14 | @ExtendWith(CoroutineTestExtension::class)
15 | class SignUpStatesDeliveryTest {
16 |
17 | private val email = "valid@email.com"
18 | private val password = "Pa\$woRd12."
19 |
20 | private val savedStateHandle = SavedStateHandle()
21 | private val dispatcher = Dispatchers.Unconfined
22 |
23 | @Test
24 | fun statesDeliveredInOrder() = runTest {
25 | val repository = InMemoryAuthRepository().apply { setOffline() }
26 | val viewModel = SignUpViewModel(savedStateHandle, repository, dispatcher).apply {
27 | updateEmail(email)
28 | updatePassword(password)
29 | }
30 |
31 | val deliveredStates = collectStateFlow(viewModel.screenState) {
32 | viewModel.signUp()
33 | }
34 |
35 | assertThat(deliveredStates).isEqualTo(
36 | listOf(
37 | SignUpScreenState(email = email, password = password, isLoading = true),
38 | SignUpScreenState(email = email, password = password, isOfflineError = true)
39 | )
40 | )
41 | }
42 | }
--------------------------------------------------------------------------------
/domain/auth/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.hilt.android)
5 | alias(libs.plugins.kapt)
6 | }
7 |
8 | android {
9 | namespace = "nl.jovmit.androiddevs.domain.auth"
10 | compileSdk = libs.versions.compileSdkVersion.get().toInt()
11 |
12 | defaultConfig {
13 | minSdk = libs.versions.minSdkVersion.get().toInt()
14 |
15 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
16 | consumerProguardFiles("consumer-rules.pro")
17 | }
18 |
19 | buildTypes {
20 | release {
21 | isMinifyEnabled = false
22 | proguardFiles(
23 | getDefaultProguardFile("proguard-android-optimize.txt"),
24 | "proguard-rules.pro"
25 | )
26 | }
27 | }
28 |
29 | compileOptions {
30 | val javaVersion = libs.versions.javaVersion.get()
31 | sourceCompatibility = JavaVersion.toVersion(javaVersion)
32 | targetCompatibility = JavaVersion.toVersion(javaVersion)
33 | }
34 |
35 | kotlinOptions {
36 | jvmTarget = libs.versions.javaVersion.get()
37 | }
38 |
39 | testOptions.unitTests {
40 | isReturnDefaultValues = true
41 | all { tests ->
42 | tests.useJUnitPlatform()
43 | tests.testLogging {
44 | events("passed", "failed", "skipped")
45 | }
46 | }
47 | }
48 | }
49 | dependencies {
50 | implementation(projects.shared.network)
51 | implementation(libs.bundles.hilt)
52 |
53 | kapt(libs.hilt.compiler)
54 |
55 | testImplementation(projects.testutils)
56 |
57 | testRuntimeOnly(libs.junit.jupiter.engine)
58 | }
59 |
60 |
--------------------------------------------------------------------------------
/shared/network/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.kotlin.serialization)
5 | alias(libs.plugins.kapt)
6 | alias(libs.plugins.hilt.android)
7 | }
8 |
9 | android {
10 | namespace = "nl.jovmit.androiddevs.core.network"
11 | compileSdk = libs.versions.compileSdkVersion.get().toInt()
12 |
13 | defaultConfig {
14 | minSdk = libs.versions.minSdkVersion.get().toInt()
15 |
16 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
17 | consumerProguardFiles("consumer-rules.pro")
18 | }
19 |
20 | buildTypes {
21 | release {
22 | isMinifyEnabled = false
23 | proguardFiles(
24 | getDefaultProguardFile("proguard-android-optimize.txt"),
25 | "proguard-rules.pro"
26 | )
27 | }
28 | }
29 |
30 | compileOptions {
31 | val javaVersion = libs.versions.javaVersion.get()
32 | sourceCompatibility = JavaVersion.toVersion(javaVersion)
33 | targetCompatibility = JavaVersion.toVersion(javaVersion)
34 | }
35 |
36 | kotlinOptions {
37 | jvmTarget = libs.versions.javaVersion.get()
38 | }
39 |
40 | testOptions.unitTests {
41 | isReturnDefaultValues = true
42 | all { tests ->
43 | tests.useJUnitPlatform()
44 | tests.testLogging {
45 | events("passed", "failed", "skipped")
46 | }
47 | }
48 | }
49 | }
50 |
51 | dependencies {
52 | api(libs.bundles.retrofit)
53 | implementation(libs.bundles.hilt)
54 |
55 | kapt(libs.hilt.compiler)
56 |
57 | testImplementation(libs.bundles.unit.testing)
58 |
59 | testRuntimeOnly(libs.junit.jupiter.engine)
60 | }
--------------------------------------------------------------------------------
/feature/welcome/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.paparazzi)
5 | alias(libs.plugins.kotlin.compose.compiler)
6 | }
7 |
8 | android {
9 | namespace = "nl.jovmit.androiddevs.feature.welcome"
10 | compileSdk = libs.versions.compileSdkVersion.get().toInt()
11 |
12 | defaultConfig {
13 | minSdk = libs.versions.minSdkVersion.get().toInt()
14 |
15 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
16 | consumerProguardFiles("consumer-rules.pro")
17 | }
18 |
19 | buildTypes {
20 | release {
21 | isMinifyEnabled = false
22 | proguardFiles(
23 | getDefaultProguardFile("proguard-android-optimize.txt"),
24 | "proguard-rules.pro"
25 | )
26 | }
27 | }
28 |
29 | compileOptions {
30 | val javaVersion = libs.versions.javaVersion.get()
31 | sourceCompatibility = JavaVersion.toVersion(javaVersion)
32 | targetCompatibility = JavaVersion.toVersion(javaVersion)
33 | }
34 |
35 | kotlinOptions {
36 | jvmTarget = libs.versions.javaVersion.get()
37 | }
38 |
39 | buildFeatures {
40 | compose = true
41 | }
42 |
43 | testOptions.unitTests {
44 | isReturnDefaultValues = true
45 | all { tests ->
46 | tests.useJUnitPlatform()
47 | tests.testLogging {
48 | events("passed", "failed", "skipped")
49 | }
50 | }
51 | }
52 | }
53 |
54 | dependencies {
55 | implementation(projects.shared.ui)
56 | implementation(projects.domain.auth)
57 |
58 | testImplementation(projects.testutils)
59 |
60 | testRuntimeOnly(libs.junit.jupiter.engine)
61 | }
--------------------------------------------------------------------------------
/shared/network/src/main/java/nl/jovmit/androiddevs/core/network/NetworkModule.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.core.network
2 |
3 | import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
4 | import dagger.Module
5 | import dagger.Provides
6 | import dagger.hilt.InstallIn
7 | import dagger.hilt.components.SingletonComponent
8 | import kotlinx.serialization.json.Json
9 | import okhttp3.MediaType.Companion.toMediaType
10 | import okhttp3.OkHttpClient
11 | import retrofit2.Retrofit
12 | import javax.inject.Singleton
13 |
14 | @Module
15 | @InstallIn(SingletonComponent::class)
16 | object NetworkModule {
17 |
18 | @Singleton
19 | @Provides
20 | fun provideLogoutSignal(): LogoutSignal {
21 | return LogoutSignal()
22 | }
23 |
24 | @Singleton
25 | @Provides
26 | fun provideUnauthorizedInterceptor(
27 | logoutSignal: LogoutSignal
28 | ): ExpiredTokenInterceptor {
29 | return ExpiredTokenInterceptor(logoutSignal)
30 | }
31 |
32 | @Singleton
33 | @Provides
34 | fun provideHttpClient(
35 | expiredAuthInterceptor: ExpiredTokenInterceptor
36 | ): OkHttpClient {
37 | return OkHttpClient.Builder()
38 | .addInterceptor(expiredAuthInterceptor)
39 | .build()
40 | }
41 |
42 | @Singleton
43 | @Provides
44 | fun provideRetrofit(): Retrofit {
45 | val contentType = "application/json".toMediaType()
46 | return Retrofit.Builder()
47 | .baseUrl("https://api.skool.com/")
48 | .addConverterFactory(Json.asConverterFactory(contentType))
49 | .build()
50 | }
51 |
52 | @Singleton
53 | @Provides
54 | fun provideAuthService(retrofit: Retrofit): AuthService {
55 | return retrofit.create(AuthService::class.java)
56 | }
57 | }
--------------------------------------------------------------------------------
/shared/network/src/test/java/nl/jovmit/androiddevs/core/network/StubRequest.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.core.network
2 |
3 | import okhttp3.Call
4 | import okhttp3.Connection
5 | import okhttp3.Interceptor
6 | import okhttp3.Protocol
7 | import okhttp3.Request
8 | import okhttp3.Response
9 | import java.util.concurrent.TimeUnit
10 |
11 | class StubRequest(
12 | private val code: Int
13 | ) : Interceptor.Chain {
14 |
15 | private val request = Request.Builder()
16 | .url("https://dummy.url/")
17 | .build()
18 |
19 | override fun call(): Call {
20 | TODO("Not yet implemented")
21 | }
22 |
23 | override fun connectTimeoutMillis(): Int {
24 | TODO("Not yet implemented")
25 | }
26 |
27 | override fun connection(): Connection? {
28 | TODO("Not yet implemented")
29 | }
30 |
31 | override fun proceed(request: Request): Response {
32 | return Response.Builder()
33 | .request(request)
34 | .protocol(Protocol.HTTP_2)
35 | .code(code)
36 | .message("")
37 | .build()
38 | }
39 |
40 | override fun readTimeoutMillis(): Int {
41 | TODO("Not yet implemented")
42 | }
43 |
44 | override fun request(): Request {
45 | return request
46 | }
47 |
48 | override fun withConnectTimeout(timeout: Int, unit: TimeUnit): Interceptor.Chain {
49 | TODO("Not yet implemented")
50 | }
51 |
52 | override fun withReadTimeout(timeout: Int, unit: TimeUnit): Interceptor.Chain {
53 | TODO("Not yet implemented")
54 | }
55 |
56 | override fun withWriteTimeout(timeout: Int, unit: TimeUnit): Interceptor.Chain {
57 | TODO("Not yet implemented")
58 | }
59 |
60 | override fun writeTimeoutMillis(): Int {
61 | TODO("Not yet implemented")
62 | }
63 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/feature/signup/src/test/java/nl/jovmit/androiddevs/feature/signup/SignUpScreenStateTest.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.signup
2 |
3 | import androidx.lifecycle.SavedStateHandle
4 | import com.google.common.truth.Truth.assertThat
5 | import kotlinx.coroutines.Dispatchers
6 | import kotlinx.coroutines.test.runTest
7 | import nl.jovmit.androiddevs.domain.auth.InMemoryAuthRepository
8 | import nl.jovmit.androiddevs.feature.signup.state.SignUpScreenState
9 | import org.junit.jupiter.api.Test
10 |
11 | class SignUpScreenStateTest {
12 |
13 | private val authRepository = InMemoryAuthRepository()
14 | private val savedStateHandle = SavedStateHandle()
15 |
16 | @Test
17 | fun `email value updating`() {
18 | val newEmailValue = "email@"
19 | val viewModel = SignUpViewModel(savedStateHandle, authRepository, Dispatchers.Unconfined)
20 |
21 | viewModel.updateEmail(newEmailValue)
22 |
23 | assertThat(viewModel.screenState.value).isEqualTo(
24 | SignUpScreenState(email = newEmailValue)
25 | )
26 | }
27 |
28 | @Test
29 | fun `password value updating`() = runTest {
30 | val newValue = ":irrelevant:"
31 | val viewModel = SignUpViewModel(savedStateHandle, authRepository, Dispatchers.Unconfined)
32 |
33 | viewModel.updatePassword(newValue)
34 |
35 | assertThat(viewModel.screenState.value).isEqualTo(
36 | SignUpScreenState(password = newValue)
37 | )
38 | }
39 |
40 | @Test
41 | fun `about value updating`() {
42 | val newValue = ":dunno:"
43 | val viewModel = SignUpViewModel(savedStateHandle, authRepository, Dispatchers.Unconfined)
44 |
45 | viewModel.updateAbout(newValue)
46 |
47 | assertThat(viewModel.screenState.value).isEqualTo(
48 | SignUpScreenState(about = newValue)
49 | )
50 | }
51 | }
--------------------------------------------------------------------------------
/feature/postdetails/src/main/java/nl/jovmit/androiddevs/feature/postdetails/PostDetailsViewModel.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.postdetails
2 |
3 | import androidx.lifecycle.SavedStateHandle
4 | import androidx.lifecycle.ViewModel
5 | import androidx.lifecycle.viewModelScope
6 | import dagger.hilt.android.lifecycle.HiltViewModel
7 | import kotlinx.coroutines.CoroutineDispatcher
8 | import kotlinx.coroutines.delay
9 | import kotlinx.coroutines.flow.MutableStateFlow
10 | import kotlinx.coroutines.flow.asStateFlow
11 | import kotlinx.coroutines.flow.update
12 | import kotlinx.coroutines.launch
13 | import kotlinx.coroutines.withContext
14 | import javax.inject.Inject
15 |
16 | @HiltViewModel
17 | class PostDetailsViewModel @Inject constructor(
18 | savedStateHandle: SavedStateHandle,
19 | private val backgroundDispatcher: CoroutineDispatcher,
20 | ) : ViewModel() {
21 |
22 | private val _screenState = MutableStateFlow(PostDetailsScreenState())
23 | private val postDetailsArgs = PostDetailsDestination(savedStateHandle)
24 | //alternatively, we can do this (if you find it nicer API)
25 | //private val postDetailsArgs = PostDetailsDestination.from(savedStateHandle)
26 |
27 | val screenState = _screenState.asStateFlow()
28 |
29 | fun loadPostDetails() {
30 | val postId = postDetailsArgs.postId
31 | viewModelScope.launch {
32 | setLoading()
33 | val result = withContext(backgroundDispatcher) {
34 | delay(1500) //mimic loading data
35 | "Post $postId"
36 | }
37 | onPostResult(result)
38 | }
39 | }
40 |
41 | private fun setLoading() {
42 | _screenState.update { it.copy(isLoading = true) }
43 | }
44 |
45 | private fun onPostResult(result: String) {
46 | _screenState.update { it.copy(title = result, isLoading = false) }
47 | }
48 | }
--------------------------------------------------------------------------------
/shared/database/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.sql.delight)
5 | }
6 |
7 | android {
8 | namespace = "nl.jovmit.androiddevs.core.database"
9 | compileSdk = libs.versions.compileSdkVersion.get().toInt()
10 |
11 | defaultConfig {
12 | minSdk = libs.versions.minSdkVersion.get().toInt()
13 |
14 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
15 | consumerProguardFiles("consumer-rules.pro")
16 | }
17 |
18 | buildTypes {
19 | release {
20 | isMinifyEnabled = false
21 | proguardFiles(
22 | getDefaultProguardFile("proguard-android-optimize.txt"),
23 | "proguard-rules.pro"
24 | )
25 | }
26 | }
27 |
28 | compileOptions {
29 | val javaVersion = libs.versions.javaVersion.get()
30 | sourceCompatibility = JavaVersion.toVersion(javaVersion)
31 | targetCompatibility = JavaVersion.toVersion(javaVersion)
32 | }
33 |
34 | kotlinOptions {
35 | jvmTarget = libs.versions.javaVersion.get()
36 | }
37 |
38 | testOptions.unitTests {
39 | isReturnDefaultValues = true
40 | all { tests ->
41 | tests.useJUnitPlatform()
42 | tests.testLogging {
43 | events("passed", "failed", "skipped")
44 | }
45 | }
46 | }
47 | }
48 |
49 | sqldelight {
50 | databases {
51 | create("UsersDatabase") {
52 | packageName.set("nl.jovmit.androiddevs.core.database")
53 | }
54 | }
55 | }
56 |
57 | dependencies {
58 | implementation(libs.bundles.hilt)
59 | implementation(libs.sql.delight.android)
60 |
61 | testImplementation(libs.bundles.unit.testing)
62 |
63 | testRuntimeOnly(libs.junit.jupiter.engine)
64 | }
--------------------------------------------------------------------------------
/feature/timeline/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.kotlin.compose.compiler)
5 | alias(libs.plugins.hilt.android)
6 | alias(libs.plugins.kapt)
7 | }
8 |
9 | android {
10 | namespace = "nl.jovmit.androiddevs.feature.timeline"
11 | compileSdk = libs.versions.compileSdkVersion.get().toInt()
12 |
13 | defaultConfig {
14 | minSdk = libs.versions.minSdkVersion.get().toInt()
15 |
16 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
17 | consumerProguardFiles("consumer-rules.pro")
18 | }
19 |
20 | buildTypes {
21 | release {
22 | isMinifyEnabled = false
23 | proguardFiles(
24 | getDefaultProguardFile("proguard-android-optimize.txt"),
25 | "proguard-rules.pro"
26 | )
27 | }
28 | }
29 |
30 | compileOptions {
31 | val javaVersion = libs.versions.javaVersion.get()
32 | sourceCompatibility = JavaVersion.toVersion(javaVersion)
33 | targetCompatibility = JavaVersion.toVersion(javaVersion)
34 | }
35 |
36 | kotlinOptions {
37 | jvmTarget = libs.versions.javaVersion.get()
38 | }
39 |
40 | buildFeatures {
41 | compose = true
42 | }
43 |
44 | testOptions.unitTests {
45 | isReturnDefaultValues = true
46 | all { tests ->
47 | tests.useJUnitPlatform()
48 | tests.testLogging {
49 | events("passed", "failed", "skipped")
50 | }
51 | }
52 | }
53 | }
54 |
55 | dependencies {
56 | implementation(projects.shared.ui)
57 | implementation(projects.shared.network)
58 | implementation(libs.bundles.hilt)
59 |
60 | kapt(libs.hilt.compiler)
61 |
62 | testImplementation(libs.bundles.unit.testing)
63 |
64 | testRuntimeOnly(libs.junit.jupiter.engine)
65 | }
--------------------------------------------------------------------------------
/feature/postdetails/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.kapt)
5 | alias(libs.plugins.hilt.android)
6 | alias(libs.plugins.kotlin.compose.compiler)
7 | alias(libs.plugins.kotlin.serialization)
8 | }
9 |
10 | android {
11 | namespace = "nl.jovmit.androiddevs.feature.postdetails"
12 | compileSdk = libs.versions.compileSdkVersion.get().toInt()
13 |
14 | defaultConfig {
15 | minSdk = libs.versions.minSdkVersion.get().toInt()
16 |
17 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
18 | consumerProguardFiles("consumer-rules.pro")
19 | }
20 |
21 | buildTypes {
22 | release {
23 | isMinifyEnabled = false
24 | proguardFiles(
25 | getDefaultProguardFile("proguard-android-optimize.txt"),
26 | "proguard-rules.pro"
27 | )
28 | }
29 | }
30 |
31 | compileOptions {
32 | val javaVersion = libs.versions.javaVersion.get()
33 | sourceCompatibility = JavaVersion.toVersion(javaVersion)
34 | targetCompatibility = JavaVersion.toVersion(javaVersion)
35 | }
36 |
37 | kotlinOptions {
38 | jvmTarget = libs.versions.javaVersion.get()
39 | }
40 |
41 | buildFeatures {
42 | compose = true
43 | }
44 |
45 | testOptions.unitTests {
46 | isReturnDefaultValues = true
47 | all { tests ->
48 | tests.useJUnitPlatform()
49 | tests.testLogging {
50 | events("passed", "failed", "skipped")
51 | }
52 | }
53 | }
54 | }
55 |
56 | dependencies {
57 | implementation(projects.shared.ui)
58 | implementation(libs.bundles.hilt)
59 |
60 | kapt(libs.hilt.compiler)
61 |
62 | testImplementation(libs.bundles.unit.testing)
63 |
64 | testRuntimeOnly(libs.junit.jupiter.engine)
65 | }
--------------------------------------------------------------------------------
/feature/signup/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.kotlin.compose.compiler)
5 | alias(libs.plugins.kotlin.serialization)
6 | alias(libs.plugins.kapt)
7 | alias(libs.plugins.parcelable)
8 | }
9 |
10 | android {
11 | namespace = "nl.jovmit.androiddevs.feature.signup"
12 | compileSdk = libs.versions.compileSdkVersion.get().toInt()
13 |
14 | defaultConfig {
15 | minSdk = libs.versions.minSdkVersion.get().toInt()
16 |
17 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
18 | consumerProguardFiles("consumer-rules.pro")
19 | }
20 |
21 | buildTypes {
22 | release {
23 | isMinifyEnabled = false
24 | proguardFiles(
25 | getDefaultProguardFile("proguard-android-optimize.txt"),
26 | "proguard-rules.pro"
27 | )
28 | }
29 | }
30 |
31 | compileOptions {
32 | val javaVersion = libs.versions.javaVersion.get()
33 | sourceCompatibility = JavaVersion.toVersion(javaVersion)
34 | targetCompatibility = JavaVersion.toVersion(javaVersion)
35 | }
36 |
37 | kotlinOptions {
38 | jvmTarget = libs.versions.javaVersion.get()
39 | }
40 |
41 | buildFeatures {
42 | compose = true
43 | }
44 |
45 | testOptions.unitTests {
46 | isReturnDefaultValues = true
47 | all { tests ->
48 | tests.useJUnitPlatform()
49 | tests.testLogging {
50 | events("passed", "failed", "skipped")
51 | }
52 | }
53 | }
54 | }
55 |
56 | dependencies {
57 | implementation(projects.shared.ui)
58 | implementation(projects.domain.auth)
59 | implementation(libs.bundles.hilt)
60 |
61 | kapt(libs.hilt.compiler)
62 |
63 | testImplementation(projects.testutils)
64 |
65 | testRuntimeOnly(libs.junit.jupiter.engine)
66 | }
--------------------------------------------------------------------------------
/shared/ui/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.hilt.android)
5 | alias(libs.plugins.kapt)
6 | alias(libs.plugins.kotlin.compose.compiler)
7 | }
8 |
9 | android {
10 | namespace = "nl.jovmit.androiddevs.shared.ui"
11 | compileSdk = libs.versions.compileSdkVersion.get().toInt()
12 |
13 | defaultConfig {
14 | minSdk = libs.versions.minSdkVersion.get().toInt()
15 |
16 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
17 | consumerProguardFiles("consumer-rules.pro")
18 | }
19 |
20 | buildTypes {
21 | release {
22 | isMinifyEnabled = false
23 | proguardFiles(
24 | getDefaultProguardFile("proguard-android-optimize.txt"),
25 | "proguard-rules.pro"
26 | )
27 | }
28 | }
29 |
30 | compileOptions {
31 | val javaVersion = libs.versions.javaVersion.get()
32 | sourceCompatibility = JavaVersion.toVersion(javaVersion)
33 | targetCompatibility = JavaVersion.toVersion(javaVersion)
34 | }
35 |
36 | kotlinOptions {
37 | jvmTarget = libs.versions.javaVersion.get()
38 | }
39 |
40 | buildFeatures {
41 | compose = true
42 | }
43 |
44 | testOptions.unitTests {
45 | isReturnDefaultValues = true
46 | all { tests ->
47 | tests.useJUnitPlatform()
48 | tests.testLogging {
49 | events("passed", "failed", "skipped")
50 | }
51 | }
52 | }
53 | }
54 |
55 | dependencies {
56 | api(platform(libs.compose.bom))
57 | api(libs.bundles.compose)
58 |
59 | debugImplementation(libs.bundles.compose.debug)
60 |
61 | implementation(libs.coil.compose)
62 | implementation(libs.bundles.hilt)
63 |
64 | kapt(libs.hilt.compiler)
65 |
66 | testImplementation(projects.testutils)
67 |
68 | testRuntimeOnly(libs.junit.jupiter.engine)
69 | }
--------------------------------------------------------------------------------
/feature/login/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.hilt.android)
5 | alias(libs.plugins.kapt)
6 | alias(libs.plugins.parcelable)
7 | alias(libs.plugins.paparazzi)
8 | alias(libs.plugins.kotlin.serialization)
9 | alias(libs.plugins.kotlin.compose.compiler)
10 | }
11 |
12 | android {
13 | namespace = "nl.jovmit.androiddevs.feature.login"
14 | compileSdk = libs.versions.compileSdkVersion.get().toInt()
15 |
16 | defaultConfig {
17 | minSdk = libs.versions.minSdkVersion.get().toInt()
18 |
19 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
20 | consumerProguardFiles("consumer-rules.pro")
21 | }
22 |
23 | buildTypes {
24 | release {
25 | isMinifyEnabled = false
26 | proguardFiles(
27 | getDefaultProguardFile("proguard-android-optimize.txt"),
28 | "proguard-rules.pro"
29 | )
30 | }
31 | }
32 |
33 | compileOptions {
34 | val javaVersion = libs.versions.javaVersion.get()
35 | sourceCompatibility = JavaVersion.toVersion(javaVersion)
36 | targetCompatibility = JavaVersion.toVersion(javaVersion)
37 | }
38 |
39 | kotlinOptions {
40 | jvmTarget = libs.versions.javaVersion.get()
41 | }
42 |
43 | buildFeatures {
44 | compose = true
45 | }
46 |
47 | testOptions.unitTests {
48 | isReturnDefaultValues = true
49 | all { tests ->
50 | tests.useJUnitPlatform()
51 | tests.testLogging {
52 | events("passed", "failed", "skipped")
53 | }
54 | }
55 | }
56 | }
57 |
58 | dependencies {
59 | implementation(projects.shared.ui)
60 | implementation(projects.domain.auth)
61 | implementation(libs.bundles.androidx)
62 | implementation(libs.bundles.hilt)
63 |
64 | kapt(libs.hilt.compiler)
65 |
66 | testImplementation(projects.testutils)
67 |
68 | testRuntimeOnly(libs.junit.jupiter.engine)
69 | }
--------------------------------------------------------------------------------
/shared/ui/src/main/java/nl/jovmit/androiddevs/shared/ui/composables/PreviewClips.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.shared.ui.composables
2 |
3 | import androidx.compose.foundation.Image
4 | import androidx.compose.foundation.background
5 | import androidx.compose.foundation.layout.Arrangement
6 | import androidx.compose.foundation.layout.Box
7 | import androidx.compose.foundation.layout.Column
8 | import androidx.compose.foundation.layout.padding
9 | import androidx.compose.foundation.layout.size
10 | import androidx.compose.foundation.layout.wrapContentSize
11 | import androidx.compose.material3.Text
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.ui.Modifier
14 | import androidx.compose.ui.draw.clip
15 | import androidx.compose.ui.res.painterResource
16 | import androidx.compose.ui.tooling.preview.Preview
17 | import androidx.compose.ui.unit.dp
18 | import nl.jovmit.androiddevs.shared.ui.R
19 | import nl.jovmit.androiddevs.shared.ui.theme.AppTheme
20 |
21 | @Composable
22 | @Preview
23 | private fun PreviewClips() {
24 | AppTheme {
25 | Column(
26 | modifier = Modifier.padding(AppTheme.size.medium),
27 | verticalArrangement = Arrangement.spacedBy(AppTheme.size.large)
28 | ) {
29 | Box(
30 | modifier = Modifier
31 | .clip(AppTheme.shape.circular)
32 | .size(200.dp)
33 | .background(AppTheme.colorScheme.primary)
34 | ) {
35 | Text(
36 | text = "Some long text going here",
37 | color = AppTheme.colorScheme.onPrimary
38 | )
39 | }
40 | Box(
41 | modifier = Modifier
42 | .wrapContentSize()
43 | ) {
44 | Image(
45 | modifier = Modifier.size(200.dp),
46 | painter = painterResource(id = R.drawable.imperfect_circle_shape),
47 | contentDescription = null
48 | )
49 | Text(
50 | text = "Some long text going here",
51 | color = AppTheme.colorScheme.onPrimary
52 | )
53 | }
54 | }
55 | }
56 | }
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Deploy To Google Play
2 |
3 | on:
4 | push:
5 | branches: [main]
6 |
7 | jobs:
8 | test:
9 | name: Unit Test
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - uses: actions/checkout@v4
14 |
15 | - name: Setup JDK 17
16 | uses: actions/setup-java@v4
17 | with:
18 | distribution: 'temurin'
19 | java-version: 17
20 | cache: 'gradle'
21 |
22 | - name: Grant execute permissions for gradlew
23 | run: chmod +x gradlew
24 |
25 | - name: Run unit tests
26 | run: ./gradlew clean testDebug
27 |
28 | distribute:
29 | name: Distribute bundle to Google Play
30 | needs: test
31 | runs-on: ubuntu-latest
32 |
33 | steps:
34 | - uses: actions/checkout@v4
35 |
36 | - name: Setup JDK 17
37 | uses: actions/setup-java@v4
38 | with:
39 | distribution: 'temurin'
40 | java-version: 17
41 | cache: 'gradle'
42 |
43 | - name: Version Bump
44 | uses: chkfung/android-version-actions@v1.2.3
45 | with:
46 | gradlePath: app/build.gradle.kts
47 | versionCode: ${{ github.run_number }}
48 |
49 | - name: Assemble Release Bundle
50 | run: ./gradlew bundleRelease
51 |
52 | - name: Sign Release
53 | uses: r0adkll/sign-android-release@v1
54 | with:
55 | releaseDirectory: app/build/outputs/bundle/release
56 | signingKeyBase64: ${{ secrets.ANDROID_KEYSTORE }}
57 | keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
58 | alias: ${{ secrets.ANDROID_DEVS_ALIAS }}
59 | keyPassword: ${{ secrets.ANDROID_DEVS_ALIAS_PASSWORD }}
60 |
61 | - name: Setup Authorization with Google Play Store
62 | run: echo '${{ secrets.PLAY_AUTH_JSON }}' > service_account.json
63 |
64 | - name: Deploy bundle to Google Play
65 | uses: r0adkll/upload-google-play@v1.1.3
66 | with:
67 | serviceAccountJson: service_account.json
68 | packageName: nl.jovmit.androiddevs
69 | releaseFiles: app/build/outputs/bundle/release/app-release.aab
70 | track: 'internal'
71 | status: 'completed'
72 | whatsNewDirectory: whatsNew/
--------------------------------------------------------------------------------
/shared/ui/src/main/java/nl/jovmit/androiddevs/shared/ui/composables/ImperfectCircleShape.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.shared.ui.composables
2 |
3 | import android.graphics.Matrix
4 | import androidx.compose.ui.geometry.Offset
5 | import androidx.compose.ui.geometry.Size
6 | import androidx.compose.ui.graphics.Outline
7 | import androidx.compose.ui.graphics.Shape
8 | import androidx.compose.ui.graphics.asAndroidPath
9 | import androidx.compose.ui.graphics.asComposePath
10 | import androidx.compose.ui.unit.Density
11 | import androidx.compose.ui.unit.LayoutDirection
12 | import androidx.core.graphics.PathParser
13 |
14 | class ImperfectCircleShape : Shape {
15 |
16 | override fun createOutline(
17 | size: Size,
18 | layoutDirection: LayoutDirection,
19 | density: Density
20 | ): Outline {
21 | return Outline.Generic(
22 | path = PathParser.createPathFromPathData(
23 | "M256.348 170.923C250.501 187.464 239.892 201.828 228.886 214.097C217.516 226.877 203.772 237.308 188.425 244.806C173.281 252.066 156.318 258.008 139.297 258.921C122.873 259.802 106.43 253.11 89.9612 247.265C73.4921 241.421 56.2824 236.496 44.0622 225.447C31.3872 213.999 20.96 198.99 13.7292 183.786C6.49838 168.581 1.7623 150.989 0.852762 133.899C0.709835 131.208 0.66003 128.479 0.703341 125.713C0.92403 111.119 3.49078 96.6569 8.30445 82.8847C14.1515 66.3494 26.4952 53.5648 37.5395 41.2954C48.9412 28.5695 61.2264 16.5089 76.3702 9.2425C91.5139 1.97612 108.646 1.12163 125.667 0.208441C142.244 -0.757879 158.845 1.64253 174.477 7.2661C190.946 13.1366 204.972 21.1531 217.192 32.1962C229.848 43.6306 242.588 55.3716 249.819 70.5697C257.049 85.7677 259.869 102.988 260.772 120.084C260.856 121.61 260.934 123.156 261.006 124.709C261.694 139.868 261.629 155.927 256.348 170.923Z"
24 | ).asComposePath().apply {
25 | val pathSize = getBounds().size
26 | val matrix = Matrix()
27 | matrix.postScale(
28 | size.width / pathSize.width,
29 | size.height / pathSize.height
30 | )
31 | asAndroidPath().transform(matrix)
32 | val left = getBounds().left
33 | val top = getBounds().top
34 | translate(Offset(-left, -top))
35 | }
36 | )
37 | }
38 | }
--------------------------------------------------------------------------------
/shared/ui/src/main/java/nl/jovmit/androiddevs/shared/ui/composables/EmailInput.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.shared.ui.composables
2 |
3 | import androidx.compose.foundation.text.KeyboardActions
4 | import androidx.compose.foundation.text.KeyboardOptions
5 | import androidx.compose.material3.Text
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.Modifier
8 | import androidx.compose.ui.res.stringResource
9 | import androidx.compose.ui.text.input.ImeAction
10 | import androidx.compose.ui.text.input.KeyboardType
11 | import androidx.compose.ui.tooling.preview.PreviewLightDark
12 | import nl.jovmit.androiddevs.shared.ui.R
13 | import nl.jovmit.androiddevs.shared.ui.theme.AppTheme
14 |
15 | @Composable
16 | fun EmailInput(
17 | modifier: Modifier = Modifier,
18 | email: String,
19 | isInvalidEmailFormat: Boolean = false,
20 | keyboardOptions: KeyboardOptions = KeyboardOptions(
21 | keyboardType = KeyboardType.Email,
22 | imeAction = ImeAction.Done
23 | ),
24 | keyboardActions: KeyboardActions = KeyboardActions.Default,
25 | onEmailChanged: (newValue: String) -> Unit,
26 | testTag: String = "email"
27 | ) {
28 | TextInput(
29 | modifier = modifier,
30 | text = email,
31 | label = stringResource(id = R.string.enter_email),
32 | hint = stringResource(id = R.string.email_hint),
33 | onTextChanged = onEmailChanged,
34 | keyboardOptions = keyboardOptions,
35 | keyboardActions = keyboardActions,
36 | error = {
37 | if (isInvalidEmailFormat) {
38 | Text(
39 | text = stringResource(id = R.string.error_bad_email_format),
40 | color = AppTheme.colorScheme.error
41 | )
42 | }
43 | },
44 | testTag = testTag
45 | )
46 | }
47 |
48 | @Composable
49 | @PreviewLightDark
50 | private fun EmailInputPreview() {
51 | AppTheme {
52 | EmailInput(
53 | email = "",
54 | onEmailChanged = {}
55 | )
56 | }
57 | }
58 |
59 | @Composable
60 | @PreviewLightDark
61 | private fun EmailInputPreviewWithError() {
62 | AppTheme {
63 | EmailInput(
64 | email = "",
65 | onEmailChanged = {},
66 | isInvalidEmailFormat = true
67 | )
68 | }
69 | }
--------------------------------------------------------------------------------
/testutils/src/main/java/nl/jovmit/androiddevs/domain/auth/InMemoryAuthRepository.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.domain.auth
2 |
3 | import nl.jovmit.androiddevs.domain.auth.data.AuthResult
4 | import nl.jovmit.androiddevs.domain.auth.data.User
5 | import java.util.UUID
6 |
7 | class InMemoryAuthRepository(
8 | private val authToken: String = "",
9 | usersForPassword: Map> = emptyMap()
10 | ) : AuthRepository {
11 |
12 | private var isUnavailable = false
13 | private var isOffline = false
14 |
15 | private val _usersForPassword = usersForPassword.toMutableMap()
16 |
17 | override suspend fun login(email: String, password: String): AuthResult {
18 | if (isUnavailable) return AuthResult.BackendError
19 | if (isOffline) return AuthResult.OfflineError
20 | val matchingUsers = _usersForPassword.getOrElse(password) { emptyList() }
21 | val found = matchingUsers.find { it.email == email }
22 | found?.let { user ->
23 | return AuthResult.Success(authToken, user)
24 | }
25 | return AuthResult.IncorrectCredentials
26 | }
27 |
28 | override suspend fun signUp(
29 | email: String,
30 | password: String,
31 | about: String
32 | ): AuthResult {
33 | if (isUnavailable) return AuthResult.BackendError
34 | if (isOffline) return AuthResult.OfflineError
35 | if (isKnownUser(email)) return AuthResult.ExistingUserError
36 | val user = User(UUID.randomUUID().toString(), email, about)
37 | saveUserData(password, user)
38 | return AuthResult.Success(authToken, user)
39 | }
40 |
41 | private fun isKnownUser(email: String) = _usersForPassword.values.flatten().any {
42 | it.email == email
43 | }
44 |
45 | private fun saveUserData(password: String, user: User) {
46 | val currentUsers = _usersForPassword.getOrElse(password) { emptyList() }
47 | currentUsers.toMutableList().apply {
48 | add(user)
49 | }
50 | _usersForPassword[password] = currentUsers
51 | }
52 |
53 | fun setLoggedInUsers(usersForPassword: Map>) {
54 | _usersForPassword.putAll(usersForPassword)
55 | }
56 |
57 | fun setUnavailable() {
58 | isUnavailable = true
59 | }
60 |
61 | fun setOffline() {
62 | isOffline = true
63 | }
64 | }
--------------------------------------------------------------------------------
/shared/ui/src/main/java/nl/jovmit/androiddevs/shared/ui/composables/Buttons.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.shared.ui.composables
2 |
3 | import androidx.compose.foundation.BorderStroke
4 | import androidx.compose.foundation.layout.Arrangement
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.material3.Button
8 | import androidx.compose.material3.ButtonDefaults
9 | import androidx.compose.material3.OutlinedButton
10 | import androidx.compose.material3.Text
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.ui.Modifier
13 | import androidx.compose.ui.tooling.preview.PreviewLightDark
14 | import androidx.compose.ui.unit.dp
15 | import nl.jovmit.androiddevs.shared.ui.theme.AppTheme
16 |
17 | @Composable
18 | fun PrimaryButton(
19 | modifier: Modifier = Modifier,
20 | label: String,
21 | onClick: () -> Unit
22 | ) {
23 | Button(
24 | modifier = modifier,
25 | onClick = onClick,
26 | colors = ButtonDefaults.buttonColors(
27 | containerColor = AppTheme.colorScheme.primary,
28 | contentColor = AppTheme.colorScheme.onPrimary
29 | ),
30 | shape = AppTheme.shape.button
31 | ) {
32 | Text(
33 | text = label,
34 | style = AppTheme.typography.labelLarge
35 | )
36 | }
37 | }
38 |
39 | @Composable
40 | fun SecondaryButton(
41 | modifier: Modifier = Modifier,
42 | label: String,
43 | onClick: () -> Unit
44 | ) {
45 | OutlinedButton(
46 | modifier = modifier,
47 | onClick = onClick,
48 | colors = ButtonDefaults.outlinedButtonColors(
49 | containerColor = AppTheme.colorScheme.secondary,
50 | contentColor = AppTheme.colorScheme.onSecondary
51 | ),
52 | shape = AppTheme.shape.button,
53 | border = BorderStroke(2.dp, AppTheme.colorScheme.onSecondary)
54 | ) {
55 | Text(
56 | text = label,
57 | style = AppTheme.typography.labelLarge
58 | )
59 | }
60 | }
61 |
62 | @PreviewLightDark
63 | @Composable
64 | private fun PreviewPrimaryButton() {
65 | AppTheme {
66 | Column(
67 | modifier = Modifier
68 | .padding(AppTheme.size.medium),
69 | verticalArrangement = Arrangement.spacedBy(AppTheme.size.normal)
70 | ) {
71 | PrimaryButton(
72 | label = "Primary",
73 | onClick = {}
74 | )
75 | SecondaryButton(
76 | label = "Secondary",
77 | onClick = {}
78 | )
79 | }
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/shared/ui/src/main/java/nl/jovmit/androiddevs/shared/ui/theme/AppDesignSystem.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.shared.ui.theme
2 |
3 | import androidx.compose.foundation.shape.CircleShape
4 | import androidx.compose.runtime.Stable
5 | import androidx.compose.runtime.staticCompositionLocalOf
6 | import androidx.compose.ui.graphics.Brush
7 | import androidx.compose.ui.graphics.Color
8 | import androidx.compose.ui.graphics.RectangleShape
9 | import androidx.compose.ui.graphics.Shape
10 | import androidx.compose.ui.text.TextStyle
11 | import androidx.compose.ui.unit.Dp
12 |
13 | @Stable
14 | data class AppColorScheme(
15 | val background: Color,
16 | val onBackground: Color,
17 | val primary: Color,
18 | val onPrimary: Color,
19 | val secondary: Color,
20 | val onSecondary: Color,
21 | val separator: Color,
22 | val error: Color
23 | ) {
24 |
25 | val primaryHorizontalGradient: Brush = Brush.horizontalGradient(listOf(primary, secondary))
26 | }
27 |
28 | @Stable
29 | data class AppTypography(
30 | val titleLarge: TextStyle,
31 | val titleNormal: TextStyle,
32 | val paragraph: TextStyle,
33 | val labelLarge: TextStyle,
34 | val labelNormal: TextStyle,
35 | val labelSmall: TextStyle
36 | )
37 |
38 | @Stable
39 | data class AppShape(
40 | val container: Shape,
41 | val button: Shape,
42 | val circular: Shape
43 | )
44 |
45 | @Stable
46 | data class AppSize(
47 | val large: Dp,
48 | val medium: Dp,
49 | val normal: Dp,
50 | val small: Dp
51 | )
52 |
53 | val LocalAppColorScheme = staticCompositionLocalOf {
54 | AppColorScheme(
55 | background = Color.Unspecified,
56 | onBackground = Color.Unspecified,
57 | primary = Color.Unspecified,
58 | onPrimary = Color.Unspecified,
59 | secondary = Color.Unspecified,
60 | onSecondary = Color.Unspecified,
61 | separator = Color.Unspecified,
62 | error = Color.Unspecified
63 | )
64 | }
65 |
66 | val LocalAppTypography = staticCompositionLocalOf {
67 | AppTypography(
68 | titleLarge = TextStyle.Default,
69 | titleNormal = TextStyle.Default,
70 | paragraph = TextStyle.Default,
71 | labelLarge = TextStyle.Default,
72 | labelNormal = TextStyle.Default,
73 | labelSmall = TextStyle.Default
74 | )
75 | }
76 |
77 | val LocalAppShape = staticCompositionLocalOf {
78 | AppShape(
79 | container = RectangleShape,
80 | button = RectangleShape,
81 | circular = CircleShape
82 | )
83 | }
84 |
85 | val LocalAppSize = staticCompositionLocalOf {
86 | AppSize(
87 | large = Dp.Unspecified,
88 | medium = Dp.Unspecified,
89 | normal = Dp.Unspecified,
90 | small = Dp.Unspecified
91 | )
92 | }
--------------------------------------------------------------------------------
/domain/auth/src/test/java/nl/jovmit/androiddevs/base/auth/AuthContractTest.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.base.auth
2 |
3 | import com.google.common.truth.Truth.assertThat
4 | import kotlinx.coroutines.test.runTest
5 | import nl.jovmit.androiddevs.domain.auth.AuthRepository
6 | import nl.jovmit.androiddevs.domain.auth.data.AuthResult
7 | import nl.jovmit.androiddevs.domain.auth.data.User
8 | import nl.jovmit.androiddevs.domain.auth.data.UserBuilder.Companion.aUser
9 | import org.junit.jupiter.api.Test
10 | import java.util.UUID
11 |
12 | abstract class AuthContractTest {
13 |
14 | private val authToken = UUID.randomUUID().toString()
15 | private val bobPassword = ":irrelevant:"
16 | private val bob = aUser().withEmail(":irrelevant_too:").build()
17 |
18 | @Test
19 | fun successfulLogin() = runTest {
20 | val usersForPassword = mapOf(bobPassword to listOf(bob))
21 | val repository = authRepositoryWith(authToken, usersForPassword)
22 |
23 | val result = repository.login(bob.email, bobPassword)
24 |
25 | assertThat(result).isEqualTo(AuthResult.Success(authToken, bob))
26 | }
27 |
28 | @Test
29 | fun attemptToLoginWithWrongPassword() = runTest {
30 | val usersForPassword = mapOf(bobPassword to listOf(bob))
31 | val repository = authRepositoryWith(authToken, usersForPassword)
32 |
33 | val result = repository.login(bob.email, "otherThan$bobPassword")
34 |
35 | assertThat(result).isEqualTo(AuthResult.IncorrectCredentials)
36 | }
37 |
38 | @Test
39 | fun attemptToLoginWithWrongEmail() = runTest {
40 | val usersForPassword = mapOf(bobPassword to listOf(bob))
41 | val repository = authRepositoryWith(authToken, usersForPassword)
42 |
43 | val result = repository.login("anythingBut${bob.email}", bobPassword)
44 |
45 | assertThat(result).isEqualTo(AuthResult.IncorrectCredentials)
46 | }
47 |
48 | @Test
49 | fun backendErrorWhenLoggingIn() = runTest {
50 | val repository = unavailableAuthRepository()
51 |
52 | val result = repository.login(":irrelevant:", ":irrelevant")
53 |
54 | assertThat(result).isEqualTo(AuthResult.BackendError)
55 | }
56 |
57 | @Test
58 | fun offlineErrorWhenLoggingIn() = runTest {
59 | val repository = offlineAuthRepository()
60 |
61 | val result = repository.login(":irrelevant:", ":irrelevant")
62 |
63 | assertThat(result).isEqualTo(AuthResult.OfflineError)
64 | }
65 |
66 | abstract fun authRepositoryWith(
67 | authToken: String,
68 | usersForPassword: Map>
69 | ): AuthRepository
70 |
71 | abstract fun unavailableAuthRepository(): AuthRepository
72 |
73 | abstract fun offlineAuthRepository(): AuthRepository
74 | }
--------------------------------------------------------------------------------
/feature/login/src/test/java/nl/jovmit/androiddevs/feature/login/LoginScreenStateTest.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.login
2 |
3 | import androidx.lifecycle.SavedStateHandle
4 | import com.google.common.truth.Truth.assertThat
5 | import kotlinx.coroutines.Dispatchers
6 | import nl.jovmit.androiddevs.domain.auth.InMemoryAuthRepository
7 | import nl.jovmit.androiddevs.testutils.CoroutineTestExtension
8 | import org.junit.jupiter.api.Test
9 | import org.junit.jupiter.api.extension.ExtendWith
10 |
11 | @ExtendWith(CoroutineTestExtension::class)
12 | class LoginScreenStateTest {
13 |
14 | private val savedStateHandle = SavedStateHandle()
15 | private val usersCatalog = InMemoryAuthRepository(usersForPassword = emptyMap())
16 | private val backgroundDispatcher = Dispatchers.Unconfined
17 |
18 | @Test
19 | fun defaultScreenState() {
20 | val viewModel = LoginViewModel(savedStateHandle, usersCatalog, backgroundDispatcher)
21 |
22 | assertThat(viewModel.screenState.value)
23 | .isEqualTo(LoginScreenState())
24 | }
25 |
26 | @Test
27 | fun updateEmail() {
28 | val updatedEmail = ":some email:"
29 | val viewModel = LoginViewModel(savedStateHandle, usersCatalog, backgroundDispatcher)
30 |
31 | viewModel.updateEmail(updatedEmail)
32 |
33 | assertThat(viewModel.screenState.value)
34 | .isEqualTo(LoginScreenState(email = updatedEmail))
35 | }
36 |
37 | @Test
38 | fun updatePassword() {
39 | val newPassword = ":a password:"
40 | val viewModel = LoginViewModel(savedStateHandle, usersCatalog, backgroundDispatcher)
41 |
42 | viewModel.updatePassword(newPassword)
43 |
44 | assertThat(viewModel.screenState.value)
45 | .isEqualTo(LoginScreenState(password = newPassword))
46 | }
47 |
48 | @Test
49 | fun resetWrongEmailState() {
50 | val initialEmailValue = "something"
51 | val viewModel = LoginViewModel(savedStateHandle, usersCatalog, backgroundDispatcher).apply {
52 | updateEmail(initialEmailValue)
53 | login()
54 | }
55 |
56 | viewModel.updateEmail("$initialEmailValue@")
57 |
58 | assertThat(viewModel.screenState.value.isWrongEmailFormat)
59 | .isEqualTo(false)
60 | }
61 |
62 | @Test
63 | fun resetWrongPasswordState() {
64 | val initialPasswordValue = "pass"
65 | val viewModel = LoginViewModel(savedStateHandle, usersCatalog, backgroundDispatcher).apply {
66 | updateEmail("bob@app.com")
67 | updatePassword(initialPasswordValue)
68 | login()
69 | }
70 |
71 | viewModel.updatePassword("$initialPasswordValue.")
72 |
73 | assertThat(viewModel.screenState.value.isBadPasswordFormat)
74 | .isEqualTo(false)
75 | }
76 | }
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.application)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.kapt)
5 | alias(libs.plugins.hilt.android)
6 | alias(libs.plugins.kotlin.compose.compiler)
7 | }
8 |
9 | android {
10 | namespace = "nl.jovmit.androiddevs"
11 | compileSdk = libs.versions.compileSdkVersion.get().toInt()
12 |
13 | defaultConfig {
14 | applicationId = "nl.jovmit.androiddevs"
15 | minSdk = libs.versions.minSdkVersion.get().toInt()
16 | targetSdk = libs.versions.compileSdkVersion.get().toInt()
17 | versionCode = 1
18 | versionName = "1.0"
19 |
20 | testInstrumentationRunner = "nl.jovmit.androiddevs.CustomTestRunner"
21 | vectorDrawables {
22 | useSupportLibrary = true
23 | }
24 | }
25 |
26 | buildTypes {
27 | release {
28 | isMinifyEnabled = false
29 | proguardFiles(
30 | getDefaultProguardFile("proguard-android-optimize.txt"),
31 | "proguard-rules.pro"
32 | )
33 | }
34 | }
35 |
36 | compileOptions {
37 | val javaVersion = libs.versions.javaVersion.get()
38 | sourceCompatibility = JavaVersion.toVersion(javaVersion)
39 | targetCompatibility = JavaVersion.toVersion(javaVersion)
40 | }
41 |
42 | kotlinOptions {
43 | jvmTarget = libs.versions.javaVersion.get()
44 | }
45 |
46 | buildFeatures {
47 | compose = true
48 | }
49 |
50 | packaging {
51 | resources {
52 | excludes += "/META-INF/{AL2.0,LGPL2.1}"
53 | excludes += "META-INF/LICENSE.md"
54 | excludes += "META-INF/LICENSE-notice.md"
55 | }
56 | }
57 |
58 | testOptions.unitTests {
59 | isReturnDefaultValues = true
60 | all { tests ->
61 | tests.useJUnitPlatform()
62 | tests.testLogging {
63 | events("passed", "failed", "skipped")
64 | }
65 | }
66 | }
67 | }
68 |
69 | dependencies {
70 | implementation(projects.shared.ui)
71 | implementation(projects.shared.network)
72 | implementation(projects.feature.welcome)
73 | implementation(projects.feature.signup)
74 | implementation(projects.feature.login)
75 | implementation(projects.feature.timeline)
76 | implementation(projects.feature.postdetails)
77 |
78 | implementation(libs.bundles.androidx)
79 | implementation(libs.bundles.hilt)
80 |
81 | kapt(libs.hilt.compiler)
82 |
83 | androidTestImplementation(platform(libs.compose.bom))
84 | androidTestImplementation(libs.bundles.ui.testing)
85 | androidTestImplementation(projects.testutils)
86 |
87 | kaptAndroidTest(libs.hilt.android.test.compiler)
88 |
89 | testImplementation(projects.testutils)
90 |
91 | testRuntimeOnly(libs.junit.jupiter.engine)
92 | }
--------------------------------------------------------------------------------
/app/src/androidTest/java/nl/jovmit/androiddevs/LoginScreenRobot.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs
2 |
3 | import androidx.compose.ui.test.assertIsDisplayed
4 | import androidx.compose.ui.test.junit4.AndroidComposeTestRule
5 | import androidx.compose.ui.test.onNodeWithTag
6 | import androidx.compose.ui.test.onNodeWithText
7 | import androidx.compose.ui.test.performClick
8 | import androidx.compose.ui.test.performTextInput
9 | import androidx.test.ext.junit.rules.ActivityScenarioRule
10 | import nl.jovmit.androiddevs.shared.ui.R
11 |
12 | fun launchLoginScreen(
13 | rule: AndroidComposeTestRule, MainActivity>,
14 | block: LoginRobot.() -> Unit
15 | ): LoginRobot {
16 | val loginButtonText = rule.activity.getString(R.string.login_title)
17 | rule.onNodeWithText(loginButtonText).performClick()
18 | return LoginRobot(rule).apply(block)
19 | }
20 |
21 | class LoginRobot(
22 | private val rule: AndroidComposeTestRule, MainActivity>
23 | ) {
24 |
25 | fun typeEmail(email: String) {
26 | rule.onNodeWithTag("email")
27 | .performTextInput(email)
28 | }
29 |
30 | fun typePassword(password: String) {
31 | rule.onNodeWithTag("password")
32 | .performTextInput(password)
33 | }
34 |
35 | fun submit() {
36 | rule.onNodeWithTag("loginButton")
37 | .performClick()
38 | }
39 |
40 | infix fun verify(
41 | block: LoginVerification.() -> Unit
42 | ): LoginVerification {
43 | return LoginVerification(rule).apply(block)
44 | }
45 | }
46 |
47 | class LoginVerification(
48 | private val rule: AndroidComposeTestRule, MainActivity>
49 | ) {
50 |
51 | fun badEmailErrorIsShown() {
52 | val badEmailErrorMessage = rule.activity.getString(R.string.error_bad_email_format)
53 | rule.onNodeWithText(badEmailErrorMessage)
54 | .assertIsDisplayed()
55 | }
56 |
57 | fun badEmailErrorIsGone() {
58 | val badEmailErrorMessage = rule.activity.getString(R.string.error_bad_email_format)
59 | rule.onNodeWithText(badEmailErrorMessage)
60 | .assertDoesNotExist()
61 | }
62 |
63 | fun badPasswordErrorIsShown() {
64 | val badPasswordErrorMessage = rule.activity.getString(R.string.error_bad_password_format)
65 | rule.onNodeWithText(badPasswordErrorMessage)
66 | .assertIsDisplayed()
67 | }
68 |
69 | fun badPasswordErrorIsGone() {
70 | val badPasswordErrorMessage = rule.activity.getString(R.string.error_bad_password_format)
71 | rule.onNodeWithText(badPasswordErrorMessage)
72 | .assertDoesNotExist()
73 | }
74 |
75 | fun loginErrorMessageIsShown() {
76 | val invalidCredentialsError = rule.activity.getString(R.string.error_invalid_credentials)
77 | rule.onNodeWithText(invalidCredentialsError)
78 | .assertIsDisplayed()
79 | }
80 |
81 | fun userLoggedInSuccessfully() {
82 | rule.onNodeWithText("Timeline")
83 | .assertIsDisplayed()
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/app/src/main/java/nl/jovmit/androiddevs/MainApp.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs
2 |
3 | import androidx.compose.animation.AnimatedContentTransitionScope
4 | import androidx.compose.animation.core.tween
5 | import androidx.compose.animation.scaleIn
6 | import androidx.compose.animation.scaleOut
7 | import androidx.compose.foundation.layout.fillMaxSize
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.runtime.LaunchedEffect
10 | import androidx.compose.ui.Modifier
11 | import androidx.hilt.navigation.compose.hiltViewModel
12 | import androidx.navigation.compose.NavHost
13 | import androidx.navigation.compose.rememberNavController
14 | import nl.jovmit.androiddevs.feature.login.loginScreen
15 | import nl.jovmit.androiddevs.feature.login.navigateToLogin
16 | import nl.jovmit.androiddevs.feature.postdetails.navigateToPostDetails
17 | import nl.jovmit.androiddevs.feature.postdetails.postDetailsScreen
18 | import nl.jovmit.androiddevs.feature.signup.navigateToSignUp
19 | import nl.jovmit.androiddevs.feature.signup.signUpScreen
20 | import nl.jovmit.androiddevs.feature.timeline.navigateToTimeline
21 | import nl.jovmit.androiddevs.feature.timeline.timelineScreen
22 | import nl.jovmit.androiddevs.feature.welcome.WELCOME_ROUTE
23 | import nl.jovmit.androiddevs.feature.welcome.welcomeScreen
24 |
25 | @Composable
26 | fun MainApp(
27 | mainViewModel: MainAppViewModel = hiltViewModel()
28 | ) {
29 |
30 | val navController = rememberNavController()
31 |
32 | LaunchedEffect(Unit) {
33 | mainViewModel.observeLoggedOut()
34 | mainViewModel.loggedOut.collect {
35 | navController.navigateToLogin()
36 | }
37 | }
38 |
39 | NavHost(
40 | modifier = Modifier.fillMaxSize(),
41 | navController = navController,
42 | startDestination = WELCOME_ROUTE,
43 | enterTransition = {
44 | slideIntoContainer(
45 | towards = AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = tween()
46 | )
47 | },
48 | exitTransition = {
49 | scaleOut(targetScale = .9f, animationSpec = tween())
50 | },
51 | popEnterTransition = {
52 | scaleIn(initialScale = .9f, animationSpec = tween())
53 | },
54 | popExitTransition = {
55 | slideOutOfContainer(
56 | towards = AnimatedContentTransitionScope.SlideDirection.Right, animationSpec = tween()
57 | )
58 | }
59 | ) {
60 | welcomeScreen(
61 | onLogin = { navController.navigateToLogin() },
62 | onSignUp = { navController.navigateToSignUp() }
63 | )
64 |
65 | signUpScreen(
66 | onNavigateUp = { navController.navigateUp() }
67 | )
68 |
69 | loginScreen(
70 | onLoggedIn = { navController.navigateToTimeline() },
71 | onNavigateUp = { navController.navigateUp() }
72 | )
73 |
74 | timelineScreen(
75 | onItemClicked = { itemId ->
76 | navController.navigateToPostDetails(itemId)
77 | }
78 | )
79 |
80 | postDetailsScreen(
81 | onNavigateUp = { navController.navigateUp() }
82 | )
83 | }
84 | }
--------------------------------------------------------------------------------
/app/src/androidTest/java/nl/jovmit/androiddevs/LoginTest.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs
2 |
3 | import androidx.compose.ui.test.junit4.createAndroidComposeRule
4 | import dagger.hilt.android.testing.BindValue
5 | import dagger.hilt.android.testing.HiltAndroidRule
6 | import dagger.hilt.android.testing.HiltAndroidTest
7 | import dagger.hilt.android.testing.UninstallModules
8 | import nl.jovmit.androiddevs.domain.auth.AuthModule
9 | import nl.jovmit.androiddevs.domain.auth.AuthRepository
10 | import nl.jovmit.androiddevs.domain.auth.InMemoryAuthRepository
11 | import nl.jovmit.androiddevs.domain.auth.data.User
12 | import org.junit.Rule
13 | import org.junit.Test
14 |
15 | @HiltAndroidTest
16 | @UninstallModules(AuthModule::class)
17 | class LoginTest {
18 |
19 | @get:Rule(order = 0)
20 | val hiltRule = HiltAndroidRule(this)
21 |
22 | @get:Rule(order = 1)
23 | val loginTestRule = createAndroidComposeRule()
24 |
25 | @BindValue
26 | @JvmField
27 | val usersCatalog: AuthRepository = InMemoryAuthRepository(usersForPassword = emptyMap())
28 |
29 | @Test
30 | fun displayBadEmailFormatError() {
31 | launchLoginScreen(loginTestRule) {
32 | typeEmail("email")
33 | submit()
34 | } verify {
35 | badEmailErrorIsShown()
36 | }
37 | }
38 |
39 | @Test
40 | fun resetBadEmailFormatError() {
41 | launchLoginScreen(loginTestRule) {
42 | typeEmail("email")
43 | submit()
44 | typeEmail("email@")
45 | } verify {
46 | badEmailErrorIsGone()
47 | }
48 | }
49 |
50 | @Test
51 | fun displayBadPasswordError() {
52 | launchLoginScreen(loginTestRule) {
53 | typeEmail("email@email.com")
54 | typePassword("bad pass")
55 | submit()
56 | } verify {
57 | badPasswordErrorIsShown()
58 | }
59 | }
60 |
61 | @Test
62 | fun resetBadPasswordFormatError() {
63 | launchLoginScreen(loginTestRule) {
64 | typeEmail("email@email.com")
65 | typePassword("bad pass")
66 | submit()
67 | typePassword("bad pass1")
68 | } verify {
69 | badPasswordErrorIsGone()
70 | }
71 | }
72 |
73 | @Test
74 | fun errorLoggingIn() {
75 | launchLoginScreen(loginTestRule) {
76 | typeEmail("email@email.com")
77 | typePassword("passWord12.")
78 | submit()
79 | } verify {
80 | loginErrorMessageIsShown()
81 | }
82 | }
83 |
84 | @Test
85 | fun successfulLogin() {
86 | val email = "email@email.com"
87 | val password = "passWord12."
88 | setupAuthRepositoryWith(email, password)
89 |
90 | launchLoginScreen(loginTestRule) {
91 | typeEmail(email)
92 | typePassword(password)
93 | submit()
94 | } verify {
95 | userLoggedInSuccessfully()
96 | }
97 | }
98 |
99 | private fun setupAuthRepositoryWith(email: String, password: String) {
100 | (usersCatalog as InMemoryAuthRepository)
101 | .setLoggedInUsers(mapOf(password to listOf(User("", email, ""))))
102 | }
103 | }
104 |
105 |
106 |
107 |
108 |
109 |
--------------------------------------------------------------------------------
/feature/login/src/main/java/nl/jovmit/androiddevs/feature/login/LoginViewModel.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.login
2 |
3 | import androidx.lifecycle.SavedStateHandle
4 | import androidx.lifecycle.ViewModel
5 | import androidx.lifecycle.viewModelScope
6 | import dagger.hilt.android.lifecycle.HiltViewModel
7 | import kotlinx.coroutines.CoroutineDispatcher
8 | import kotlinx.coroutines.flow.StateFlow
9 | import kotlinx.coroutines.launch
10 | import kotlinx.coroutines.withContext
11 | import nl.jovmit.androiddevs.shared.ui.validation.EmailValidator
12 | import nl.jovmit.androiddevs.shared.ui.extensions.update
13 | import nl.jovmit.androiddevs.shared.ui.validation.PasswordValidator
14 | import nl.jovmit.androiddevs.domain.auth.AuthRepository
15 | import nl.jovmit.androiddevs.domain.auth.data.AuthResult
16 | import javax.inject.Inject
17 |
18 | @HiltViewModel
19 | class LoginViewModel @Inject constructor(
20 | private val savedStateHandle: SavedStateHandle,
21 | private val authRepository: AuthRepository,
22 | private val background: CoroutineDispatcher
23 | ) : ViewModel(), LoginActions {
24 |
25 | private val emailValidator = EmailValidator()
26 | private val passwordValidator = PasswordValidator()
27 |
28 | val screenState: StateFlow =
29 | savedStateHandle.getStateFlow(LOGIN_SCREEN_STATE, LoginScreenState())
30 |
31 | override fun updateEmail(newValue: String) {
32 | savedStateHandle.update(LOGIN_SCREEN_STATE) {
33 | it.copy(email = newValue, isWrongEmailFormat = false)
34 | }
35 | }
36 |
37 | override fun updatePassword(newValue: String) {
38 | savedStateHandle.update(LOGIN_SCREEN_STATE) {
39 | it.copy(password = newValue, isBadPasswordFormat = false)
40 | }
41 | }
42 |
43 | override fun login() {
44 | val email = screenState.value.email
45 | val password = screenState.value.password
46 | if (!emailValidator.validateEmail(email)) {
47 | setIncorrectEmailFormatError()
48 | } else if (!passwordValidator.validatePassword(password)) {
49 | setIncorrectPasswordFormat()
50 | } else {
51 | proceedLoggingIn(email, password)
52 | }
53 | }
54 |
55 | private fun proceedLoggingIn(email: String, password: String) {
56 | viewModelScope.launch {
57 | val loginResult = withContext(background) {
58 | authRepository.login(email, password)
59 | }
60 | onLoginResults(loginResult)
61 | }
62 | }
63 |
64 | private fun onLoginResults(loginResult: AuthResult) {
65 | if (loginResult is AuthResult.Success) {
66 | savedStateHandle.update(LOGIN_SCREEN_STATE) {
67 | it.copy(loggedInUser = loginResult.user.email)
68 | }
69 | } else {
70 | savedStateHandle.update(LOGIN_SCREEN_STATE) {
71 | it.copy(wrongCredentials = true)
72 | }
73 | }
74 | }
75 |
76 | private fun setIncorrectEmailFormatError() {
77 | savedStateHandle.update(LOGIN_SCREEN_STATE) {
78 | it.copy(isWrongEmailFormat = true)
79 | }
80 | }
81 |
82 | private fun setIncorrectPasswordFormat() {
83 | savedStateHandle.update(LOGIN_SCREEN_STATE) {
84 | it.copy(isBadPasswordFormat = true)
85 | }
86 | }
87 |
88 | companion object {
89 | const val LOGIN_SCREEN_STATE = "loginScreenStateKey"
90 | }
91 | }
--------------------------------------------------------------------------------
/shared/ui/src/main/res/drawable/logo_android_devs.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
12 |
15 |
18 |
24 |
30 |
31 |
32 |
36 |
40 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/shared/ui/src/main/java/nl/jovmit/androiddevs/shared/ui/theme/AppTheme.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.shared.ui.theme
2 |
3 | import androidx.compose.foundation.LocalIndication
4 | import androidx.compose.foundation.isSystemInDarkTheme
5 | import androidx.compose.foundation.shape.RoundedCornerShape
6 | import androidx.compose.material3.ripple
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.runtime.CompositionLocalProvider
9 | import androidx.compose.ui.graphics.Color
10 | import androidx.compose.ui.text.TextStyle
11 | import androidx.compose.ui.text.font.FontWeight
12 | import androidx.compose.ui.unit.dp
13 | import androidx.compose.ui.unit.sp
14 | import nl.jovmit.androiddevs.shared.ui.composables.ImperfectCircleShape
15 | import nl.jovmit.androiddevs.shared.ui.datetime.LocalDateTimeFormat
16 | import nl.jovmit.androiddevs.shared.ui.datetime.appZonedDateTimeFormat
17 |
18 | private val darkColorScheme = AppColorScheme(
19 | background = Color.Black,
20 | onBackground = Purple80,
21 | primary = PurpleGrey40,
22 | onPrimary = PurpleGrey80,
23 | secondary = Pink40,
24 | onSecondary = Pink80,
25 | separator = LightGray,
26 | error = DarkRed
27 | )
28 |
29 | private val lightColorScheme = AppColorScheme(
30 | background = Color.White,
31 | onBackground = Purple40,
32 | primary = PurpleGrey80,
33 | onPrimary = PurpleGrey40,
34 | secondary = Pink80,
35 | onSecondary = Pink40,
36 | separator = DarkGray,
37 | error = LightRed
38 | )
39 |
40 | private val typography = AppTypography(
41 | titleLarge = TextStyle(
42 | fontFamily = OpenSans,
43 | fontWeight = FontWeight.Bold,
44 | fontSize = 24.sp
45 | ),
46 | titleNormal = TextStyle(
47 | fontFamily = OpenSans,
48 | fontWeight = FontWeight.Bold,
49 | fontSize = 20.sp
50 | ),
51 | paragraph = TextStyle(
52 | fontFamily = OpenSans,
53 | fontSize = 16.sp
54 | ),
55 | labelLarge = TextStyle(
56 | fontFamily = OpenSans,
57 | fontWeight = FontWeight.SemiBold,
58 | fontSize = 16.sp
59 | ),
60 | labelNormal = TextStyle(
61 | fontFamily = OpenSans,
62 | fontSize = 14.sp
63 | ),
64 | labelSmall = TextStyle(
65 | fontFamily = OpenSans,
66 | fontSize = 12.sp
67 | )
68 | )
69 |
70 | private val shape = AppShape(
71 | container = RoundedCornerShape(12.dp),
72 | button = RoundedCornerShape(50),
73 | circular = ImperfectCircleShape()
74 | )
75 |
76 | private val size = AppSize(
77 | large = 24.dp,
78 | medium = 16.dp,
79 | normal = 12.dp,
80 | small = 8.dp
81 | )
82 |
83 | @Composable
84 | fun AppTheme(
85 | isDarkTheme: Boolean = isSystemInDarkTheme(),
86 | content: @Composable () -> Unit
87 | ) {
88 | val colorScheme = if (isDarkTheme) darkColorScheme else lightColorScheme
89 | CompositionLocalProvider(
90 | LocalAppColorScheme provides colorScheme,
91 | LocalAppTypography provides typography,
92 | LocalAppShape provides shape,
93 | LocalAppSize provides size,
94 | LocalDateTimeFormat provides appZonedDateTimeFormat,
95 | LocalIndication provides ripple(),
96 | content = content
97 | )
98 | }
99 |
100 | object AppTheme {
101 |
102 | val colorScheme: AppColorScheme
103 | @Composable get() = LocalAppColorScheme.current
104 |
105 | val typography: AppTypography
106 | @Composable get() = LocalAppTypography.current
107 |
108 | val shape: AppShape
109 | @Composable get() = LocalAppShape.current
110 |
111 | val size: AppSize
112 | @Composable get() = LocalAppSize.current
113 | }
--------------------------------------------------------------------------------
/shared/ui/src/main/java/nl/jovmit/androiddevs/shared/ui/composables/PasswordInput.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.shared.ui.composables
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.text.KeyboardActions
5 | import androidx.compose.foundation.text.KeyboardOptions
6 | import androidx.compose.material.icons.Icons.Default
7 | import androidx.compose.material.icons.filled.Visibility
8 | import androidx.compose.material.icons.filled.VisibilityOff
9 | import androidx.compose.material3.Icon
10 | import androidx.compose.material3.Text
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.runtime.getValue
13 | import androidx.compose.runtime.mutableStateOf
14 | import androidx.compose.runtime.remember
15 | import androidx.compose.runtime.setValue
16 | import androidx.compose.ui.Modifier
17 | import androidx.compose.ui.res.stringResource
18 | import androidx.compose.ui.text.input.PasswordVisualTransformation
19 | import androidx.compose.ui.text.input.VisualTransformation
20 | import androidx.compose.ui.tooling.preview.PreviewLightDark
21 | import nl.jovmit.androiddevs.shared.ui.R
22 | import nl.jovmit.androiddevs.shared.ui.theme.AppTheme
23 |
24 | @Composable
25 | fun PasswordInput(
26 | modifier: Modifier = Modifier,
27 | password: String,
28 | isInvalidPasswordFormat: Boolean = false,
29 | keyboardActions: KeyboardActions = KeyboardActions.Default,
30 | keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
31 | onPasswordChanged: (newValue: String) -> Unit,
32 | testTag: String = "password"
33 | ) {
34 | var isPasswordHidden by remember { mutableStateOf(true) }
35 | val visualTransformation = if (isPasswordHidden) {
36 | PasswordVisualTransformation()
37 | } else {
38 | VisualTransformation.None
39 | }
40 | TextInput(
41 | modifier = modifier,
42 | text = password,
43 | label = stringResource(id = R.string.enter_password),
44 | hint = stringResource(id = R.string.password_hint),
45 | keyboardActions = keyboardActions,
46 | keyboardOptions = keyboardOptions,
47 | onTextChanged = onPasswordChanged,
48 | visualTransformation = visualTransformation,
49 | trailingIcon = {
50 | val drawable = if (isPasswordHidden) Default.VisibilityOff else Default.Visibility
51 | Icon(
52 | modifier = Modifier.clickable(
53 | onClickLabel = if (isPasswordHidden) {
54 | stringResource(id = R.string.cd_show_password)
55 | } else {
56 | stringResource(id = R.string.cd_hide_password)
57 | },
58 | onClick = { isPasswordHidden = !isPasswordHidden }
59 | ),
60 | imageVector = drawable,
61 | contentDescription = null,
62 | tint = AppTheme.colorScheme.onBackground
63 | )
64 | },
65 | error = {
66 | if (isInvalidPasswordFormat) {
67 | Text(
68 | text = stringResource(id = R.string.error_bad_password_format),
69 | color = AppTheme.colorScheme.error
70 | )
71 | }
72 | },
73 | testTag = testTag
74 | )
75 | }
76 |
77 | @Composable
78 | @PreviewLightDark
79 | private fun PreviewPasswordInput() {
80 | AppTheme {
81 | PasswordInput(
82 | password = "something",
83 | onPasswordChanged = {}
84 | )
85 | }
86 | }
87 |
88 | @Composable
89 | @PreviewLightDark
90 | private fun PreviewPasswordInputWithHint() {
91 | AppTheme {
92 | PasswordInput(
93 | password = "",
94 | onPasswordChanged = {}
95 | )
96 | }
97 | }
98 |
99 | @Composable
100 | @PreviewLightDark
101 | private fun PreviewPasswordInputWithError() {
102 | AppTheme {
103 | PasswordInput(
104 | password = "",
105 | isInvalidPasswordFormat = true,
106 | onPasswordChanged = {}
107 | )
108 | }
109 | }
--------------------------------------------------------------------------------
/feature/timeline/src/main/java/nl/jovmit/androiddevs/feature/timeline/TimelineScreen.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.timeline
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.foundation.layout.Row
6 | import androidx.compose.foundation.layout.fillMaxSize
7 | import androidx.compose.foundation.layout.fillMaxWidth
8 | import androidx.compose.foundation.layout.padding
9 | import androidx.compose.foundation.lazy.LazyColumn
10 | import androidx.compose.foundation.lazy.items
11 | import androidx.compose.material3.CenterAlignedTopAppBar
12 | import androidx.compose.material3.ExperimentalMaterial3Api
13 | import androidx.compose.material3.Scaffold
14 | import androidx.compose.material3.Text
15 | import androidx.compose.material3.TopAppBarDefaults
16 | import androidx.compose.runtime.Composable
17 | import androidx.compose.ui.Alignment
18 | import androidx.compose.ui.Modifier
19 | import androidx.compose.ui.tooling.preview.PreviewLightDark
20 | import androidx.hilt.navigation.compose.hiltViewModel
21 | import nl.jovmit.androiddevs.shared.ui.composables.PrimaryButton
22 | import nl.jovmit.androiddevs.shared.ui.theme.AppTheme
23 |
24 | @Composable
25 | internal fun TimelineScreen(
26 | viewModel: TimelineViewModel = hiltViewModel(),
27 | onItemClicked: (itemId: String) -> Unit,
28 | ) {
29 |
30 | TimelineScreenContent(
31 | onItemClicked = onItemClicked,
32 | onForceLogOut = viewModel::doLogout
33 | )
34 | }
35 |
36 | @OptIn(ExperimentalMaterial3Api::class)
37 | @Composable
38 | private fun TimelineScreenContent(
39 | onItemClicked: (itemId: String) -> Unit,
40 | onForceLogOut: () -> Unit
41 | ) {
42 | Scaffold(
43 | modifier = Modifier.fillMaxSize(),
44 | containerColor = AppTheme.colorScheme.background,
45 | topBar = {
46 | CenterAlignedTopAppBar(
47 | title = {
48 | Text(
49 | text = "Timeline",
50 | color = AppTheme.colorScheme.onBackground,
51 | style = AppTheme.typography.titleNormal
52 | )
53 | },
54 | colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
55 | containerColor = AppTheme.colorScheme.background,
56 | )
57 | )
58 | }
59 | ) { paddingValues ->
60 | Box(
61 | modifier = Modifier
62 | .fillMaxSize()
63 | .padding(paddingValues),
64 | contentAlignment = Alignment.Center
65 | ) {
66 | val items = (1..50).map { ListItem(it.toString(), "Item $it") }
67 | LazyColumn(
68 | modifier = Modifier.fillMaxSize()
69 | ) {
70 | items(items) { item ->
71 | Row(
72 | modifier = Modifier
73 | .fillMaxWidth()
74 | .clickable {
75 | onItemClicked(item.id)
76 | }
77 | .padding(AppTheme.size.medium),
78 | verticalAlignment = Alignment.CenterVertically
79 | ) {
80 | Text(
81 | text = item.title,
82 | style = AppTheme.typography.labelLarge,
83 | color = AppTheme.colorScheme.onPrimary
84 | )
85 | }
86 | }
87 | }
88 | PrimaryButton(
89 | label = "Log Me Out",
90 | onClick = onForceLogOut
91 | )
92 | }
93 | }
94 | }
95 |
96 | private data class ListItem(
97 | val id: String,
98 | val title: String
99 | )
100 |
101 | @PreviewLightDark
102 | @Composable
103 | private fun PreviewTimelineScreen() {
104 | AppTheme {
105 | TimelineScreenContent(
106 | onItemClicked = {},
107 | onForceLogOut = {}
108 | )
109 | }
110 | }
--------------------------------------------------------------------------------
/feature/postdetails/src/main/java/nl/jovmit/androiddevs/feature/postdetails/PostDetailsScreen.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.postdetails
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.material.icons.Icons
9 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
10 | import androidx.compose.material3.CenterAlignedTopAppBar
11 | import androidx.compose.material3.CircularProgressIndicator
12 | import androidx.compose.material3.ExperimentalMaterial3Api
13 | import androidx.compose.material3.Icon
14 | import androidx.compose.material3.IconButton
15 | import androidx.compose.material3.Scaffold
16 | import androidx.compose.material3.Text
17 | import androidx.compose.material3.TopAppBarDefaults
18 | import androidx.compose.runtime.Composable
19 | import androidx.compose.runtime.LaunchedEffect
20 | import androidx.compose.runtime.getValue
21 | import androidx.compose.ui.Alignment
22 | import androidx.compose.ui.Modifier
23 | import androidx.compose.ui.tooling.preview.PreviewLightDark
24 | import androidx.hilt.navigation.compose.hiltViewModel
25 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
26 | import nl.jovmit.androiddevs.shared.ui.theme.AppTheme
27 |
28 | @Composable
29 | internal fun PostDetailsScreenContainer(
30 | viewModel: PostDetailsViewModel = hiltViewModel(),
31 | onNavigateUp: () -> Unit
32 | ) {
33 |
34 | val screenState by viewModel.screenState.collectAsStateWithLifecycle()
35 | LaunchedEffect(Unit) {
36 | viewModel.loadPostDetails()
37 | }
38 |
39 | PostDetailsScreen(
40 | screenState = screenState,
41 | onNavigateUp = onNavigateUp
42 | )
43 | }
44 |
45 | @OptIn(ExperimentalMaterial3Api::class)
46 | @Composable
47 | private fun PostDetailsScreen(
48 | modifier: Modifier = Modifier,
49 | screenState: PostDetailsScreenState,
50 | onNavigateUp: () -> Unit
51 | ) {
52 | Scaffold(
53 | modifier = modifier,
54 | topBar = {
55 | CenterAlignedTopAppBar(
56 | navigationIcon = {
57 | IconButton(onClick = onNavigateUp) {
58 | Icon(
59 | imageVector = Icons.AutoMirrored.Filled.ArrowBack,
60 | contentDescription = "Navigate Up"
61 | )
62 | }
63 | },
64 | title = {
65 | Text(
66 | text = "Post Details",
67 | color = AppTheme.colorScheme.onBackground,
68 | style = AppTheme.typography.titleNormal
69 | )
70 | },
71 | colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
72 | containerColor = AppTheme.colorScheme.background,
73 | )
74 | )
75 | }
76 | ) { paddingValues ->
77 | Box(
78 | modifier = Modifier
79 | .padding(paddingValues)
80 | .fillMaxSize()
81 | ) {
82 | Column(modifier = Modifier.fillMaxWidth()) {
83 | Text(
84 | modifier = Modifier.padding(AppTheme.size.large),
85 | text = screenState.title,
86 | style = AppTheme.typography.labelLarge,
87 | color = AppTheme.colorScheme.onBackground
88 | )
89 | }
90 | if (screenState.isLoading) {
91 | CircularProgressIndicator(
92 | modifier = Modifier.align(Alignment.Center),
93 | color = AppTheme.colorScheme.secondary
94 | )
95 | }
96 | }
97 | }
98 | }
99 |
100 | @Composable
101 | @PreviewLightDark
102 | private fun PostDetailsScreenPreview() {
103 | AppTheme {
104 | PostDetailsScreen(
105 | screenState = PostDetailsScreenState(title = "Welcome Android Devs"),
106 | onNavigateUp = {}
107 | )
108 | }
109 | }
--------------------------------------------------------------------------------
/feature/welcome/src/main/java/nl/jovmit/androiddevs/feature/welcome/WelcomeScreen.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.welcome
2 |
3 | import androidx.compose.foundation.Image
4 | import androidx.compose.foundation.layout.Arrangement
5 | import androidx.compose.foundation.layout.Box
6 | import androidx.compose.foundation.layout.Column
7 | import androidx.compose.foundation.layout.fillMaxSize
8 | import androidx.compose.foundation.layout.fillMaxWidth
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.material3.CenterAlignedTopAppBar
11 | import androidx.compose.material3.ExperimentalMaterial3Api
12 | import androidx.compose.material3.Scaffold
13 | import androidx.compose.material3.Text
14 | import androidx.compose.material3.TopAppBarDefaults
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.ui.Alignment
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.res.painterResource
19 | import androidx.compose.ui.res.stringResource
20 | import androidx.compose.ui.tooling.preview.PreviewLightDark
21 | import nl.jovmit.androiddevs.shared.ui.R
22 | import nl.jovmit.androiddevs.shared.ui.composables.PrimaryButton
23 | import nl.jovmit.androiddevs.shared.ui.composables.SecondaryButton
24 | import nl.jovmit.androiddevs.shared.ui.theme.AppTheme
25 |
26 | @OptIn(ExperimentalMaterial3Api::class)
27 | @Composable
28 | internal fun WelcomeScreen(
29 | onLogin: () -> Unit,
30 | onSignUp: () -> Unit
31 | ) {
32 | Scaffold(
33 | modifier = Modifier.fillMaxSize(),
34 | containerColor = AppTheme.colorScheme.background,
35 | topBar = {
36 | CenterAlignedTopAppBar(
37 | title = {
38 | Text(
39 | text = stringResource(id = R.string.app_name),
40 | color = AppTheme.colorScheme.onBackground,
41 | style = AppTheme.typography.titleNormal
42 | )
43 | },
44 | colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
45 | containerColor = AppTheme.colorScheme.background,
46 | )
47 | )
48 | }
49 | ) { paddingValues ->
50 | Column(
51 | modifier = Modifier
52 | .fillMaxSize()
53 | .padding(paddingValues),
54 | horizontalAlignment = Alignment.CenterHorizontally,
55 | verticalArrangement = Arrangement.SpaceEvenly
56 | ) {
57 | Box(
58 | modifier = Modifier.weight(.8f),
59 | contentAlignment = Alignment.Center
60 | ) {
61 | Text(
62 | text = stringResource(id = R.string.welcome_message),
63 | color = AppTheme.colorScheme.onBackground,
64 | style = AppTheme.typography.titleLarge
65 | )
66 | }
67 | Image(
68 | modifier = Modifier.weight(1f),
69 | painter = painterResource(id = R.drawable.logo_android_devs),
70 | contentDescription = stringResource(id = R.string.cd_logo)
71 | )
72 | Column(
73 | modifier = Modifier
74 | .weight(1f)
75 | .padding(AppTheme.size.large),
76 | verticalArrangement = Arrangement.spacedBy(
77 | space = AppTheme.size.small,
78 | alignment = Alignment.Bottom
79 | )
80 | ) {
81 | PrimaryButton(
82 | modifier = Modifier.fillMaxWidth(),
83 | label = stringResource(id = R.string.sign_up_title),
84 | onClick = onSignUp
85 | )
86 | SecondaryButton(
87 | modifier = Modifier.fillMaxWidth(),
88 | label = stringResource(id = R.string.login_title),
89 | onClick = onLogin
90 | )
91 | }
92 | }
93 | }
94 | }
95 |
96 | @Composable
97 | @PreviewLightDark
98 | private fun PreviewLoginScreen() {
99 | AppTheme {
100 | WelcomeScreen(
101 | onLogin = {},
102 | onSignUp = {}
103 | )
104 | }
105 | }
--------------------------------------------------------------------------------
/domain/auth/src/test/java/nl/jovmit/androiddevs/base/auth/HttpAuthTest.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.base.auth
2 |
3 | import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
4 | import kotlinx.serialization.encodeToString
5 | import kotlinx.serialization.json.Json
6 | import nl.jovmit.androiddevs.core.network.AuthResponse
7 | import nl.jovmit.androiddevs.core.network.AuthService
8 | import nl.jovmit.androiddevs.core.network.LoginData
9 | import nl.jovmit.androiddevs.core.network.SignUpData
10 | import nl.jovmit.androiddevs.domain.auth.AuthRepository
11 | import nl.jovmit.androiddevs.domain.auth.RemoteAuthRepository
12 | import nl.jovmit.androiddevs.domain.auth.data.User
13 | import okhttp3.MediaType.Companion.toMediaType
14 | import okhttp3.ResponseBody.Companion.toResponseBody
15 | import okhttp3.mockwebserver.Dispatcher
16 | import okhttp3.mockwebserver.MockResponse
17 | import okhttp3.mockwebserver.MockWebServer
18 | import okhttp3.mockwebserver.RecordedRequest
19 | import retrofit2.HttpException
20 | import retrofit2.Response
21 | import retrofit2.Retrofit
22 | import java.io.IOException
23 |
24 | class HttpAuthTest : AuthContractTest() {
25 |
26 | private val mockWebServer = MockWebServer()
27 | private val retrofit = Retrofit.Builder()
28 | .baseUrl(mockWebServer.url("/"))
29 | .addConverterFactory(Json.Default.asConverterFactory("application/json".toMediaType()))
30 | .build()
31 |
32 | private val authService = retrofit.create(AuthService::class.java)
33 |
34 | override fun authRepositoryWith(
35 | authToken: String,
36 | usersForPassword: Map>
37 | ): AuthRepository {
38 | mockWebServer.dispatcher = CustomDispatcher(
39 | authToken = authToken,
40 | usersForPassword = usersForPassword
41 | )
42 | return RemoteAuthRepository(authService)
43 | }
44 |
45 | override fun unavailableAuthRepository(): AuthRepository {
46 | val unavailableApi = object : AuthService {
47 | override suspend fun signUp(signUpData: SignUpData): AuthResponse {
48 | TODO("Not yet implemented")
49 | }
50 |
51 | override suspend fun login(loginData: LoginData): AuthResponse {
52 | throw HttpException(Response.error(400, "".toResponseBody()))
53 | }
54 | }
55 | return RemoteAuthRepository(unavailableApi)
56 | }
57 |
58 | override fun offlineAuthRepository(): AuthRepository {
59 | val offline = object : AuthService {
60 | override suspend fun signUp(signUpData: SignUpData): AuthResponse {
61 | TODO("Not yet implemented")
62 | }
63 |
64 | override suspend fun login(loginData: LoginData): AuthResponse {
65 | throw IOException()
66 | }
67 | }
68 | return RemoteAuthRepository(offline)
69 | }
70 |
71 | private class CustomDispatcher(
72 | private val authToken: String,
73 | private val usersForPassword: Map>
74 | ) : Dispatcher() {
75 |
76 | override fun dispatch(request: RecordedRequest): MockResponse {
77 | val requestBody = request.body.readUtf8()
78 | val loginData = Json.Default.decodeFromString(requestBody)
79 | if (usersForPassword.keys.contains(loginData.password)) {
80 | val matchingUsers = usersForPassword[loginData.password]
81 | val user = matchingUsers?.find { it.email == loginData.email }
82 | return user?.let { userResponse(authToken, it) } ?: invalidCredentials()
83 | } else {
84 | return invalidCredentials()
85 | }
86 | }
87 |
88 | private fun userResponse(authToken: String, user: User): MockResponse {
89 | val response = AuthResponse(
90 | token = authToken,
91 | userData = AuthResponse.UserData(
92 | id = user.userId,
93 | email = user.email,
94 | about = user.about
95 | )
96 | )
97 | val entity = Json.encodeToString(response)
98 | return MockResponse().setResponseCode(200)
99 | .setBody(entity)
100 | }
101 |
102 | private fun invalidCredentials() = MockResponse().setResponseCode(401)
103 | .setBody("""{"error":"Invalid Credentials"}""")
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/feature/signup/src/main/java/nl/jovmit/androiddevs/feature/signup/SignUpViewModel.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.signup
2 |
3 | import androidx.lifecycle.SavedStateHandle
4 | import androidx.lifecycle.ViewModel
5 | import androidx.lifecycle.viewModelScope
6 | import dagger.hilt.android.lifecycle.HiltViewModel
7 | import kotlinx.coroutines.CoroutineDispatcher
8 | import kotlinx.coroutines.launch
9 | import kotlinx.coroutines.withContext
10 | import nl.jovmit.androiddevs.shared.ui.extensions.update
11 | import nl.jovmit.androiddevs.shared.ui.validation.EmailValidator
12 | import nl.jovmit.androiddevs.shared.ui.validation.PasswordValidator
13 | import nl.jovmit.androiddevs.domain.auth.AuthRepository
14 | import nl.jovmit.androiddevs.domain.auth.data.AuthResult
15 | import nl.jovmit.androiddevs.feature.signup.state.SignUpScreenState
16 | import javax.inject.Inject
17 |
18 | @HiltViewModel
19 | class SignUpViewModel @Inject constructor(
20 | private val savedStateHandle: SavedStateHandle,
21 | private val authRepository: AuthRepository,
22 | private val backgroundDispatcher: CoroutineDispatcher
23 | ) : ViewModel() {
24 |
25 | private val emailValidator = EmailValidator()
26 | private val passwordValidator = PasswordValidator()
27 |
28 | val screenState = savedStateHandle.getStateFlow(SIGN_UP, SignUpScreenState())
29 |
30 | fun updateEmail(value: String) {
31 | savedStateHandle.update(SIGN_UP) {
32 | it.copy(email = value)
33 | }
34 | }
35 |
36 | fun updatePassword(value: String) {
37 | savedStateHandle.update(SIGN_UP) {
38 | it.copy(password = value)
39 | }
40 | }
41 |
42 | fun updateAbout(value: String) {
43 | savedStateHandle.update(SIGN_UP) {
44 | it.copy(about = value)
45 | }
46 | }
47 |
48 | fun signUp() {
49 | val email = screenState.value.email
50 | val password = screenState.value.password
51 | val about = screenState.value.about
52 |
53 | val isEmailValid = emailValidator.validateEmail(email)
54 | val isPasswordValid = passwordValidator.validatePassword(password)
55 |
56 | if (!isEmailValid) { setIncorrectEmailFormatError() }
57 | if (!isPasswordValid) { setIncorrectPasswordFormatError() }
58 |
59 | if (isEmailValid && isPasswordValid) {
60 | performSignUp(email, password, about)
61 | }
62 | }
63 |
64 | private fun setIncorrectEmailFormatError() {
65 | savedStateHandle.update(SIGN_UP) {
66 | it.copy(incorrectEmailFormat = true)
67 | }
68 | }
69 |
70 | private fun setIncorrectPasswordFormatError() {
71 | savedStateHandle.update(SIGN_UP) {
72 | it.copy(incorrectPasswordFormat = true)
73 | }
74 | }
75 |
76 | private fun performSignUp(email: String, password: String, about: String) {
77 | viewModelScope.launch {
78 | setLoading()
79 | val result = withContext(backgroundDispatcher) {
80 | authRepository.signUp(email, password, about)
81 | }
82 | onAuthResults(result)
83 | }
84 | }
85 |
86 | private fun setLoading() {
87 | savedStateHandle.update(SIGN_UP) {
88 | it.copy(isLoading = true)
89 | }
90 | }
91 |
92 | private fun onAuthResults(result: AuthResult) {
93 | when (result) {
94 | is AuthResult.Success -> onSignedUp()
95 | is AuthResult.BackendError -> onBackendError()
96 | is AuthResult.IncorrectCredentials -> {}
97 | is AuthResult.ExistingUserError -> onExistingUserError()
98 | is AuthResult.OfflineError -> onOfflineError()
99 | }
100 | }
101 |
102 | private fun onSignedUp() {
103 | savedStateHandle.update(SIGN_UP) {
104 | it.copy(isLoading = false, isSignedUp = true)
105 | }
106 | }
107 |
108 | private fun onBackendError() {
109 | savedStateHandle.update(SIGN_UP) {
110 | it.copy(isLoading = false, isBackendError = true)
111 | }
112 | }
113 |
114 | private fun onExistingUserError() {
115 | savedStateHandle.update(SIGN_UP) {
116 | it.copy(isLoading = false, isExistingEmail = true)
117 | }
118 | }
119 |
120 | private fun onOfflineError() {
121 | savedStateHandle.update(SIGN_UP) {
122 | it.copy(isLoading = false, isOfflineError = true)
123 | }
124 | }
125 |
126 | companion object {
127 | private const val SIGN_UP = "signUpKey"
128 | }
129 | }
--------------------------------------------------------------------------------
/feature/login/src/test/java/nl/jovmit/androiddevs/feature/login/LoginTest.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.login
2 |
3 | import androidx.lifecycle.SavedStateHandle
4 | import com.google.common.truth.Truth.assertThat
5 | import kotlinx.coroutines.Dispatchers
6 | import nl.jovmit.androiddevs.domain.auth.InMemoryAuthRepository
7 | import nl.jovmit.androiddevs.domain.auth.data.UserBuilder.Companion.aUser
8 | import nl.jovmit.androiddevs.testutils.CoroutineTestExtension
9 | import org.junit.jupiter.api.Test
10 | import org.junit.jupiter.api.extension.ExtendWith
11 |
12 | @ExtendWith(CoroutineTestExtension::class)
13 | class LoginTest {
14 |
15 | private val alycia = aUser().withEmail("alycia@app.com").build()
16 | private val alice = aUser().withEmail("alice@app.com").build()
17 | private val alicePassword = ":Passw0rd:"
18 | private val bob = aUser().withEmail("bob@app.com").build()
19 | private val bobPassword = "bobsPassword1"
20 | private val unknownEmail = "valid@email.com"
21 | private val usersForPassword = mapOf(
22 | alicePassword to listOf(alice, alycia),
23 | bobPassword to listOf(bob)
24 | )
25 | private val usersCatalog = InMemoryAuthRepository(usersForPassword = usersForPassword)
26 | private val savedStateHandle = SavedStateHandle()
27 | private val backgroundDispatcher = Dispatchers.Unconfined
28 |
29 | @Test
30 | fun userLoggedIn() {
31 | val viewModel = LoginViewModel(savedStateHandle, usersCatalog, backgroundDispatcher).apply {
32 | updateEmail(alice.email)
33 | updatePassword(alicePassword)
34 | }
35 |
36 | viewModel.login()
37 |
38 | assertThat(viewModel.screenState.value.loggedInUser).isEqualTo(alice.email)
39 | }
40 |
41 | @Test
42 | fun providedIncorrectPassword() {
43 | val viewModel = LoginViewModel(savedStateHandle, usersCatalog, backgroundDispatcher).apply {
44 | updateEmail(alice.email)
45 | updatePassword("anythingBut$alicePassword")
46 | }
47 |
48 | viewModel.login()
49 |
50 | assertThat(viewModel.screenState.value)
51 | .isEqualTo(viewModel.screenState.value.copy(wrongCredentials = true))
52 | }
53 |
54 | @Test
55 | fun providedIncorrectEmail() {
56 | val viewModel = LoginViewModel(savedStateHandle, usersCatalog, backgroundDispatcher).apply {
57 | updateEmail(unknownEmail)
58 | updatePassword(alicePassword)
59 | }
60 |
61 | viewModel.login()
62 |
63 | assertThat(viewModel.screenState.value)
64 | .isEqualTo(viewModel.screenState.value.copy(wrongCredentials = true))
65 | }
66 |
67 | @Test
68 | fun loginWithUserHavingSamePassword() {
69 | val viewModel = LoginViewModel(savedStateHandle, usersCatalog, backgroundDispatcher).apply {
70 | updateEmail(alycia.email)
71 | updatePassword(alicePassword)
72 | }
73 |
74 | viewModel.login()
75 |
76 | assertThat(viewModel.screenState.value.loggedInUser).isEqualTo(alycia.email)
77 | }
78 |
79 | @Test
80 | fun anotherLoggedInUser() {
81 | val viewModel = LoginViewModel(savedStateHandle, usersCatalog, backgroundDispatcher).apply {
82 | updateEmail(bob.email)
83 | updatePassword(bobPassword)
84 | }
85 |
86 | viewModel.login()
87 |
88 | assertThat(viewModel.screenState.value.loggedInUser).isEqualTo(bob.email)
89 | }
90 |
91 | @Test
92 | fun noUserFound() {
93 | val viewModel = LoginViewModel(savedStateHandle, usersCatalog, backgroundDispatcher).apply {
94 | updateEmail(unknownEmail)
95 | updatePassword("validPassword1")
96 | }
97 |
98 | viewModel.login()
99 |
100 | assertThat(viewModel.screenState.value)
101 | .isEqualTo(viewModel.screenState.value.copy(wrongCredentials = true))
102 | }
103 |
104 | @Test
105 | fun attemptToLoginWithIncorrectEmail() {
106 | val viewModel = LoginViewModel(savedStateHandle, usersCatalog, backgroundDispatcher).apply {
107 | updateEmail(" ")
108 | updatePassword(bobPassword)
109 | }
110 |
111 | viewModel.login()
112 |
113 | assertThat(viewModel.screenState.value)
114 | .isEqualTo(viewModel.screenState.value.copy(isWrongEmailFormat = true))
115 | }
116 |
117 | @Test
118 | fun attemptToLoginWithIncorrectPassword() {
119 | val viewModel = LoginViewModel(savedStateHandle, usersCatalog, backgroundDispatcher).apply {
120 | updateEmail(bob.email)
121 | updatePassword("wrong")
122 | }
123 |
124 | viewModel.login()
125 |
126 | assertThat(viewModel.screenState.value)
127 | .isEqualTo(viewModel.screenState.value.copy(isBadPasswordFormat = true))
128 | }
129 | }
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
--------------------------------------------------------------------------------
/feature/signup/src/test/java/nl/jovmit/androiddevs/feature/signup/SignUpTest.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.signup
2 |
3 | import androidx.lifecycle.SavedStateHandle
4 | import com.google.common.truth.Truth.assertThat
5 | import kotlinx.coroutines.Dispatchers
6 | import nl.jovmit.androiddevs.domain.auth.InMemoryAuthRepository
7 | import nl.jovmit.androiddevs.domain.auth.data.User
8 | import nl.jovmit.androiddevs.feature.signup.state.SignUpScreenState
9 | import nl.jovmit.androiddevs.testutils.CoroutineTestExtension
10 | import org.junit.jupiter.api.Test
11 | import org.junit.jupiter.api.extension.ExtendWith
12 |
13 | @ExtendWith(CoroutineTestExtension::class)
14 | class SignUpTest {
15 |
16 | // - contract test to make sure prod code is aligned with the fake
17 |
18 | private val validEmail = "email@email.com"
19 | private val validPassword = "ValidP@ssword1"
20 |
21 | private val authRepository = InMemoryAuthRepository()
22 | private val savedStateHandle = SavedStateHandle()
23 | private val coroutineDispatcher = Dispatchers.Unconfined
24 |
25 | @Test
26 | fun invalidEmail() {
27 | val email = "invalid email format"
28 | val viewModel = SignUpViewModel(savedStateHandle, authRepository, coroutineDispatcher)
29 |
30 | viewModel.signUp(email = email)
31 |
32 | assertThat(viewModel.screenState.value).isEqualTo(
33 | SignUpScreenState(
34 | email = email,
35 | incorrectEmailFormat = true,
36 | incorrectPasswordFormat = true
37 | )
38 | )
39 | }
40 |
41 | @Test
42 | fun invalidPassword() {
43 | val viewModel = SignUpViewModel(savedStateHandle, authRepository, coroutineDispatcher)
44 |
45 | viewModel.signUp(email = validEmail)
46 |
47 | assertThat(viewModel.screenState.value).isEqualTo(
48 | SignUpScreenState(email = validEmail, incorrectPasswordFormat = true)
49 | )
50 | }
51 |
52 | @Test
53 | fun invalidEmailWithValidPassword() {
54 | val viewModel = SignUpViewModel(savedStateHandle, authRepository, coroutineDispatcher)
55 |
56 | viewModel.signUp(password = validPassword)
57 |
58 | assertThat(viewModel.screenState.value).isEqualTo(
59 | SignUpScreenState(password = validPassword, incorrectEmailFormat = true)
60 | )
61 | }
62 |
63 | @Test
64 | fun signedUpSuccessfully() {
65 | val viewModel = SignUpViewModel(savedStateHandle, authRepository, coroutineDispatcher)
66 |
67 | viewModel.signUp(validEmail, validPassword)
68 |
69 | assertThat(viewModel.screenState.value).isEqualTo(
70 | SignUpScreenState(
71 | email = validEmail,
72 | password = validPassword,
73 | isSignedUp = true
74 | )
75 | )
76 | }
77 |
78 | @Test
79 | fun attemptToSignUpWithKnownEmail() {
80 | val newPassword = "another$validPassword"
81 | val repository = InMemoryAuthRepository(
82 | usersForPassword = mapOf(validPassword to listOf(User("userId", validEmail, "")))
83 | )
84 | val viewModel = SignUpViewModel(savedStateHandle, repository, coroutineDispatcher)
85 |
86 | viewModel.signUp(validEmail, newPassword)
87 |
88 | assertThat(viewModel.screenState.value).isEqualTo(
89 | SignUpScreenState(
90 | email = validEmail,
91 | password = newPassword,
92 | isExistingEmail = true
93 | )
94 | )
95 | }
96 |
97 | @Test
98 | fun errorCreatingNewAccount() {
99 | val unavailableAuthRepository = InMemoryAuthRepository().apply {
100 | setUnavailable()
101 | }
102 | val viewModel = SignUpViewModel(
103 | savedStateHandle,
104 | unavailableAuthRepository,
105 | coroutineDispatcher
106 | )
107 |
108 | viewModel.signUp(validEmail, validPassword)
109 |
110 | assertThat(viewModel.screenState.value).isEqualTo(
111 | SignUpScreenState(
112 | email = validEmail,
113 | password = validPassword,
114 | isBackendError = true
115 | )
116 | )
117 | }
118 |
119 | @Test
120 | fun attemptToSignUpWhenOffline() {
121 | val offlineAuthRepository = InMemoryAuthRepository().apply {
122 | setOffline()
123 | }
124 | val viewModel = SignUpViewModel(
125 | savedStateHandle,
126 | offlineAuthRepository,
127 | coroutineDispatcher
128 | )
129 |
130 | viewModel.signUp(validEmail, validPassword)
131 |
132 | assertThat(viewModel.screenState.value).isEqualTo(
133 | SignUpScreenState(
134 | email = validEmail,
135 | password = validPassword,
136 | isOfflineError = true
137 | )
138 | )
139 | }
140 |
141 | private fun SignUpViewModel.signUp(email: String = "", password: String = "") {
142 | updateEmail(email)
143 | updatePassword(password)
144 | signUp()
145 | }
146 | }
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | minSdkVersion = "26"
3 | compileSdkVersion = "35"
4 | javaVersion = "17"
5 | gradlePluginVersion = "8.9.2"
6 | sqlDelightVersion = "2.0.2"
7 | truthVersion = "1.1.5"
8 | kotlinVersion = "2.1.10"
9 | androidxCoreKtxVersion = "1.16.0"
10 | composeBomVersion = "2025.05.00"
11 | composeActivityVersion = "1.10.1"
12 | composeNavVersion = "2.9.0"
13 | coilVersion = "2.6.0"
14 | androidxUnitVersion = "1.2.1"
15 | androidxEspressoVersion = "3.6.1"
16 | testJunitJupiterVersion = "5.11.4"
17 | androidxLifecycleVersion = "2.9.0"
18 | hiltVersion = "2.51.1"
19 | hiltNavigationVersion = "1.2.0"
20 | paparazziVersion = "1.3.1"
21 | retrofitVersion = "2.11.0"
22 | okhttpVersion = "4.12.0"
23 |
24 | [libraries]
25 | androidx-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCoreKtxVersion" }
26 | androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidxLifecycleVersion" }
27 |
28 | compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBomVersion" }
29 | compose-activity = { module = "androidx.activity:activity-compose", version.ref = "composeActivityVersion" }
30 | compose-ui = { module = "androidx.compose.ui:ui" }
31 | compose-graphics = { module = "androidx.compose.ui:ui-graphics" }
32 | compose-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
33 | compose-material3 = { module = "androidx.compose.material3:material3" }
34 | compose-material-icons = { module = "androidx.compose.material:material-icons-extended" }
35 | compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
36 | compose-ui-test-manifest = { module = "androidx.compose.ui:ui-tooling" }
37 | compose-navigation = { module = "androidx.navigation:navigation-compose", version.ref = "composeNavVersion" }
38 | compose-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycleVersion" }
39 |
40 | coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilVersion" }
41 |
42 | sql-delight-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqlDelightVersion" }
43 |
44 | hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltVersion" }
45 | hilt-navigation = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationVersion" }
46 | hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hiltVersion" }
47 | hilt-android-test = { module = "com.google.dagger:hilt-android-testing", version.ref = "hiltVersion" }
48 | hilt-android-test-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltVersion" }
49 |
50 | network-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttpVersion" }
51 | network-retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofitVersion" }
52 | network-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.8.0" }
53 | network-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version = "1.0.0" }
54 |
55 | androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxUnitVersion" }
56 | androidx-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxEspressoVersion" }
57 | androidx-compose-test = { module = "androidx.compose.ui:ui-test-junit4" }
58 | junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "testJunitJupiterVersion" }
59 | jupiter = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "testJunitJupiterVersion" }
60 | jupiter-vintage = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "testJunitJupiterVersion" }
61 | jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "testJunitJupiterVersion" }
62 | truth = { module = "com.google.truth:truth", version.ref = "truthVersion" }
63 | test-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version = "1.10.1" }
64 | test-okhttp-mockserver = { module = "com.squareup.okhttp3:mockwebserver", version = "4.10.0" }
65 |
66 | [bundles]
67 | androidx = ["androidx-ktx", "androidx-lifecycle-runtime"]
68 | compose = ["compose-activity", "compose-ui", "compose-graphics", "compose-preview", "compose-material3", "compose-navigation", "compose-lifecycle-runtime", "compose-material-icons", "network-serialization"]
69 | compose-debug = ["compose-ui-tooling", "compose-ui-test-manifest"]
70 | hilt = ["hilt-android", "hilt-navigation"]
71 | retrofit = ["network-okhttp", "network-retrofit", "network-serialization", "network-serialization-converter"]
72 | ui-testing = ["androidx-junit", "androidx-espresso", "androidx-compose-test", "hilt-android-test"]
73 | unit-testing = ["jupiter", "jupiter-params", "truth", "jupiter-vintage", "test-coroutines", "test-okhttp-mockserver"]
74 |
75 | [plugins]
76 | android-application = { id = "com.android.application", version.ref = "gradlePluginVersion" }
77 | android-library = { id = "com.android.library", version.ref = "gradlePluginVersion" }
78 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlinVersion" }
79 | kotlin-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlinVersion" }
80 | kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlinVersion" }
81 | kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinVersion" }
82 | hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hiltVersion" }
83 | paparazzi = { id = "app.cash.paparazzi", version.ref = "paparazziVersion" }
84 | parcelable = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlinVersion" }
85 | sql-delight = { id = "app.cash.sqldelight", version.ref = "sqlDelightVersion" }
86 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/feature/signup/src/main/java/nl/jovmit/androiddevs/feature/signup/SignUpScreen.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.feature.signup
2 |
3 | import androidx.compose.foundation.Image
4 | import androidx.compose.foundation.layout.Arrangement
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.fillMaxSize
7 | import androidx.compose.foundation.layout.fillMaxWidth
8 | import androidx.compose.foundation.layout.imePadding
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.foundation.rememberScrollState
11 | import androidx.compose.foundation.text.KeyboardActions
12 | import androidx.compose.foundation.text.KeyboardOptions
13 | import androidx.compose.foundation.verticalScroll
14 | import androidx.compose.material.icons.Icons
15 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
16 | import androidx.compose.material3.CenterAlignedTopAppBar
17 | import androidx.compose.material3.ExperimentalMaterial3Api
18 | import androidx.compose.material3.Icon
19 | import androidx.compose.material3.IconButton
20 | import androidx.compose.material3.Scaffold
21 | import androidx.compose.material3.Text
22 | import androidx.compose.material3.TopAppBarDefaults
23 | import androidx.compose.runtime.Composable
24 | import androidx.compose.runtime.getValue
25 | import androidx.compose.ui.Alignment
26 | import androidx.compose.ui.Modifier
27 | import androidx.compose.ui.focus.FocusRequester
28 | import androidx.compose.ui.focus.focusRequester
29 | import androidx.compose.ui.res.painterResource
30 | import androidx.compose.ui.res.stringResource
31 | import androidx.compose.ui.text.input.ImeAction
32 | import androidx.compose.ui.text.input.KeyboardType
33 | import androidx.compose.ui.tooling.preview.PreviewLightDark
34 | import androidx.hilt.navigation.compose.hiltViewModel
35 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
36 | import nl.jovmit.androiddevs.shared.ui.R
37 | import nl.jovmit.androiddevs.shared.ui.composables.EmailInput
38 | import nl.jovmit.androiddevs.shared.ui.composables.PasswordInput
39 | import nl.jovmit.androiddevs.shared.ui.composables.PrimaryButton
40 | import nl.jovmit.androiddevs.shared.ui.composables.TextInput
41 | import nl.jovmit.androiddevs.shared.ui.theme.AppTheme
42 | import nl.jovmit.androiddevs.feature.signup.state.SignUpScreenState
43 |
44 | @Composable
45 | internal fun SignUpScreen(
46 | viewModel: SignUpViewModel = hiltViewModel(),
47 | onNavigateUp: () -> Unit
48 | ) {
49 |
50 | val screenState by viewModel.screenState.collectAsStateWithLifecycle()
51 |
52 | SignUpScreenContent(
53 | signUpScreenState = screenState,
54 | onEmailChanged = viewModel::updateEmail,
55 | onPasswordChanged = viewModel::updatePassword,
56 | onAboutChanged = viewModel::updateAbout,
57 | onSignUp = {},
58 | onNavigateUp = onNavigateUp
59 | )
60 | }
61 |
62 | @OptIn(ExperimentalMaterial3Api::class)
63 | @Composable
64 | private fun SignUpScreenContent(
65 | signUpScreenState: SignUpScreenState,
66 | onEmailChanged: (newValue: String) -> Unit,
67 | onPasswordChanged: (newValue: String) -> Unit,
68 | onAboutChanged: (newValue: String) -> Unit,
69 | onSignUp: () -> Unit,
70 | onNavigateUp: () -> Unit,
71 | ) {
72 | Scaffold(
73 | modifier = Modifier.fillMaxSize(),
74 | containerColor = AppTheme.colorScheme.background,
75 | topBar = {
76 | CenterAlignedTopAppBar(
77 | navigationIcon = {
78 | IconButton(onClick = onNavigateUp) {
79 | Icon(
80 | imageVector = Icons.AutoMirrored.Filled.ArrowBack,
81 | contentDescription = null,
82 | tint = AppTheme.colorScheme.onBackground
83 | )
84 | }
85 | },
86 | title = {
87 | Text(
88 | text = stringResource(R.string.sign_up_title),
89 | color = AppTheme.colorScheme.onBackground,
90 | style = AppTheme.typography.titleNormal
91 | )
92 | },
93 | colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
94 | containerColor = AppTheme.colorScheme.background,
95 | )
96 | )
97 | }
98 | ) { paddingValues ->
99 | val verticalScroll = rememberScrollState()
100 | Column(
101 | modifier = Modifier
102 | .fillMaxSize()
103 | .verticalScroll(verticalScroll)
104 | .padding(paddingValues),
105 | verticalArrangement = Arrangement.spacedBy(AppTheme.size.large)
106 | ) {
107 | Image(
108 | modifier = Modifier
109 | .padding(AppTheme.size.large)
110 | .align(Alignment.CenterHorizontally),
111 | painter = painterResource(id = R.drawable.logo_android_devs),
112 | contentDescription = stringResource(id = R.string.cd_logo)
113 | )
114 | Column(
115 | modifier = Modifier
116 | .fillMaxWidth()
117 | .imePadding()
118 | .padding(horizontal = AppTheme.size.medium),
119 | horizontalAlignment = Alignment.CenterHorizontally,
120 | verticalArrangement = Arrangement.spacedBy(AppTheme.size.normal)
121 | ) {
122 | val passwordFocus = FocusRequester()
123 | val aboutFocus = FocusRequester()
124 | EmailInput(
125 | modifier = Modifier.fillMaxWidth(),
126 | email = signUpScreenState.email,
127 | keyboardOptions = KeyboardOptions(
128 | keyboardType = KeyboardType.Email,
129 | imeAction = ImeAction.Next
130 | ),
131 | keyboardActions = KeyboardActions(
132 | onNext = { passwordFocus.requestFocus() }
133 | ),
134 | onEmailChanged = onEmailChanged,
135 | )
136 | PasswordInput(
137 | modifier = Modifier
138 | .focusRequester(passwordFocus)
139 | .fillMaxWidth(),
140 | keyboardOptions = KeyboardOptions(
141 | imeAction = ImeAction.Next
142 | ),
143 | keyboardActions = KeyboardActions(
144 | onNext = { aboutFocus.requestFocus() }
145 | ),
146 | password = signUpScreenState.password,
147 | onPasswordChanged = onPasswordChanged
148 | )
149 | TextInput(
150 | modifier = Modifier
151 | .focusRequester(aboutFocus)
152 | .fillMaxWidth(),
153 | text = signUpScreenState.about,
154 | onTextChanged = onAboutChanged,
155 | keyboardOptions = KeyboardOptions(
156 | imeAction = ImeAction.Done
157 | ),
158 | keyboardActions = KeyboardActions(
159 | onDone = { onSignUp() }
160 | ),
161 | label = stringResource(R.string.label_about),
162 | hint = stringResource(R.string.bio_hint)
163 | )
164 | PrimaryButton(
165 | modifier = Modifier.fillMaxWidth(),
166 | label = stringResource(R.string.sign_up_title),
167 | onClick = onSignUp
168 | )
169 | }
170 | }
171 | }
172 | }
173 |
174 | @Composable
175 | @PreviewLightDark
176 | private fun PreviewLoginScreen() {
177 | AppTheme {
178 | SignUpScreenContent(
179 | signUpScreenState = SignUpScreenState(incorrectEmailFormat = true),
180 | onEmailChanged = {},
181 | onPasswordChanged = {},
182 | onAboutChanged = {},
183 | onSignUp = {},
184 | onNavigateUp = {}
185 | )
186 | }
187 | }
--------------------------------------------------------------------------------
/shared/ui/src/main/java/nl/jovmit/androiddevs/shared/ui/composables/TextInput.kt:
--------------------------------------------------------------------------------
1 | package nl.jovmit.androiddevs.shared.ui.composables
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.border
5 | import androidx.compose.foundation.layout.Arrangement
6 | import androidx.compose.foundation.layout.Box
7 | import androidx.compose.foundation.layout.Column
8 | import androidx.compose.foundation.layout.Row
9 | import androidx.compose.foundation.layout.fillMaxWidth
10 | import androidx.compose.foundation.layout.heightIn
11 | import androidx.compose.foundation.layout.padding
12 | import androidx.compose.foundation.text.BasicTextField
13 | import androidx.compose.foundation.text.KeyboardActions
14 | import androidx.compose.foundation.text.KeyboardOptions
15 | import androidx.compose.material.icons.Icons
16 | import androidx.compose.material.icons.filled.VisibilityOff
17 | import androidx.compose.material3.Icon
18 | import androidx.compose.material3.Text
19 | import androidx.compose.runtime.Composable
20 | import androidx.compose.runtime.getValue
21 | import androidx.compose.runtime.mutableStateOf
22 | import androidx.compose.runtime.remember
23 | import androidx.compose.runtime.setValue
24 | import androidx.compose.ui.Alignment
25 | import androidx.compose.ui.Modifier
26 | import androidx.compose.ui.draw.clip
27 | import androidx.compose.ui.focus.onFocusChanged
28 | import androidx.compose.ui.graphics.Color
29 | import androidx.compose.ui.graphics.SolidColor
30 | import androidx.compose.ui.platform.testTag
31 | import androidx.compose.ui.text.font.FontWeight
32 | import androidx.compose.ui.text.input.PasswordVisualTransformation
33 | import androidx.compose.ui.text.input.VisualTransformation
34 | import androidx.compose.ui.tooling.preview.PreviewLightDark
35 | import androidx.compose.ui.unit.dp
36 | import nl.jovmit.androiddevs.shared.ui.theme.AppTheme
37 |
38 | @Composable
39 | fun TextInput(
40 | modifier: Modifier = Modifier,
41 | text: String,
42 | label: String,
43 | hint: String = "",
44 | singleLine: Boolean = true,
45 | borderColor: Color = AppTheme.colorScheme.separator,
46 | selectedBorderColor: Color = AppTheme.colorScheme.primary,
47 | visualTransformation: VisualTransformation = VisualTransformation.None,
48 | keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
49 | keyboardActions: KeyboardActions = KeyboardActions.Default,
50 | trailingIcon: (@Composable () -> Unit)? = null,
51 | error: (@Composable () -> Unit)? = null,
52 | onTextChanged: (newValue: String) -> Unit,
53 | testTag: String = ""
54 | ) {
55 | var actualBorderColor by remember { mutableStateOf(borderColor) }
56 | Column(
57 | modifier = modifier,
58 | verticalArrangement = Arrangement.spacedBy(AppTheme.size.small)
59 | ) {
60 | Text(
61 | text = label,
62 | color = AppTheme.colorScheme.onBackground,
63 | style = AppTheme.typography.labelNormal.copy(
64 | fontWeight = FontWeight.SemiBold
65 | )
66 | )
67 | Column(
68 | modifier = Modifier
69 | .fillMaxWidth()
70 | .clip(AppTheme.shape.container)
71 | .border(
72 | width = 1.dp,
73 | color = actualBorderColor,
74 | shape = AppTheme.shape.container
75 | )
76 | ) {
77 | Row(
78 | modifier = Modifier.fillMaxWidth(),
79 | verticalAlignment = Alignment.CenterVertically
80 | ) {
81 | Box(
82 | modifier = Modifier
83 | .weight(1f)
84 | .heightIn(48.dp),
85 | contentAlignment = Alignment.Center
86 | ) {
87 | BasicTextField(
88 | modifier = Modifier
89 | .fillMaxWidth()
90 | .testTag(testTag)
91 | .onFocusChanged { state ->
92 | val actualColor = if (state.isFocused) selectedBorderColor else borderColor
93 | actualBorderColor = actualColor
94 | }
95 | .padding(horizontal = AppTheme.size.small),
96 | value = text,
97 | singleLine = singleLine,
98 | onValueChange = onTextChanged,
99 | textStyle = AppTheme.typography.paragraph.copy(
100 | color = AppTheme.colorScheme.onBackground
101 | ),
102 | cursorBrush = SolidColor(AppTheme.colorScheme.onBackground),
103 | visualTransformation = visualTransformation,
104 | keyboardActions = keyboardActions,
105 | keyboardOptions = keyboardOptions
106 | )
107 | if (text.isBlank()) {
108 | Text(
109 | modifier = Modifier
110 | .fillMaxWidth()
111 | .padding(horizontal = AppTheme.size.small),
112 | text = hint,
113 | style = AppTheme.typography.paragraph,
114 | color = AppTheme.colorScheme.onBackground.copy(alpha = .5f)
115 | )
116 | }
117 | }
118 | trailingIcon?.let { icon ->
119 | Box(
120 | modifier = Modifier
121 | .padding(AppTheme.size.small)
122 | ) {
123 | icon()
124 | }
125 | }
126 | }
127 | }
128 | if (error != null) {
129 | error()
130 | }
131 | }
132 | }
133 |
134 | @PreviewLightDark
135 | @Composable
136 | private fun TextInputPreview() {
137 | AppTheme {
138 | Box(
139 | modifier = Modifier
140 | .background(AppTheme.colorScheme.background)
141 | .padding(AppTheme.size.normal)
142 | ) {
143 | var text by remember { mutableStateOf("some value") }
144 | TextInput(
145 | modifier = Modifier.fillMaxWidth(),
146 | text = text,
147 | label = "Enter Name",
148 | hint = "email@email.com",
149 | onTextChanged = { text = it }
150 | )
151 | }
152 | }
153 | }
154 |
155 | @PreviewLightDark
156 | @Composable
157 | private fun TextInputPreviewWithHint() {
158 | AppTheme {
159 | Box(
160 | modifier = Modifier
161 | .background(AppTheme.colorScheme.background)
162 | .padding(AppTheme.size.normal)
163 | ) {
164 | TextInput(
165 | modifier = Modifier.fillMaxWidth(),
166 | text = "",
167 | label = "Enter Name",
168 | hint = "email@company.com",
169 | onTextChanged = {}
170 | )
171 | }
172 | }
173 | }
174 |
175 |
176 | @PreviewLightDark
177 | @Composable
178 | private fun TextInputPreviewWithVisualTransformation() {
179 | AppTheme {
180 | Box(
181 | modifier = Modifier
182 | .background(AppTheme.colorScheme.background)
183 | .padding(AppTheme.size.normal)
184 | ) {
185 | TextInput(
186 | modifier = Modifier.fillMaxWidth(),
187 | text = "something",
188 | label = "Enter Pass",
189 | onTextChanged = {},
190 | visualTransformation = PasswordVisualTransformation()
191 | )
192 | }
193 | }
194 | }
195 |
196 | @PreviewLightDark
197 | @Composable
198 | private fun TextInputPreviewWithTrailingIcon() {
199 | AppTheme {
200 | Box(
201 | modifier = Modifier
202 | .background(AppTheme.colorScheme.background)
203 | .padding(AppTheme.size.normal)
204 | ) {
205 | TextInput(
206 | modifier = Modifier.fillMaxWidth(),
207 | text = "something",
208 | label = "Enter Pass",
209 | onTextChanged = {},
210 | visualTransformation = PasswordVisualTransformation(),
211 | trailingIcon = {
212 | Icon(
213 | imageVector = Icons.Default.VisibilityOff,
214 | contentDescription = null,
215 | tint = AppTheme.colorScheme.onBackground
216 | )
217 | }
218 | )
219 | }
220 | }
221 | }
222 |
223 | @PreviewLightDark
224 | @Composable
225 | private fun TextInputPreviewWithError() {
226 | AppTheme {
227 | Box(
228 | modifier = Modifier
229 | .background(AppTheme.colorScheme.background)
230 | .padding(AppTheme.size.normal)
231 | ) {
232 | TextInput(
233 | modifier = Modifier.fillMaxWidth(),
234 | text = "something",
235 | label = "Enter Pass",
236 | onTextChanged = {},
237 | visualTransformation = PasswordVisualTransformation(),
238 | error = {
239 | Text(
240 | text = "Error of some kind",
241 | color = AppTheme.colorScheme.onBackground
242 | )
243 | }
244 | )
245 | }
246 | }
247 | }
248 |
--------------------------------------------------------------------------------