├── app ├── .gitignore ├── src │ ├── main │ │ ├── res │ │ │ ├── values │ │ │ │ ├── strings.xml │ │ │ │ ├── colors.xml │ │ │ │ └── themes.xml │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── xml │ │ │ │ ├── backup_rules.xml │ │ │ │ └── data_extraction_rules.xml │ │ │ ├── navigation │ │ │ │ └── app_main_navigation.xml │ │ │ ├── values-night │ │ │ │ └── themes.xml │ │ │ ├── layout │ │ │ │ └── activity_main.xml │ │ │ └── drawable │ │ │ │ ├── ic_launcher_foreground.xml │ │ │ │ └── ic_launcher_background.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── moove │ │ │ │ └── app │ │ │ │ ├── feature │ │ │ │ ├── home │ │ │ │ │ ├── HomeState.kt │ │ │ │ │ ├── HomeNavigator.kt │ │ │ │ │ ├── HomeViewModel.kt │ │ │ │ │ ├── HomeFragment.kt │ │ │ │ │ ├── HomeRoute.kt │ │ │ │ │ └── HomeScreen.kt │ │ │ │ └── deeplink │ │ │ │ │ ├── data │ │ │ │ │ ├── DynamicLinkDataRepository.kt │ │ │ │ │ ├── DeeplinkDataRepository.kt │ │ │ │ │ ├── remote │ │ │ │ │ │ └── FirebaseDynamicLinkDataSource.kt │ │ │ │ │ └── local │ │ │ │ │ │ └── AppDeepLinkLocalDataSource.kt │ │ │ │ │ ├── domain │ │ │ │ │ └── AppDeepLink.kt │ │ │ │ │ └── presentation │ │ │ │ │ └── DeepLinkAppNavigator.kt │ │ │ │ ├── di │ │ │ │ ├── ExceptionsModule.kt │ │ │ │ ├── CoroutineModule.kt │ │ │ │ ├── NetModule.kt │ │ │ │ ├── MainModule.kt │ │ │ │ └── DeepLinkModule.kt │ │ │ │ ├── main │ │ │ │ ├── MainNavigator.kt │ │ │ │ ├── MainActivityState.kt │ │ │ │ ├── MainActivityViewModel.kt │ │ │ │ └── MainActivity.kt │ │ │ │ ├── shared │ │ │ │ └── exception_handler │ │ │ │ │ └── main │ │ │ │ │ └── MainExceptionHandler.kt │ │ │ │ ├── MooveApp.kt │ │ │ │ └── navigation │ │ │ │ └── AppNavigator.kt │ │ └── AndroidManifest.xml │ ├── test │ │ └── java │ │ │ └── com │ │ │ └── moove │ │ │ └── ExampleUnitTest.kt │ └── androidTest │ │ └── java │ │ └── com │ │ └── moove │ │ └── ExampleInstrumentedTest.kt ├── proguard-rules.pro └── build.gradle ├── core ├── .gitignore ├── consumer-rules.pro ├── src │ └── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ └── com │ │ └── moove │ │ └── core │ │ ├── okhttp │ │ ├── NoConnectionException.kt │ │ └── ConnectionErrorInterceptor.kt │ │ ├── exception │ │ ├── ExceptionTransformer.kt │ │ └── ExceptionHandler.kt │ │ ├── kotlin │ │ ├── text │ │ │ └── StringExtensions.kt │ │ ├── ResultExtensions.kt │ │ └── coroutines │ │ │ ├── CoroutineScopeExtensions.kt │ │ │ └── AppCoroutineScope.kt │ │ └── compose │ │ └── AnnotatedString.kt ├── build.gradle └── proguard-rules.pro ├── shared ├── .gitignore ├── consumer-rules.pro ├── src │ └── main │ │ ├── java │ │ └── com │ │ │ └── moove │ │ │ └── shared │ │ │ ├── feature │ │ │ └── deeplink │ │ │ │ ├── domain │ │ │ │ ├── DeepLink.kt │ │ │ │ ├── DeeplinkRepository.kt │ │ │ │ ├── DynamicLinkRepository.kt │ │ │ │ ├── exceptions │ │ │ │ │ └── DynamicLinkParseException.kt │ │ │ │ ├── GetDynamicLinkUseCase.kt │ │ │ │ └── GetDeeplinkUseCase.kt │ │ │ │ └── presentation │ │ │ │ └── DeepLinkNavigator.kt │ │ │ ├── navigation │ │ │ ├── ScreenNavigator.kt │ │ │ ├── GlobalAppNavigator.kt │ │ │ ├── TicketsNavigator.kt │ │ │ └── NavControllerExtension.kt │ │ │ ├── presentation │ │ │ ├── viewmodel │ │ │ │ ├── ViewModelExtensions.kt │ │ │ │ └── OrbitExtensions.kt │ │ │ ├── fragment │ │ │ │ └── delegate │ │ │ │ │ ├── FragmentViewLifecycleAwareDelegateExtensions.kt │ │ │ │ │ ├── FragmentViewLifecycleAwareDelegate.kt │ │ │ │ │ └── FragmentLifecycleAwareExtensions.kt │ │ │ └── compose │ │ │ │ ├── platform │ │ │ │ ├── ComposeViewExtensions.kt │ │ │ │ └── FragmentExtensions.kt │ │ │ │ └── component │ │ │ │ ├── ScreenContent.kt │ │ │ │ ├── BlockingBox.kt │ │ │ │ └── GenericError.kt │ │ │ └── ModelMother.kt │ │ ├── res │ │ ├── values │ │ │ ├── navigation_ids.xml │ │ │ └── strings.xml │ │ ├── anim │ │ │ ├── no_animation.xml │ │ │ ├── slide_in_up.xml │ │ │ └── slide_out_bottom.xml │ │ └── layout │ │ │ └── compose_fragment.xml │ │ └── AndroidManifest.xml ├── proguard-rules.pro └── build.gradle ├── tickets ├── .gitignore ├── consumer-rules.pro ├── src │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── moove │ │ │ │ └── tickets │ │ │ │ ├── domain │ │ │ │ ├── model │ │ │ │ │ ├── Fare.kt │ │ │ │ │ └── Ryder.kt │ │ │ │ ├── use_cases │ │ │ │ │ ├── GetRydersUseCase.kt │ │ │ │ │ ├── GetFaresByIdUseCase.kt │ │ │ │ │ └── BuyTicketUseCase.kt │ │ │ │ └── TicketsRepository.kt │ │ │ │ ├── presentation │ │ │ │ ├── confirmation │ │ │ │ │ ├── ConfirmationNavigator.kt │ │ │ │ │ ├── ConfirmationState.kt │ │ │ │ │ ├── ConfirmationFragment.kt │ │ │ │ │ ├── ConfirmationRoute.kt │ │ │ │ │ ├── component │ │ │ │ │ │ └── ConfirmationItem.kt │ │ │ │ │ ├── ConfirmationViewModel.kt │ │ │ │ │ └── ConfirmationScreen.kt │ │ │ │ ├── fare │ │ │ │ │ ├── model │ │ │ │ │ │ ├── FareModelFake.kt │ │ │ │ │ │ └── FareModel.kt │ │ │ │ │ ├── FareListState.kt │ │ │ │ │ ├── FareListNavigator.kt │ │ │ │ │ ├── component │ │ │ │ │ │ ├── FareList.kt │ │ │ │ │ │ └── FareItem.kt │ │ │ │ │ ├── FareListFragment.kt │ │ │ │ │ ├── FareListRoute.kt │ │ │ │ │ ├── FareListViewModel.kt │ │ │ │ │ └── FareListScreen.kt │ │ │ │ └── list │ │ │ │ │ ├── RyderListNavigator.kt │ │ │ │ │ ├── RyderListState.kt │ │ │ │ │ ├── model │ │ │ │ │ ├── RyderModelFake.kt │ │ │ │ │ └── RyderModel.kt │ │ │ │ │ ├── component │ │ │ │ │ ├── RyderList.kt │ │ │ │ │ └── RyderItem.kt │ │ │ │ │ ├── RyderListFragment.kt │ │ │ │ │ ├── RyderListRoute.kt │ │ │ │ │ ├── RyderListViewModel.kt │ │ │ │ │ └── RyderListScreen.kt │ │ │ │ ├── data │ │ │ │ ├── local │ │ │ │ │ ├── dto │ │ │ │ │ │ ├── TicketsDataDTO.kt │ │ │ │ │ │ ├── FareDTO.kt │ │ │ │ │ │ └── RyderDTO.kt │ │ │ │ │ └── TicketsLocalDataSource.kt │ │ │ │ └── TicketsDataRepository.kt │ │ │ │ └── di │ │ │ │ └── TicketsModule.kt │ │ └── res │ │ │ └── navigation │ │ │ └── tickets_navigation.xml │ └── test │ │ └── java │ │ └── com │ │ └── moove │ │ └── tickets │ │ ├── data │ │ ├── dto │ │ │ └── RyderDTOMother.kt │ │ └── TicketsDataRepositoryTest.kt │ │ ├── domain │ │ ├── model │ │ │ └── RyderMother.kt │ │ └── use_cases │ │ │ ├── BuyTicketUseCaseTest.kt │ │ │ ├── GetRydersUseCaseTest.kt │ │ │ └── GetFaresByIdUseCaseTest.kt │ │ └── presentation │ │ ├── list │ │ └── RyderListViewModelTest.kt │ │ ├── fare │ │ └── FareListViewModelTest.kt │ │ └── confirmation │ │ └── ConfirmationViewModelTest.kt ├── build.gradle └── proguard-rules.pro ├── design-system ├── .gitignore ├── consumer-rules.pro ├── src │ └── main │ │ ├── res │ │ └── font │ │ │ ├── quicksand_bold.ttf │ │ │ ├── source_sans_pro.ttf │ │ │ └── source_sans_pro_semibold.ttf │ │ ├── AndroidManifest.xml │ │ └── java │ │ └── com │ │ └── moove │ │ └── design_system │ │ └── compose │ │ ├── TopAppBar.kt │ │ ├── moove │ │ ├── NeutralColors.kt │ │ ├── Shapes.kt │ │ ├── Button.kt │ │ ├── Theme.kt │ │ ├── Colors.kt │ │ └── Typography.kt │ │ ├── Shapes.kt │ │ ├── Dimensions.kt │ │ ├── NeutralColors.kt │ │ ├── Typography.kt │ │ ├── Colors.kt │ │ ├── Theme.kt │ │ ├── Scaffold.kt │ │ └── ProgressIndicator.kt ├── build.gradle └── proguard-rules.pro ├── android-docs ├── Dockerfile ├── mkdocs.yml └── docs │ └── index.md ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── compose-android-config.gradle └── base-android-config.gradle ├── .gitignore ├── .github ├── actions │ └── job-set-up │ │ └── action.yml └── workflows │ ├── generate_docs.sh │ ├── code_review.yml │ └── docs.yml ├── settings.gradle ├── gradle.properties ├── README.md └── gradlew.bat /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /core/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /core/consumer-rules.pro: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /shared/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /shared/consumer-rules.pro: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tickets/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /tickets/consumer-rules.pro: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /design-system/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /design-system/consumer-rules.pro: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /android-docs/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:latest 2 | COPY android-docs/html /usr/share/nginx/html 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Moove 3 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MkhytarMkhoian/Moove/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MkhytarMkhoian/Moove/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MkhytarMkhoian/Moove/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MkhytarMkhoian/Moove/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MkhytarMkhoian/Moove/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MkhytarMkhoian/Moove/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /shared/src/main/java/com/moove/shared/feature/deeplink/domain/DeepLink.kt: -------------------------------------------------------------------------------- 1 | package com.moove.shared.feature.deeplink.domain 2 | 3 | interface DeepLink -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MkhytarMkhoian/Moove/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/MkhytarMkhoian/Moove/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /design-system/src/main/res/font/quicksand_bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MkhytarMkhoian/Moove/HEAD/design-system/src/main/res/font/quicksand_bold.ttf -------------------------------------------------------------------------------- /design-system/src/main/res/font/source_sans_pro.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MkhytarMkhoian/Moove/HEAD/design-system/src/main/res/font/source_sans_pro.ttf -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MkhytarMkhoian/Moove/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/MkhytarMkhoian/Moove/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/MkhytarMkhoian/Moove/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /shared/src/main/res/values/navigation_ids.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /shared/src/main/java/com/moove/shared/navigation/ScreenNavigator.kt: -------------------------------------------------------------------------------- 1 | package com.moove.shared.navigation 2 | 3 | interface ScreenNavigator { 4 | fun goBack() 5 | } 6 | -------------------------------------------------------------------------------- /core/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /design-system/src/main/res/font/source_sans_pro_semibold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MkhytarMkhoian/Moove/HEAD/design-system/src/main/res/font/source_sans_pro_semibold.ttf -------------------------------------------------------------------------------- /shared/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /tickets/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /design-system/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/domain/model/Fare.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.domain.model 2 | 3 | data class Fare( 4 | val description: String, 5 | val price: Float, 6 | ) 7 | -------------------------------------------------------------------------------- /shared/src/main/java/com/moove/shared/navigation/GlobalAppNavigator.kt: -------------------------------------------------------------------------------- 1 | package com.moove.shared.navigation 2 | 3 | interface GlobalAppNavigator : ScreenNavigator, TicketsNavigator { 4 | 5 | fun goHome() 6 | } 7 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/domain/model/Ryder.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.domain.model 2 | 3 | data class Ryder( 4 | val id: String, 5 | val fares: List, 6 | val subtext: String?, 7 | ) -------------------------------------------------------------------------------- /gradle/compose-android-config.gradle: -------------------------------------------------------------------------------- 1 | android { 2 | buildFeatures { 3 | compose true 4 | } 5 | composeOptions { 6 | kotlinCompilerExtensionVersion libs.versions.composeCompiler.get() 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /shared/src/main/java/com/moove/shared/feature/deeplink/domain/DeeplinkRepository.kt: -------------------------------------------------------------------------------- 1 | package com.moove.shared.feature.deeplink.domain 2 | 3 | interface DeeplinkRepository { 4 | 5 | suspend fun getDeepLink(uri: String): DeepLink 6 | } 7 | -------------------------------------------------------------------------------- /shared/src/main/java/com/moove/shared/feature/deeplink/domain/DynamicLinkRepository.kt: -------------------------------------------------------------------------------- 1 | package com.moove.shared.feature.deeplink.domain 2 | 3 | interface DynamicLinkRepository { 4 | 5 | suspend fun parseLink(uri: String): String? 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | .DS_Store 5 | /build 6 | /captures 7 | .externalNativeBuild 8 | .cxx 9 | local.properties 10 | /android-docs/venv/ 11 | /.idea/ 12 | /android-docs/docs/api/ 13 | /android-docs/html/ 14 | -------------------------------------------------------------------------------- /core/src/main/java/com/moove/core/okhttp/NoConnectionException.kt: -------------------------------------------------------------------------------- 1 | package com.moove.core.okhttp 2 | 3 | import java.io.IOException 4 | 5 | class NoConnectionException( 6 | message: String? = null, 7 | cause: Throwable? = null 8 | ) : IOException(message, cause) 9 | -------------------------------------------------------------------------------- /shared/src/main/java/com/moove/shared/navigation/TicketsNavigator.kt: -------------------------------------------------------------------------------- 1 | package com.moove.shared.navigation 2 | 3 | interface TicketsNavigator { 4 | 5 | fun goFares(ryderId: String) 6 | fun goToConfirmation(ryderId: String, fareDescription: String, farePrice: Float) 7 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sun Feb 18 14:32:38 EET 2024 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /shared/src/main/java/com/moove/shared/feature/deeplink/presentation/DeepLinkNavigator.kt: -------------------------------------------------------------------------------- 1 | package com.moove.shared.feature.deeplink.presentation 2 | 3 | import com.moove.shared.feature.deeplink.domain.DeepLink 4 | 5 | interface DeepLinkNavigator { 6 | fun navigateTo(link: DeepLink) 7 | } 8 | -------------------------------------------------------------------------------- /shared/src/main/java/com/moove/shared/feature/deeplink/domain/exceptions/DynamicLinkParseException.kt: -------------------------------------------------------------------------------- 1 | package com.moove.shared.feature.deeplink.domain.exceptions 2 | 3 | class DynamicLinkParseException( 4 | message: String? = null, 5 | cause: Throwable? = null, 6 | ) : Exception(message, cause) 7 | -------------------------------------------------------------------------------- /core/src/main/java/com/moove/core/exception/ExceptionTransformer.kt: -------------------------------------------------------------------------------- 1 | package com.moove.core.exception 2 | 3 | fun interface ExceptionTransformer { 4 | fun transform(input: Throwable): Throwable 5 | } 6 | 7 | fun ExceptionTransformer.decorate(block: (ExceptionTransformer) -> ExceptionTransformer) = block(this) 8 | -------------------------------------------------------------------------------- /shared/src/main/res/anim/no_animation.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/java/com/moove/app/feature/home/HomeState.kt: -------------------------------------------------------------------------------- 1 | package com.moove.app.feature.home 2 | 3 | import android.os.Parcelable 4 | import kotlinx.parcelize.Parcelize 5 | 6 | @Parcelize 7 | class HomeState : Parcelable 8 | 9 | sealed class HomeEffect { 10 | data object GoToRyderList : HomeEffect() 11 | } 12 | -------------------------------------------------------------------------------- /shared/src/main/res/anim/slide_in_up.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | -------------------------------------------------------------------------------- /core/src/main/java/com/moove/core/kotlin/text/StringExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.moove.core.kotlin.text 2 | 3 | import java.util.regex.Pattern 4 | 5 | fun String.matchesPattern(pattern: String): Boolean { 6 | return Pattern 7 | .compile("($pattern)") 8 | .matcher(this) 9 | .find() 10 | } 11 | -------------------------------------------------------------------------------- /shared/src/main/res/anim/slide_out_bottom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | -------------------------------------------------------------------------------- /shared/src/main/res/layout/compose_fragment.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | -------------------------------------------------------------------------------- /core/src/main/java/com/moove/core/exception/ExceptionHandler.kt: -------------------------------------------------------------------------------- 1 | package com.moove.core.exception 2 | 3 | import kotlinx.coroutines.CoroutineExceptionHandler 4 | 5 | fun interface ExceptionHandler { 6 | fun handle(error: Throwable) 7 | } 8 | 9 | fun ExceptionHandler.asCoroutineExceptionHandler() = 10 | CoroutineExceptionHandler { _, throwable -> this.handle(throwable) } 11 | -------------------------------------------------------------------------------- /design-system/src/main/java/com/moove/design_system/compose/TopAppBar.kt: -------------------------------------------------------------------------------- 1 | package com.moove.design_system.compose 2 | 3 | import androidx.compose.runtime.Immutable 4 | import androidx.compose.ui.text.TextStyle 5 | 6 | @Immutable 7 | data class TopAppBarTypography( 8 | val title: TextStyle = TextStyle.Default, 9 | val titleExpanded: TextStyle = TextStyle.Default, 10 | ) 11 | -------------------------------------------------------------------------------- /shared/src/main/java/com/moove/shared/feature/deeplink/domain/GetDynamicLinkUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.moove.shared.feature.deeplink.domain 2 | 3 | class GetDynamicLinkUseCase( 4 | private val dynamicLinkRepository: DynamicLinkRepository, 5 | ) { 6 | 7 | suspend operator fun invoke(uri: String): String? { 8 | return dynamicLinkRepository.parseLink(uri) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/moove/app/di/ExceptionsModule.kt: -------------------------------------------------------------------------------- 1 | package com.moove.app.di 2 | 3 | import com.moove.app.shared.exception_handler.main.MainExceptionHandler 4 | import com.moove.core.exception.ExceptionHandler 5 | import org.koin.dsl.module 6 | 7 | val exceptionsModule = module { 8 | single { listOf() } 9 | single { MainExceptionHandler(get()) } 10 | } 11 | -------------------------------------------------------------------------------- /shared/src/main/java/com/moove/shared/presentation/viewmodel/ViewModelExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.moove.shared.presentation.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.moove.core.kotlin.coroutines.executeUseCase 6 | 7 | suspend inline fun ViewModel.executeUseCase(block: () -> R): Result = 8 | viewModelScope.executeUseCase(block) 9 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/confirmation/ConfirmationNavigator.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.confirmation 2 | 3 | import com.moove.shared.navigation.ScreenNavigator 4 | 5 | class ConfirmationNavigator( 6 | private val screenNavigator: ScreenNavigator, 7 | ) : ScreenNavigator { 8 | 9 | override fun goBack() { 10 | screenNavigator.goBack() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/domain/use_cases/GetRydersUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.domain.use_cases 2 | 3 | import com.moove.tickets.domain.TicketsRepository 4 | import com.moove.tickets.domain.model.Ryder 5 | 6 | class GetRydersUseCase( 7 | private val ticketsRepository: TicketsRepository, 8 | ) { 9 | suspend operator fun invoke(): List = ticketsRepository.getRyders() 10 | } 11 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/domain/TicketsRepository.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.domain 2 | 3 | import com.moove.tickets.domain.model.Fare 4 | import com.moove.tickets.domain.model.Ryder 5 | 6 | interface TicketsRepository { 7 | suspend fun getRyders(): List 8 | suspend fun getFares(ryderId: String): List 9 | suspend fun buyTicket(ryderId: String, fare: Fare, totalCount: Int) 10 | } 11 | -------------------------------------------------------------------------------- /.github/actions/job-set-up/action.yml: -------------------------------------------------------------------------------- 1 | name: Job set up 2 | description: Sets up the Java and Gradle 3 | runs: 4 | using: "composite" 5 | steps: 6 | - uses: actions/setup-java@v4 7 | with: 8 | distribution: "zulu" 9 | java-version: "17" 10 | 11 | - name: Setup Gradle 12 | uses: gradle/gradle-build-action@v2 13 | 14 | - name: Setup Android SDK 15 | uses: android-actions/setup-android@v3 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/moove/app/main/MainNavigator.kt: -------------------------------------------------------------------------------- 1 | package com.moove.app.main 2 | 3 | import com.moove.shared.feature.deeplink.domain.DeepLink 4 | import com.moove.shared.feature.deeplink.presentation.DeepLinkNavigator 5 | 6 | class MainNavigator( 7 | private val deepLinkNavigator: DeepLinkNavigator, 8 | ) { 9 | 10 | fun navigateDeepLink(link: DeepLink) { 11 | deepLinkNavigator.navigateTo(link) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /shared/src/main/java/com/moove/shared/presentation/fragment/delegate/FragmentViewLifecycleAwareDelegateExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.moove.shared.presentation.fragment.delegate 2 | 3 | import android.view.View 4 | import androidx.fragment.app.Fragment 5 | import androidx.viewbinding.ViewBinding 6 | 7 | fun Fragment.viewBinding(viewBindingFactory: (View) -> T) = 8 | FragmentViewLifecycleAwareDelegate(this, viewBindingFactory) 9 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/test/java/com/moove/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.moove 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } -------------------------------------------------------------------------------- /design-system/src/main/java/com/moove/design_system/compose/moove/NeutralColors.kt: -------------------------------------------------------------------------------- 1 | package com.moove.design_system.compose.moove 2 | 3 | import com.moove.design_system.compose.NeutralColors 4 | 5 | internal val mooveNeutralColors: NeutralColors 6 | get() = NeutralColors( 7 | MooveColors.Grey01, 8 | MooveColors.Grey02, 9 | MooveColors.Grey03, 10 | MooveColors.Grey04, 11 | MooveColors.Grey05, 12 | ) 13 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/data/local/dto/TicketsDataDTO.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.data.local.dto 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | 6 | @JsonClass(generateAdapter = true) 7 | data class TicketsDataDTO( 8 | @Json(name = "Adult") 9 | val adult: RyderDTO, 10 | @Json(name = "Child") 11 | val child: RyderDTO, 12 | @Json(name = "Senior") 13 | val senior: RyderDTO, 14 | ) -------------------------------------------------------------------------------- /core/src/main/java/com/moove/core/kotlin/ResultExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.moove.core.kotlin 2 | 3 | import java.util.concurrent.CancellationException 4 | 5 | inline fun runSuspendCatching(block: () -> R): Result { 6 | return try { 7 | Result.success(block()) 8 | } catch (cancellationException: CancellationException) { 9 | throw cancellationException 10 | } catch (e: Throwable) { 11 | Result.failure(e) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/domain/use_cases/GetFaresByIdUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.domain.use_cases 2 | 3 | import com.moove.tickets.domain.TicketsRepository 4 | import com.moove.tickets.domain.model.Fare 5 | 6 | class GetFaresByIdUseCase( 7 | private val ticketsRepository: TicketsRepository, 8 | ) { 9 | suspend operator fun invoke(ryderId: String): List { 10 | return ticketsRepository.getFares(ryderId) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/moove/app/di/CoroutineModule.kt: -------------------------------------------------------------------------------- 1 | package com.moove.app.di 2 | 3 | import com.moove.core.exception.ExceptionHandler 4 | import com.moove.core.exception.asCoroutineExceptionHandler 5 | import com.moove.core.kotlin.coroutines.AppCoroutineScope 6 | import org.koin.dsl.module 7 | 8 | val coroutineModule = module { 9 | single { AppCoroutineScope(exceptionHandler = get()) } 10 | single { get().asCoroutineExceptionHandler() } 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/moove/app/main/MainActivityState.kt: -------------------------------------------------------------------------------- 1 | package com.moove.app.main 2 | 3 | import android.os.Parcelable 4 | import com.moove.shared.feature.deeplink.domain.DeepLink 5 | import kotlinx.parcelize.Parcelize 6 | 7 | @Parcelize 8 | class MainActivityState : Parcelable 9 | 10 | sealed class MainActivityEffect { 11 | data object ShowGenericError : MainActivityEffect() 12 | data class NavigateDeepLink(val deepLink: DeepLink) : MainActivityEffect() 13 | } 14 | -------------------------------------------------------------------------------- /design-system/src/main/java/com/moove/design_system/compose/Shapes.kt: -------------------------------------------------------------------------------- 1 | package com.moove.design_system.compose 2 | 3 | import androidx.compose.material.Shapes 4 | import androidx.compose.runtime.Immutable 5 | import androidx.compose.runtime.staticCompositionLocalOf 6 | 7 | @Immutable 8 | data class AppShapes( 9 | val material: Shapes, 10 | ) 11 | 12 | val LocalAppShapes = staticCompositionLocalOf { 13 | AppShapes( 14 | material = Shapes(), 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/java/com/moove/app/di/NetModule.kt: -------------------------------------------------------------------------------- 1 | package com.moove.app.di 2 | 3 | import com.squareup.moshi.Moshi 4 | import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory 5 | import dev.zacsweers.moshix.adapters.AdaptedBy 6 | import org.koin.dsl.module 7 | 8 | val netModule = module { 9 | 10 | single { 11 | Moshi.Builder() 12 | .add(KotlinJsonAdapterFactory()) 13 | .add(AdaptedBy.Factory()) 14 | .build() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /design-system/src/main/java/com/moove/design_system/compose/Dimensions.kt: -------------------------------------------------------------------------------- 1 | package com.moove.design_system.compose 2 | 3 | import androidx.compose.ui.unit.dp 4 | 5 | object Spacing { 6 | val XXS = 2.dp 7 | val XS = 4.dp 8 | val S = 8.dp 9 | val SM = 12.dp 10 | val M = 16.dp 11 | val L = 24.dp 12 | val XL = 32.dp 13 | val XXL = 48.dp 14 | } 15 | 16 | object Elevation { 17 | val XS = 2.dp 18 | val S = 4.dp 19 | val M = 8.dp 20 | val LM = 12.dp 21 | } 22 | -------------------------------------------------------------------------------- /core/src/main/java/com/moove/core/okhttp/ConnectionErrorInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.moove.core.okhttp 2 | 3 | import java.io.IOException 4 | 5 | class ConnectionErrorInterceptor : okhttp3.Interceptor { 6 | 7 | override fun intercept(chain: okhttp3.Interceptor.Chain): okhttp3.Response { 8 | try { 9 | return chain.proceed(chain.request()) 10 | } catch (exception: IOException) { 11 | throw NoConnectionException(cause = exception) 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /design-system/src/main/java/com/moove/design_system/compose/NeutralColors.kt: -------------------------------------------------------------------------------- 1 | package com.moove.design_system.compose 2 | 3 | import androidx.compose.runtime.Immutable 4 | import androidx.compose.ui.graphics.Color 5 | 6 | @Immutable 7 | data class NeutralColors( 8 | val grey01: Color = Color.Unspecified, 9 | val grey02: Color = Color.Unspecified, 10 | val grey03: Color = Color.Unspecified, 11 | val grey04: Color = Color.Unspecified, 12 | val grey05: Color = Color.Unspecified, 13 | ) 14 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 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 = "Moove" 17 | include ':app' 18 | include ':shared' 19 | include ':design-system' 20 | include ':tickets' 21 | include ':core' 22 | -------------------------------------------------------------------------------- /shared/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Select Ryder 4 | Select Fare 5 | Confirm Selection 6 | An error has occurred. 7 | Buy %s Tickets - %s 8 | Success 9 | 10 | -------------------------------------------------------------------------------- /shared/src/main/java/com/moove/shared/feature/deeplink/domain/GetDeeplinkUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.moove.shared.feature.deeplink.domain 2 | 3 | class GetDeeplinkUseCase( 4 | private val deeplinkRepository: DeeplinkRepository, 5 | private val getDynamicLinkUseCase: GetDynamicLinkUseCase, 6 | ) { 7 | suspend operator fun invoke(uri: String): DeepLink { 8 | val parsedUri = getDynamicLinkUseCase(uri) ?: uri 9 | val deepLink = deeplinkRepository.getDeepLink(parsedUri) 10 | return deepLink 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/moove/app/feature/deeplink/data/DynamicLinkDataRepository.kt: -------------------------------------------------------------------------------- 1 | package com.moove.app.feature.deeplink.data 2 | 3 | import com.moove.app.feature.deeplink.data.remote.FirebaseDynamicLinkDataSource 4 | import com.moove.shared.feature.deeplink.domain.DynamicLinkRepository 5 | 6 | class DynamicLinkDataRepository( 7 | private val dataSource: FirebaseDynamicLinkDataSource, 8 | ) : DynamicLinkRepository { 9 | 10 | override suspend fun parseLink(uri: String): String? { 11 | return dataSource.parseLink(uri) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tickets/src/test/java/com/moove/tickets/data/dto/RyderDTOMother.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.data.dto 2 | 3 | import com.moove.shared.faker 4 | import com.moove.tickets.data.local.dto.FareDTO 5 | import com.moove.tickets.data.local.dto.RyderDTO 6 | 7 | fun randomFareDTO() = FareDTO( 8 | description = faker.name().fullName(), 9 | price = faker.number().randomDigitNotZero().toFloat(), 10 | ) 11 | 12 | fun randomRyderDTO() = RyderDTO( 13 | fares = listOf(randomFareDTO(), randomFareDTO()), 14 | subtext = faker.name().fullName(), 15 | ) 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/moove/app/feature/deeplink/domain/AppDeepLink.kt: -------------------------------------------------------------------------------- 1 | package com.moove.app.feature.deeplink.domain 2 | 3 | import com.moove.shared.feature.deeplink.domain.DeepLink 4 | import com.moove.tickets.domain.model.Fare 5 | 6 | sealed class AppDeepLink: DeepLink { 7 | data object Unknown : AppDeepLink() 8 | data object Home : AppDeepLink() 9 | data class FareList(val ryderId: String) : AppDeepLink() 10 | 11 | data class Confirmation( 12 | val ryderId: String, 13 | val fare: Fare, 14 | ) : AppDeepLink() 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/fare/model/FareModelFake.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.fare.model 2 | 3 | internal val fakeFareModels: List 4 | get() = listOf( 5 | FareModel( 6 | description = "2.5 Hour Ticket", 7 | price = 2.5f, 8 | ), 9 | FareModel( 10 | description = "1 Day Pass", 11 | price = 5.0f, 12 | ), 13 | FareModel( 14 | description = "30 Day Pass", 15 | price = 100f, 16 | ) 17 | ) -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/domain/use_cases/BuyTicketUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.domain.use_cases 2 | 3 | import com.moove.tickets.domain.TicketsRepository 4 | import com.moove.tickets.domain.model.Fare 5 | import com.moove.tickets.domain.model.Ryder 6 | 7 | class BuyTicketUseCase( 8 | private val ticketsRepository: TicketsRepository, 9 | ) { 10 | suspend operator fun invoke(ryderId: String, fare: Fare, totalCount: Int) { 11 | ticketsRepository.buyTicket(ryderId = ryderId, fare = fare, totalCount = totalCount) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /design-system/src/main/java/com/moove/design_system/compose/Typography.kt: -------------------------------------------------------------------------------- 1 | package com.moove.design_system.compose 2 | 3 | import androidx.compose.material.Typography 4 | import androidx.compose.runtime.Immutable 5 | import androidx.compose.runtime.staticCompositionLocalOf 6 | 7 | @Immutable 8 | data class AppTypography( 9 | val material: Typography, 10 | val appBar: TopAppBarTypography, 11 | ) 12 | 13 | val LocalAppTypography = staticCompositionLocalOf { 14 | AppTypography( 15 | material = Typography(), 16 | appBar = TopAppBarTypography() 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/moove/app/shared/exception_handler/main/MainExceptionHandler.kt: -------------------------------------------------------------------------------- 1 | package com.moove.app.shared.exception_handler.main 2 | 3 | import com.moove.core.exception.ExceptionHandler 4 | import com.moove.core.okhttp.NoConnectionException 5 | 6 | class MainExceptionHandler( 7 | private val exceptionHandlers: List 8 | ) : ExceptionHandler { 9 | 10 | override fun handle(error: Throwable) { 11 | if (error is NoConnectionException) { 12 | return 13 | } 14 | exceptionHandlers.forEach { it.handle(error) } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/list/RyderListNavigator.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.list 2 | 3 | import com.moove.shared.navigation.ScreenNavigator 4 | import com.moove.shared.navigation.TicketsNavigator 5 | 6 | class RyderListNavigator( 7 | private val ticketsNavigator: TicketsNavigator, 8 | private val screenNavigator: ScreenNavigator, 9 | ) : ScreenNavigator { 10 | 11 | fun goFares(id: String) { 12 | ticketsNavigator.goFares(id) 13 | } 14 | 15 | override fun goBack() { 16 | screenNavigator.goBack() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/data/local/dto/FareDTO.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.data.local.dto 2 | 3 | import com.moove.tickets.domain.model.Fare 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | 7 | @JsonClass(generateAdapter = true) 8 | data class FareDTO( 9 | @Json(name = "description") 10 | val description: String, 11 | @Json(name = "price") 12 | val price: Float, 13 | ) 14 | 15 | fun FareDTO.asDomain() = Fare( 16 | description = description, 17 | price = price, 18 | ) 19 | 20 | fun List.asDomain() = map { it.asDomain() } 21 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/data/local/dto/RyderDTO.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.data.local.dto 2 | 3 | import com.moove.tickets.domain.model.Ryder 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | 7 | @JsonClass(generateAdapter = true) 8 | data class RyderDTO( 9 | @Json(name = "fares") 10 | val fares: List, 11 | @Json(name = "subtext") 12 | val subtext: String?, 13 | ) 14 | 15 | // TODO add extra tests 16 | fun RyderDTO.asDomain(id: String) = Ryder( 17 | id = id, 18 | fares = fares.asDomain(), 19 | subtext = subtext, 20 | ) -------------------------------------------------------------------------------- /tickets/src/test/java/com/moove/tickets/domain/model/RyderMother.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.domain.model 2 | 3 | import com.moove.shared.faker 4 | import com.moove.tickets.presentation.fare.model.FareModel 5 | import com.moove.tickets.presentation.list.model.RyderModel 6 | 7 | fun randomFare() = Fare( 8 | description = faker.name().fullName(), 9 | price = faker.number().randomDigitNotZero().toFloat(), 10 | ) 11 | 12 | fun randomRyder() = Ryder( 13 | id = faker.idNumber().valid(), 14 | subtext = faker.name().fullName(), 15 | fares = listOf(randomFare(), randomFare()), 16 | ) 17 | -------------------------------------------------------------------------------- /.github/workflows/generate_docs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # The website is built using MkDocs with the Material theme. 4 | # https://squidfunk.github.io/mkdocs-material/ 5 | # It requires Python to run. 6 | # Install the packages with the following command: 7 | # pip install mkdocs mkdocs-material mkdocs-redirects 8 | 9 | set -ex 10 | 11 | # Generate the API docs 12 | ./gradlew dokkaHtmlMultiModule 13 | #mv ./build/dokka/api android-docs/docs 14 | 15 | # Build the site locally 16 | cd ./android-docs 17 | python3 -m venv venv 18 | source venv/bin/activate 19 | pip3 install mkdocs-material 20 | mkdocs build 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/moove/app/feature/deeplink/data/DeeplinkDataRepository.kt: -------------------------------------------------------------------------------- 1 | package com.moove.app.feature.deeplink.data 2 | 3 | import com.moove.app.feature.deeplink.data.local.AppDeepLinkLocalDataSource 4 | import com.moove.shared.feature.deeplink.domain.DeepLink 5 | import com.moove.shared.feature.deeplink.domain.DeeplinkRepository 6 | 7 | class DeeplinkDataRepository( 8 | private val localDataSource: AppDeepLinkLocalDataSource, 9 | ) : DeeplinkRepository { 10 | 11 | override suspend fun getDeepLink(uri: String): DeepLink { 12 | return localDataSource.getDeepLinkData(uri) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /shared/src/main/java/com/moove/shared/presentation/compose/platform/ComposeViewExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.moove.shared.presentation.compose.platform 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.platform.ComposeView 5 | import androidx.compose.ui.platform.ViewCompositionStrategy 6 | import com.moove.design_system.compose.AppTheme 7 | 8 | fun ComposeView.setAppComposeContent(strategy: ViewCompositionStrategy? = null, content: @Composable () -> Unit) { 9 | strategy?.also(::setViewCompositionStrategy) 10 | setContent { 11 | AppTheme(content = content) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/moove/app/feature/home/HomeNavigator.kt: -------------------------------------------------------------------------------- 1 | package com.moove.app.feature.home 2 | 3 | import androidx.navigation.NavController 4 | import com.moove.shared.navigation.ScreenNavigator 5 | import com.moove.shared.navigation.navigateSafely 6 | 7 | class HomeNavigator( 8 | private val navController: NavController, 9 | private val screenNavigator: ScreenNavigator, 10 | ) : ScreenNavigator { 11 | 12 | fun goRyderList() { 13 | navController.navigateSafely(HomeFragmentDirections.actionHomeFragmentToTicketsFlow()) 14 | } 15 | 16 | override fun goBack() { 17 | screenNavigator.goBack() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /design-system/src/main/java/com/moove/design_system/compose/moove/Shapes.kt: -------------------------------------------------------------------------------- 1 | package com.moove.design_system.compose.moove 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material.Shapes 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.unit.dp 7 | import com.moove.design_system.compose.AppShapes 8 | 9 | internal val mooveShapes: AppShapes 10 | @Composable 11 | get() = AppShapes( 12 | material = Shapes( 13 | small = RoundedCornerShape(8.dp), 14 | medium = RoundedCornerShape(8.dp), 15 | large = RoundedCornerShape(8.dp) 16 | ), 17 | ) 18 | -------------------------------------------------------------------------------- /design-system/src/main/java/com/moove/design_system/compose/Colors.kt: -------------------------------------------------------------------------------- 1 | package com.moove.design_system.compose 2 | 3 | import androidx.compose.material.Colors 4 | import androidx.compose.material.lightColors 5 | import androidx.compose.runtime.Immutable 6 | import androidx.compose.runtime.staticCompositionLocalOf 7 | 8 | @Immutable 9 | data class AppColors( 10 | val material: Colors, 11 | val neutral: NeutralColors, 12 | val buttons: ButtonsColors, 13 | ) 14 | 15 | val LocalAppColors = staticCompositionLocalOf { 16 | AppColors( 17 | material = lightColors(), 18 | neutral = NeutralColors(), 19 | buttons = ButtonsColors(), 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/fare/model/FareModel.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.fare.model 2 | 3 | import android.os.Parcelable 4 | import com.moove.tickets.domain.model.Fare 5 | import kotlinx.parcelize.Parcelize 6 | 7 | @Parcelize 8 | data class FareModel( 9 | val description: String, 10 | val price: Float, 11 | ) : Parcelable 12 | 13 | fun Fare.asPresentation() = FareModel( 14 | description = description, 15 | price = price, 16 | ) 17 | 18 | fun List.asPresentation(): List = map { it.asPresentation() } 19 | 20 | fun FareModel.asDomain() = Fare( 21 | description = description, 22 | price = price, 23 | ) 24 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/list/RyderListState.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.list 2 | 3 | import android.os.Parcelable 4 | import com.moove.shared.presentation.compose.component.ScreenContentStatus 5 | import com.moove.tickets.presentation.list.model.RyderModel 6 | import kotlinx.parcelize.Parcelize 7 | 8 | @Parcelize 9 | data class RyderListState( 10 | val status: ScreenContentStatus = ScreenContentStatus.Idle, 11 | val ryders: List = emptyList(), 12 | ) : Parcelable 13 | 14 | sealed class RyderListEffect { 15 | data class GoToFares(val id: String) : RyderListEffect() 16 | object ShowGenericError : RyderListEffect() 17 | } -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/fare/FareListState.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.fare 2 | 3 | import android.os.Parcelable 4 | import com.moove.shared.presentation.compose.component.ScreenContentStatus 5 | import com.moove.tickets.presentation.fare.model.FareModel 6 | import kotlinx.parcelize.Parcelize 7 | 8 | @Parcelize 9 | data class FareListState( 10 | val status: ScreenContentStatus = ScreenContentStatus.Idle, 11 | val fares: List = emptyList(), 12 | ) : Parcelable 13 | 14 | sealed class FareListEffect { 15 | data class GoToConfirmation(val ryderId: String, val fare: FareModel) : FareListEffect() 16 | object ShowGenericError : FareListEffect() 17 | } -------------------------------------------------------------------------------- /design-system/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.library' 3 | id 'org.jetbrains.kotlin.android' 4 | } 5 | 6 | apply from: "${rootProject.projectDir}/gradle/base-android-config.gradle" 7 | apply from: "${rootProject.projectDir}/gradle/compose-android-config.gradle" 8 | 9 | android { 10 | namespace 'com.moove.design_system' 11 | } 12 | 13 | dependencies { 14 | 15 | // region Google 16 | implementation libs.google.material 17 | implementation libs.bundles.google.accompanist 18 | // endregion 19 | 20 | // region AndroidX 21 | implementation libs.androidx.appcompat 22 | implementation libs.androidx.core 23 | implementation libs.bundles.androidx.compose 24 | // endregion 25 | } -------------------------------------------------------------------------------- /tickets/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.library' 3 | id 'kotlin-android' 4 | id 'kotlin-parcelize' 5 | id 'androidx.navigation.safeargs.kotlin' 6 | id 'com.google.devtools.ksp' 7 | } 8 | 9 | apply from: "${rootProject.projectDir}/gradle/base-android-config.gradle" 10 | apply from: "${rootProject.projectDir}/gradle/compose-android-config.gradle" 11 | 12 | android { 13 | namespace 'com.moove.tickets' 14 | } 15 | 16 | dependencies { 17 | 18 | implementation project(':shared') 19 | 20 | ksp libs.moshi.compiler 21 | 22 | testImplementation libs.bundles.tests.unit 23 | testImplementation libs.bundles.tests.viewmodel 24 | androidTestImplementation libs.bundles.tests.android 25 | } -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/fare/FareListNavigator.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.fare 2 | 3 | import com.moove.shared.navigation.ScreenNavigator 4 | import com.moove.shared.navigation.TicketsNavigator 5 | import com.moove.tickets.presentation.fare.model.FareModel 6 | 7 | class FareListNavigator( 8 | private val ticketsNavigator: TicketsNavigator, 9 | private val screenNavigator: ScreenNavigator, 10 | ) : ScreenNavigator { 11 | 12 | fun goToConfirmation(ryderId: String, fare: FareModel) { 13 | ticketsNavigator.goToConfirmation(ryderId, fare.description, fare.price) 14 | } 15 | 16 | override fun goBack() { 17 | screenNavigator.goBack() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/list/model/RyderModelFake.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.list.model 2 | 3 | import com.moove.tickets.presentation.fare.model.fakeFareModels 4 | 5 | internal val fakeRyderModels: List 6 | get() = listOf( 7 | RyderModel( 8 | id = "Adult", 9 | fares = fakeFareModels, 10 | subtext = null, 11 | ), 12 | RyderModel( 13 | id = "Child", 14 | fares = fakeFareModels, 15 | subtext = "Ages 8-17", 16 | ), 17 | RyderModel( 18 | id = "Senior", 19 | fares = fakeFareModels, 20 | subtext = "Ages 60+", 21 | ), 22 | ) -------------------------------------------------------------------------------- /core/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.library' 3 | id 'kotlin-android' 4 | } 5 | apply from: "${rootProject.projectDir}/gradle/base-android-config.gradle" 6 | 7 | android { 8 | namespace 'com.moove.core' 9 | } 10 | 11 | dependencies { 12 | // region kotlin 13 | api libs.kotlin.coroutines.core 14 | api libs.kotlin.coroutines.android 15 | // endregion 16 | 17 | // region ui 18 | api libs.androidx.core 19 | api libs.androidx.compose.ui.text 20 | // endregion 21 | 22 | // region io 23 | api platform(libs.okhttp.platform) 24 | api libs.okhttp.client 25 | api libs.okhttp.logging 26 | // endregion 27 | 28 | // region Tests 29 | testImplementation libs.bundles.tests.unit 30 | // endregion 31 | } -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/confirmation/ConfirmationState.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.confirmation 2 | 3 | import android.os.Parcelable 4 | import com.moove.shared.presentation.compose.component.ScreenContentStatus 5 | import com.moove.tickets.presentation.fare.model.FareModel 6 | import kotlinx.parcelize.Parcelize 7 | 8 | @Parcelize 9 | data class ConfirmationState( 10 | val status: ScreenContentStatus = ScreenContentStatus.Idle, 11 | val ryderId: String, 12 | val fare: FareModel, 13 | val ticketCount: Int, 14 | val totalPrice: Float, 15 | ) : Parcelable 16 | 17 | sealed class ConfirmationEffect { 18 | object ShowGenericError : ConfirmationEffect() 19 | object ShowSuccessMessage : ConfirmationEffect() 20 | } -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/list/model/RyderModel.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.list.model 2 | 3 | import android.os.Parcelable 4 | import com.moove.tickets.domain.model.Ryder 5 | import com.moove.tickets.presentation.fare.model.FareModel 6 | import com.moove.tickets.presentation.fare.model.asPresentation 7 | import kotlinx.parcelize.Parcelize 8 | 9 | @Parcelize 10 | data class RyderModel( 11 | val id: String, 12 | val fares: List, 13 | val subtext: String?, 14 | ) : Parcelable 15 | 16 | fun Ryder.asPresentation() = RyderModel( 17 | id = id, 18 | fares = fares.asPresentation(), 19 | subtext = subtext, 20 | ) 21 | 22 | fun List.asPresentation(): List = map { it.asPresentation() } 23 | -------------------------------------------------------------------------------- /shared/src/main/java/com/moove/shared/ModelMother.kt: -------------------------------------------------------------------------------- 1 | package com.moove.shared 2 | 3 | import com.github.javafaker.Faker 4 | 5 | val faker = object : Faker() {} 6 | 7 | fun randomListOf( 8 | minListElements: Int = 2, 9 | maxListElements: Int = 20, 10 | elementGenerationBlock: () -> T 11 | ): List { 12 | val numberOfElements = faker.number().numberBetween(minListElements, maxListElements) 13 | return (0..numberOfElements).map { elementGenerationBlock() } 14 | } 15 | 16 | fun Array.random() = this[faker.number().numberBetween(0, this.size)] 17 | 18 | fun randomNullableOf(nullableValueGenerationBlock: () -> T): T? { 19 | if (faker.bool().bool()) { 20 | return null 21 | } 22 | return nullableValueGenerationBlock() 23 | } 24 | -------------------------------------------------------------------------------- /core/src/main/java/com/moove/core/kotlin/coroutines/CoroutineScopeExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.moove.core.kotlin.coroutines 2 | 3 | import com.moove.core.kotlin.runSuspendCatching 4 | import kotlinx.coroutines.CoroutineExceptionHandler 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.coroutines.coroutineScope 7 | 8 | @Suppress("RedundantSuspendModifier", "SuspendFunctionOnCoroutineScope") 9 | suspend inline fun CoroutineScope.executeUseCase(block: () -> R): Result { 10 | val currentExecContext = coroutineScope { coroutineContext } 11 | val exceptionHandler = currentExecContext[CoroutineExceptionHandler] 12 | return runSuspendCatching(block) 13 | .onFailure { 14 | exceptionHandler?.handleException(currentExecContext, it) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/moove/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.moove 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.moove", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /core/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/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 -------------------------------------------------------------------------------- /tickets/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 -------------------------------------------------------------------------------- /design-system/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/main/res/navigation/app_main_navigation.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 12 | 13 | 16 | 17 | 18 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /core/src/main/java/com/moove/core/kotlin/coroutines/AppCoroutineScope.kt: -------------------------------------------------------------------------------- 1 | package com.moove.core.kotlin.coroutines 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | import kotlinx.coroutines.CoroutineExceptionHandler 5 | import kotlinx.coroutines.CoroutineName 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.SupervisorJob 9 | 10 | /** 11 | * App-wide coroutine scope intended to be a singleton. 12 | * It is supervisor scope, meaning any unhandled exception wont affect siblings. 13 | * Also, it requires exception handler to exist meaning we want all un-handled exceptions to be caught. 14 | */ 15 | class AppCoroutineScope( 16 | dispatcher: CoroutineDispatcher = Dispatchers.Default, 17 | exceptionHandler: CoroutineExceptionHandler, 18 | ) : CoroutineScope by CoroutineScope( 19 | SupervisorJob() + dispatcher + exceptionHandler + CoroutineName("AppScope") 20 | ) 21 | -------------------------------------------------------------------------------- /design-system/src/main/java/com/moove/design_system/compose/moove/Button.kt: -------------------------------------------------------------------------------- 1 | package com.moove.design_system.compose.moove 2 | 3 | import androidx.compose.runtime.Composable 4 | import com.moove.design_system.compose.ButtonsColors 5 | import com.moove.design_system.compose.PrimaryButtonColors 6 | 7 | private object MooveButtonsDefaults { 8 | val mainColor = MooveColors.FloytGreen 9 | val contentColor = MooveColors.White 10 | val disabledColor = MooveColors.Grey05 11 | val disabledContentColor = MooveColors.Grey03 12 | } 13 | 14 | internal val mooveButtonsColors: ButtonsColors 15 | @Composable 16 | get() = ButtonsColors( 17 | primary = PrimaryButtonColors( 18 | background = MooveButtonsDefaults.mainColor, 19 | content = MooveButtonsDefaults.contentColor, 20 | disabledBackground = MooveButtonsDefaults.disabledColor, 21 | disabledContent = MooveButtonsDefaults.disabledContentColor, 22 | ), 23 | ) 24 | -------------------------------------------------------------------------------- /shared/src/main/java/com/moove/shared/presentation/compose/platform/FragmentExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.moove.shared.presentation.compose.platform 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.remember 5 | import androidx.compose.ui.platform.ComposeView 6 | import androidx.compose.ui.platform.LocalContext 7 | import androidx.compose.ui.platform.ViewCompositionStrategy 8 | import androidx.fragment.app.Fragment 9 | import androidx.fragment.app.FragmentActivity 10 | import androidx.fragment.app.FragmentManager 11 | 12 | fun Fragment.setAppComposeContent(view: ComposeView, content: @Composable () -> Unit) { 13 | view.setAppComposeContent(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed, content) 14 | } 15 | 16 | @Composable 17 | fun rememberFragmentManager(): FragmentManager? { 18 | val context = LocalContext.current 19 | return remember(context) { 20 | (context as? FragmentActivity)?.supportFragmentManager 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tickets/src/test/java/com/moove/tickets/domain/use_cases/BuyTicketUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.domain.use_cases 2 | 3 | import com.moove.shared.faker 4 | import com.moove.tickets.domain.TicketsRepository 5 | import com.moove.tickets.domain.model.Fare 6 | import io.mockk.coVerify 7 | import io.mockk.mockk 8 | import kotlinx.coroutines.test.runTest 9 | import org.junit.Test 10 | 11 | class BuyTicketUseCaseTest { 12 | 13 | private val ticketsRepository: TicketsRepository = mockk(relaxed = true) 14 | 15 | private val buyTicketUseCase = BuyTicketUseCase(ticketsRepository) 16 | 17 | @Test 18 | fun `On invoke should call correct method on repository`() = runTest { 19 | val ryderId = faker.idNumber().valid() 20 | val fare: Fare = mockk() 21 | val totalCount: Int = faker.number().randomDigitNotZero() 22 | 23 | buyTicketUseCase(ryderId, fare, totalCount) 24 | 25 | coVerify { ticketsRepository.buyTicket(ryderId, fare, totalCount) } 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/main/java/com/moove/app/feature/home/HomeViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.moove.app.feature.home 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.moove.core.exception.ExceptionHandler 5 | import com.moove.core.exception.asCoroutineExceptionHandler 6 | import org.orbitmvi.orbit.Container 7 | import org.orbitmvi.orbit.ContainerHost 8 | import org.orbitmvi.orbit.syntax.simple.intent 9 | import org.orbitmvi.orbit.syntax.simple.postSideEffect 10 | import org.orbitmvi.orbit.viewmodel.container 11 | 12 | class HomeViewModel( 13 | exceptionHandler: ExceptionHandler, 14 | ) : ViewModel(), ContainerHost { 15 | 16 | override val container: Container = container( 17 | initialState = HomeState(), 18 | buildSettings = { 19 | this.exceptionHandler = exceptionHandler.asCoroutineExceptionHandler() 20 | }, 21 | ) 22 | 23 | fun onRyderClick() = intent { 24 | postSideEffect(HomeEffect.GoToRyderList) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 18 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/fare/component/FareList.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.fare.component 2 | 3 | import androidx.compose.foundation.lazy.LazyColumn 4 | import androidx.compose.foundation.lazy.items 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.tooling.preview.Preview 7 | import com.moove.design_system.compose.AppTheme 8 | import com.moove.tickets.presentation.fare.model.FareModel 9 | import com.moove.tickets.presentation.fare.model.fakeFareModels 10 | 11 | @Composable 12 | fun FareList( 13 | fares: List, 14 | onClick: (FareModel) -> Unit, 15 | ) { 16 | LazyColumn { 17 | items(fares) { item -> 18 | FareItem(fare = item, onClick = onClick) 19 | } 20 | } 21 | } 22 | 23 | @Preview(name = "Fare List", showBackground = true) 24 | @Composable 25 | private fun PreviewRyderList() { 26 | AppTheme { 27 | FareList( 28 | fares = fakeFareModels, 29 | onClick = {} 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/list/component/RyderList.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.list.component 2 | 3 | import androidx.compose.foundation.lazy.LazyColumn 4 | import androidx.compose.foundation.lazy.items 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.tooling.preview.Preview 7 | import com.moove.design_system.compose.AppTheme 8 | import com.moove.tickets.presentation.list.model.RyderModel 9 | import com.moove.tickets.presentation.list.model.fakeRyderModels 10 | 11 | @Composable 12 | fun RyderList( 13 | ryders: List, 14 | onClick: (RyderModel) -> Unit, 15 | ) { 16 | LazyColumn { 17 | items(ryders) { item -> 18 | RyderItem(ryder = item, onClick = onClick) 19 | } 20 | } 21 | } 22 | 23 | @Preview(name = "Ryder List", showBackground = true) 24 | @Composable 25 | private fun PreviewRyderList() { 26 | AppTheme { 27 | RyderList( 28 | ryders = fakeRyderModels, 29 | onClick = {} 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tickets/src/test/java/com/moove/tickets/domain/use_cases/GetRydersUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.domain.use_cases 2 | 3 | import com.moove.tickets.domain.TicketsRepository 4 | import com.moove.tickets.domain.model.Ryder 5 | import io.mockk.coEvery 6 | import io.mockk.coVerify 7 | import io.mockk.mockk 8 | import kotlinx.coroutines.test.runTest 9 | import org.junit.Test 10 | import kotlin.test.assertEquals 11 | 12 | class GetRydersUseCaseTest { 13 | 14 | private val ticketsRepository: TicketsRepository = mockk(relaxed = true) 15 | 16 | private val getRydersUseCase = GetRydersUseCase(ticketsRepository) 17 | 18 | @Test 19 | fun `On invoke should return correct result`() = runTest { 20 | val ryders: List = listOf( 21 | mockk(), 22 | mockk(), 23 | ) 24 | 25 | coEvery { ticketsRepository.getRyders() } returns ryders 26 | 27 | val result = getRydersUseCase() 28 | 29 | assertEquals(ryders, result) 30 | 31 | coVerify { ticketsRepository.getRyders() } 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 20 | -------------------------------------------------------------------------------- /design-system/src/main/java/com/moove/design_system/compose/moove/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.moove.design_system.compose.moove 2 | 3 | import androidx.compose.material.LocalContentColor 4 | import androidx.compose.material.MaterialTheme 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.CompositionLocalProvider 7 | import com.moove.design_system.compose.LocalAppColors 8 | import com.moove.design_system.compose.LocalAppShapes 9 | import com.moove.design_system.compose.LocalAppTypography 10 | 11 | @Composable 12 | internal fun MooveTheme(content: @Composable () -> Unit) { 13 | MaterialTheme( 14 | colors = mooveColors.material, 15 | typography = mooveTypography.material, 16 | shapes = mooveShapes.material, 17 | ) { 18 | CompositionLocalProvider( 19 | LocalContentColor provides MooveColors.Text, 20 | LocalAppTypography provides mooveTypography, 21 | LocalAppColors provides mooveColors, 22 | LocalAppShapes provides mooveShapes, 23 | content = content 24 | ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tickets/src/test/java/com/moove/tickets/domain/use_cases/GetFaresByIdUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.domain.use_cases 2 | 3 | import com.moove.shared.faker 4 | import com.moove.tickets.domain.TicketsRepository 5 | import com.moove.tickets.domain.model.Fare 6 | import io.mockk.coEvery 7 | import io.mockk.coVerify 8 | import io.mockk.mockk 9 | import kotlinx.coroutines.test.runTest 10 | import org.junit.Test 11 | import kotlin.test.assertEquals 12 | 13 | class GetFaresByIdUseCaseTest { 14 | 15 | private val ticketsRepository: TicketsRepository = mockk(relaxed = true) 16 | 17 | private val getFaresByIdUseCase = GetFaresByIdUseCase(ticketsRepository) 18 | 19 | @Test 20 | fun `On invoke should return correct result`() = runTest { 21 | val ryderId = faker.idNumber().valid() 22 | 23 | val fares: List = listOf( 24 | mockk(), 25 | mockk(), 26 | ) 27 | 28 | coEvery { ticketsRepository.getFares(ryderId) } returns fares 29 | 30 | val result = getFaresByIdUseCase(ryderId) 31 | 32 | assertEquals(fares, result) 33 | 34 | coVerify { ticketsRepository.getFares(ryderId) } 35 | } 36 | } -------------------------------------------------------------------------------- /app/src/main/java/com/moove/app/feature/deeplink/data/remote/FirebaseDynamicLinkDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.moove.app.feature.deeplink.data.remote 2 | 3 | import android.net.Uri 4 | import com.google.firebase.dynamiclinks.FirebaseDynamicLinks 5 | import com.moove.core.kotlin.text.matchesPattern 6 | import com.moove.shared.feature.deeplink.domain.exceptions.DynamicLinkParseException 7 | import kotlinx.coroutines.CoroutineDispatcher 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.tasks.await 10 | import kotlinx.coroutines.withContext 11 | 12 | class FirebaseDynamicLinkDataSource( 13 | private val host: String, 14 | private val firebaseDynamicLinks: FirebaseDynamicLinks, 15 | private val backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO, 16 | ) { 17 | 18 | suspend fun parseLink(uri: String): String? = withContext(backgroundDispatcher) { 19 | if (uri.matchesPattern(host).not()) return@withContext null 20 | try { 21 | firebaseDynamicLinks.getDynamicLink(Uri.parse(uri)).await().link?.toString() 22 | } catch (e: Exception) { 23 | throw DynamicLinkParseException(cause = e) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/java/com/moove/app/MooveApp.kt: -------------------------------------------------------------------------------- 1 | package com.moove.app 2 | 3 | import android.app.Application 4 | import com.moove.app.di.coroutineModule 5 | import com.moove.app.di.deepLinkModule 6 | import com.moove.app.di.exceptionsModule 7 | import com.moove.app.di.mainModule 8 | import com.moove.app.di.netModule 9 | import com.moove.tickets.di.ticketsModule 10 | import org.koin.android.ext.koin.androidContext 11 | import org.koin.android.ext.koin.androidLogger 12 | import org.koin.core.context.startKoin 13 | import org.koin.core.logger.Level 14 | 15 | open class MooveApp : Application() { 16 | 17 | override fun onCreate() { 18 | super.onCreate() 19 | setupDependencyInjection() 20 | } 21 | 22 | private fun setupDependencyInjection() { 23 | startKoin { 24 | androidContext(this@MooveApp) 25 | // androidLogger(if (BuildConfig.DEBUG) Level.INFO else Level.NONE) 26 | modules( 27 | mainModule, 28 | coroutineModule, 29 | exceptionsModule, 30 | ticketsModule, 31 | netModule, 32 | deepLinkModule, 33 | ) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/java/com/moove/app/di/MainModule.kt: -------------------------------------------------------------------------------- 1 | package com.moove.app.di 2 | 3 | import com.moove.app.feature.home.HomeNavigator 4 | import com.moove.app.feature.home.HomeViewModel 5 | import com.moove.app.main.MainActivityViewModel 6 | import com.moove.app.main.MainNavigator 7 | import com.moove.app.navigation.AppNavigator 8 | import com.moove.shared.navigation.GlobalAppNavigator 9 | import com.moove.shared.navigation.ScreenNavigator 10 | import com.moove.shared.navigation.TicketsNavigator 11 | import org.koin.androidx.viewmodel.dsl.viewModelOf 12 | import org.koin.core.module.dsl.factoryOf 13 | import org.koin.dsl.binds 14 | import org.koin.dsl.module 15 | 16 | val mainModule = module { 17 | 18 | factory { 19 | AppNavigator(navController = get()) 20 | } binds arrayOf( 21 | ScreenNavigator::class, 22 | GlobalAppNavigator::class, 23 | TicketsNavigator::class, 24 | ) 25 | 26 | factory { 27 | HomeNavigator( 28 | navController = get(), 29 | screenNavigator = get(), 30 | ) 31 | } 32 | viewModelOf(::HomeViewModel) 33 | 34 | viewModelOf(::MainActivityViewModel) 35 | factoryOf(::MainNavigator) 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/java/com/moove/app/feature/home/HomeFragment.kt: -------------------------------------------------------------------------------- 1 | package com.moove.app.feature.home 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.fragment.app.Fragment 6 | import androidx.lifecycle.lifecycleScope 7 | import androidx.navigation.fragment.findNavController 8 | import com.moove.shared.R 9 | import com.moove.shared.databinding.ComposeFragmentBinding 10 | import com.moove.shared.presentation.compose.platform.setAppComposeContent 11 | import com.moove.shared.presentation.fragment.delegate.viewBinding 12 | import org.koin.android.ext.android.inject 13 | import org.koin.core.parameter.parametersOf 14 | 15 | class HomeFragment : Fragment(R.layout.compose_fragment) { 16 | 17 | private val viewBinding by viewBinding(ComposeFragmentBinding::bind) 18 | private val navigator: HomeNavigator by inject { 19 | parametersOf(findNavController(), lifecycleScope, requireContext()) 20 | } 21 | 22 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 23 | super.onViewCreated(view, savedInstanceState) 24 | setAppComposeContent(viewBinding.compose) { 25 | HomeRoute(navigator = navigator) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /design-system/src/main/java/com/moove/design_system/compose/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.moove.design_system.compose 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.CompositionLocalProvider 5 | import androidx.compose.runtime.staticCompositionLocalOf 6 | import com.moove.design_system.compose.moove.MooveTheme 7 | 8 | /** Shared application theme */ 9 | @Composable 10 | fun AppTheme(theme: Theme = Theme.MOOVE, content: @Composable () -> Unit) { 11 | CompositionLocalProvider( 12 | LocalTheme provides theme, 13 | ) { 14 | when (AppTheme.theme) { 15 | Theme.MOOVE -> MooveTheme(content) 16 | } 17 | } 18 | } 19 | 20 | enum class Theme { 21 | MOOVE 22 | } 23 | 24 | val LocalTheme = staticCompositionLocalOf { Theme.MOOVE } 25 | 26 | object AppTheme { 27 | val theme: Theme 28 | @Composable 29 | get() = LocalTheme.current 30 | val typography: AppTypography 31 | @Composable 32 | get() = LocalAppTypography.current 33 | val colors: AppColors 34 | @Composable 35 | get() = LocalAppColors.current 36 | val shapes: AppShapes 37 | @Composable 38 | get() = LocalAppShapes.current 39 | } 40 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/list/RyderListFragment.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.list 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.fragment.app.Fragment 6 | import androidx.lifecycle.lifecycleScope 7 | import androidx.navigation.fragment.findNavController 8 | import com.moove.shared.R 9 | import com.moove.shared.databinding.ComposeFragmentBinding 10 | import com.moove.shared.presentation.compose.platform.setAppComposeContent 11 | import com.moove.shared.presentation.fragment.delegate.viewBinding 12 | import org.koin.android.ext.android.inject 13 | import org.koin.core.parameter.parametersOf 14 | 15 | class RyderListFragment : Fragment(R.layout.compose_fragment) { 16 | 17 | private val viewBinding by viewBinding(ComposeFragmentBinding::bind) 18 | private val navigator: RyderListNavigator by inject { 19 | parametersOf(findNavController(), lifecycleScope, requireContext()) 20 | } 21 | 22 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 23 | super.onViewCreated(view, savedInstanceState) 24 | setAppComposeContent(viewBinding.compose) { 25 | RyderListRoute(navigator = navigator) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/moove/app/feature/home/HomeRoute.kt: -------------------------------------------------------------------------------- 1 | package com.moove.app.feature.home 2 | 3 | import androidx.compose.material.ScaffoldState 4 | import androidx.compose.material.rememberScaffoldState 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.getValue 7 | import com.moove.shared.presentation.viewmodel.composableEffect 8 | import com.moove.shared.presentation.viewmodel.composableState 9 | import org.koin.androidx.compose.koinViewModel 10 | 11 | @Composable 12 | fun HomeRoute( 13 | navigator: HomeNavigator, 14 | viewModel: HomeViewModel = koinViewModel(), 15 | scaffoldState: ScaffoldState = rememberScaffoldState(), 16 | ) { 17 | val state by viewModel.composableState() 18 | 19 | HomeScreen( 20 | uiState = state, 21 | scaffoldState = scaffoldState, 22 | onRyderClick = viewModel::onRyderClick, 23 | ) 24 | 25 | viewModel.RenderEffect(navigator = navigator) 26 | } 27 | 28 | @Composable 29 | private fun HomeViewModel.RenderEffect( 30 | navigator: HomeNavigator, 31 | ) { 32 | composableEffect { effect -> 33 | when (effect) { 34 | is HomeEffect.GoToRyderList -> { 35 | navigator.goRyderList() 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/java/com/moove/app/feature/deeplink/presentation/DeepLinkAppNavigator.kt: -------------------------------------------------------------------------------- 1 | package com.moove.app.feature.deeplink.presentation 2 | 3 | import com.moove.app.feature.deeplink.domain.AppDeepLink 4 | import com.moove.shared.feature.deeplink.domain.DeepLink 5 | import com.moove.shared.feature.deeplink.presentation.DeepLinkNavigator 6 | import com.moove.shared.navigation.GlobalAppNavigator 7 | import com.moove.shared.navigation.TicketsNavigator 8 | 9 | class DeepLinkAppNavigator( 10 | private val globalAppNavigator: GlobalAppNavigator, 11 | private val ticketsNavigator: TicketsNavigator, 12 | ) : DeepLinkNavigator { 13 | override fun navigateTo(link: DeepLink) { 14 | when (link) { 15 | is AppDeepLink.FareList -> ticketsNavigator.goFares(link.ryderId) 16 | is AppDeepLink.Confirmation -> { 17 | ticketsNavigator.goFares(link.ryderId) 18 | ticketsNavigator.goToConfirmation( 19 | ryderId = link.ryderId, 20 | fareDescription = link.fare.description, 21 | farePrice = link.fare.price 22 | ) 23 | } 24 | 25 | is AppDeepLink.Home, AppDeepLink.Unknown -> globalAppNavigator.goHome() 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 21 | 22 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/data/TicketsDataRepository.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.data 2 | 3 | import com.moove.tickets.data.local.TicketsLocalDataSource 4 | import com.moove.tickets.data.local.dto.asDomain 5 | import com.moove.tickets.domain.TicketsRepository 6 | import com.moove.tickets.domain.model.Fare 7 | import com.moove.tickets.domain.model.Ryder 8 | import kotlinx.coroutines.CoroutineDispatcher 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.withContext 11 | 12 | class TicketsDataRepository( 13 | private val ticketsLocalDataSource: TicketsLocalDataSource, 14 | private val backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO, 15 | ) : TicketsRepository { 16 | 17 | override suspend fun getRyders(): List = withContext(backgroundDispatcher) { 18 | val data = ticketsLocalDataSource.getData() 19 | data.map { it.value.asDomain(it.key) } 20 | } 21 | 22 | override suspend fun getFares(ryderId: String): List = withContext(backgroundDispatcher) { 23 | val data = ticketsLocalDataSource.getData() 24 | data[ryderId]?.fares?.asDomain() ?: emptyList() 25 | } 26 | 27 | override suspend fun buyTicket(ryderId: String, fare: Fare, totalCount: Int) { 28 | // Implement request to the server 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/fare/FareListFragment.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.fare 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.fragment.app.Fragment 6 | import androidx.lifecycle.lifecycleScope 7 | import androidx.navigation.fragment.findNavController 8 | import androidx.navigation.fragment.navArgs 9 | import com.moove.shared.R 10 | import com.moove.shared.databinding.ComposeFragmentBinding 11 | import com.moove.shared.presentation.compose.platform.setAppComposeContent 12 | import com.moove.shared.presentation.fragment.delegate.viewBinding 13 | import org.koin.android.ext.android.inject 14 | import org.koin.core.parameter.parametersOf 15 | 16 | class FareListFragment : Fragment(R.layout.compose_fragment) { 17 | 18 | private val arguments: FareListFragmentArgs by navArgs() 19 | private val viewBinding by viewBinding(ComposeFragmentBinding::bind) 20 | private val navigator: FareListNavigator by inject { 21 | parametersOf(findNavController(), lifecycleScope, requireContext()) 22 | } 23 | 24 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 25 | super.onViewCreated(view, savedInstanceState) 26 | setAppComposeContent(viewBinding.compose) { 27 | FareListRoute(navigator = navigator, ryderId = arguments.ryderId) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/confirmation/ConfirmationFragment.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.confirmation 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.fragment.app.Fragment 6 | import androidx.lifecycle.lifecycleScope 7 | import androidx.navigation.fragment.findNavController 8 | import androidx.navigation.fragment.navArgs 9 | import com.moove.shared.R 10 | import com.moove.shared.databinding.ComposeFragmentBinding 11 | import com.moove.shared.presentation.compose.platform.setAppComposeContent 12 | import com.moove.shared.presentation.fragment.delegate.viewBinding 13 | import org.koin.android.ext.android.inject 14 | import org.koin.core.parameter.parametersOf 15 | 16 | class ConfirmationFragment : Fragment(R.layout.compose_fragment) { 17 | 18 | private val arguments: ConfirmationFragmentArgs by navArgs() 19 | private val viewBinding by viewBinding(ComposeFragmentBinding::bind) 20 | private val navigator: ConfirmationNavigator by inject { 21 | parametersOf(findNavController(), lifecycleScope, requireContext()) 22 | } 23 | 24 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 25 | super.onViewCreated(view, savedInstanceState) 26 | setAppComposeContent(viewBinding.compose) { 27 | FareListRoute( 28 | navigator = navigator, 29 | ryderId = arguments.ryderId, 30 | fare = arguments.fare 31 | ) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /shared/src/main/java/com/moove/shared/presentation/viewmodel/OrbitExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.moove.shared.presentation.viewmodel 2 | 3 | import android.annotation.SuppressLint 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.State 6 | import androidx.compose.runtime.collectAsState 7 | import androidx.lifecycle.Lifecycle 8 | import androidx.lifecycle.flowWithLifecycle 9 | import org.orbitmvi.orbit.ContainerHost 10 | import org.orbitmvi.orbit.compose.collectAsState 11 | import org.orbitmvi.orbit.compose.collectSideEffect 12 | 13 | val ContainerHost.currentState: STATE 14 | get() = container.stateFlow.value 15 | 16 | /** 17 | * Collect [ContainerHost.container]'s state via [collectAsState] but bounded with the [flowWithLifecycle] 18 | */ 19 | @Composable 20 | fun ContainerHost.composableState( 21 | minActiveState: Lifecycle.State = Lifecycle.State.STARTED 22 | ): State { 23 | return this.collectAsState(minActiveState) 24 | } 25 | 26 | /** 27 | * Collect [ContainerHost.container]'s effect bounded with the [flowWithLifecycle] 28 | */ 29 | @SuppressLint("ComposableNaming") 30 | @Composable 31 | fun ContainerHost.composableEffect( 32 | minActiveState: Lifecycle.State = Lifecycle.State.STARTED, 33 | action: (suspend (sideEffect: SIDE_EFFECT) -> Unit), 34 | ) { 35 | this.collectSideEffect(sideEffect = action, lifecycleState = minActiveState) 36 | } 37 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/list/RyderListRoute.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.list 2 | 3 | import androidx.compose.material.ScaffoldState 4 | import androidx.compose.material.rememberScaffoldState 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.ui.platform.LocalContext 8 | import com.moove.shared.presentation.compose.component.showGenericError 9 | import com.moove.shared.presentation.viewmodel.composableEffect 10 | import com.moove.shared.presentation.viewmodel.composableState 11 | import org.koin.androidx.compose.getViewModel 12 | 13 | @Composable 14 | fun RyderListRoute( 15 | navigator: RyderListNavigator, 16 | viewModel: RyderListViewModel = getViewModel(), 17 | scaffoldState: ScaffoldState = rememberScaffoldState(), 18 | ) { 19 | val state by viewModel.composableState() 20 | 21 | RyderListScreen( 22 | uiState = state, 23 | scaffoldState = scaffoldState, 24 | onRyderClick = viewModel::onRyderClick, 25 | ) 26 | 27 | viewModel.RenderEffect(scaffoldState = scaffoldState, navigator = navigator) 28 | } 29 | 30 | @Composable 31 | private fun RyderListViewModel.RenderEffect( 32 | scaffoldState: ScaffoldState, 33 | navigator: RyderListNavigator, 34 | ) { 35 | val context = LocalContext.current 36 | composableEffect { effect -> 37 | when (effect) { 38 | is RyderListEffect.GoToFares -> { 39 | navigator.goFares(effect.id) 40 | } 41 | 42 | RyderListEffect.ShowGenericError -> scaffoldState.showGenericError(context) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/src/main/java/com/moove/app/navigation/AppNavigator.kt: -------------------------------------------------------------------------------- 1 | package com.moove.app.navigation 2 | 3 | import androidx.navigation.NavController 4 | import com.moove.MobileNavigationDirections 5 | import com.moove.app.feature.home.HomeFragmentDirections 6 | import com.moove.shared.navigation.GlobalAppNavigator 7 | import com.moove.shared.navigation.navigateSafely 8 | import com.moove.tickets.presentation.fare.FareListFragmentDirections 9 | import com.moove.tickets.presentation.fare.model.FareModel 10 | import com.moove.tickets.presentation.list.RyderListFragmentDirections 11 | 12 | class AppNavigator( 13 | private val navController: NavController, 14 | ) : GlobalAppNavigator { 15 | 16 | override fun goFares(ryderId: String) { 17 | navController.navigateSafely(HomeFragmentDirections.actionHomeFragmentToTicketsFlow()) 18 | navController.navigateSafely( 19 | RyderListFragmentDirections.actionRydersFragmentToFareListFragment(ryderId = ryderId) 20 | ) 21 | } 22 | 23 | override fun goToConfirmation(ryderId: String, fareDescription: String, farePrice: Float) { 24 | navController.navigateSafely( 25 | FareListFragmentDirections.actionFareListFragmentToConfirmationFragment( 26 | ryderId = ryderId, 27 | fare = FareModel( 28 | description = fareDescription, 29 | price = farePrice 30 | ) 31 | ) 32 | ) 33 | } 34 | 35 | override fun goBack() { 36 | navController.navigateUp() 37 | } 38 | 39 | override fun goHome() { 40 | navController.navigateSafely( 41 | MobileNavigationDirections.actionGlobalGoHome() 42 | ) 43 | } 44 | } -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/fare/component/FareItem.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.fare.component 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.tooling.preview.Preview 11 | import com.moove.design_system.compose.AppTheme 12 | import com.moove.design_system.compose.Spacing 13 | import com.moove.tickets.presentation.fare.model.FareModel 14 | import com.moove.tickets.presentation.fare.model.fakeFareModels 15 | 16 | @Composable 17 | fun FareItem( 18 | modifier: Modifier = Modifier, 19 | fare: FareModel, 20 | onClick: (FareModel) -> Unit 21 | ) { 22 | Column( 23 | modifier = modifier 24 | .fillMaxWidth() 25 | .clickable(onClick = { onClick(fare) }) 26 | .padding(horizontal = Spacing.S, vertical = Spacing.S) 27 | ) { 28 | Text( 29 | text = fare.description, 30 | style = AppTheme.typography.material.h1, 31 | maxLines = 1, 32 | ) 33 | Text( 34 | text = "$".plus(String.format("%.2f", fare.price)), 35 | style = AppTheme.typography.material.subtitle1, 36 | maxLines = 1, 37 | ) 38 | } 39 | } 40 | 41 | @Preview(name = "Fare Item", showBackground = true) 42 | @Composable 43 | private fun PreviewFareItem() { 44 | AppTheme { 45 | FareItem( 46 | fare = fakeFareModels.first(), 47 | onClick = {} 48 | ) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/list/component/RyderItem.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.list.component 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.tooling.preview.Preview 11 | import com.moove.design_system.compose.AppTheme 12 | import com.moove.design_system.compose.Spacing 13 | import com.moove.tickets.presentation.list.model.RyderModel 14 | import com.moove.tickets.presentation.list.model.fakeRyderModels 15 | 16 | @Composable 17 | fun RyderItem( 18 | modifier: Modifier = Modifier, 19 | ryder: RyderModel, 20 | onClick: (RyderModel) -> Unit 21 | ) { 22 | Column( 23 | modifier = modifier 24 | .fillMaxWidth() 25 | .clickable(onClick = { onClick(ryder) }) 26 | .padding(horizontal = Spacing.S, vertical = Spacing.S) 27 | ) { 28 | Text( 29 | text = ryder.id, 30 | style = AppTheme.typography.material.h1, 31 | maxLines = 1, 32 | ) 33 | ryder.subtext?.let { 34 | Text( 35 | text = ryder.subtext, 36 | style = AppTheme.typography.material.subtitle1, 37 | maxLines = 1, 38 | ) 39 | } 40 | } 41 | } 42 | 43 | @Preview(name = "Ryder Item", showBackground = true) 44 | @Composable 45 | private fun PreviewRyderItem() { 46 | AppTheme { 47 | RyderItem( 48 | ryder = fakeRyderModels[1], 49 | onClick = {} 50 | ) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /android-docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Moove 2 | site_description: "Moove - Documentation" 3 | site_author: Mkhytar Mkhoian. 4 | site_dir: html 5 | 6 | nav: 7 | - 'Getting Started': index.md 8 | - 'API': api/index.html 9 | 10 | theme: 11 | name: material 12 | features: 13 | - navigation.sections 14 | - navigation.top 15 | - toc.integrate 16 | - search.suggest 17 | - search.highlight 18 | - content.tabs.link 19 | - content.code.annotation 20 | - content.code.copy 21 | language: en 22 | palette: 23 | # Palette toggle for light mode 24 | - scheme: default 25 | media: "(prefers-color-scheme: light)" 26 | primary: custom 27 | accent: 'dark-blue' 28 | toggle: 29 | icon: material/brightness-7 30 | name: Switch to dark mode 31 | 32 | # Palette toggle for dark mode 33 | - scheme: slate 34 | media: "(prefers-color-scheme: dark)" 35 | primary: custom 36 | accent: 'dark-blue' 37 | toggle: 38 | icon: material/brightness-4 39 | name: Switch to light mode 40 | 41 | plugins: 42 | - search: 43 | lang: en 44 | - offline: 45 | enabled: true 46 | 47 | markdown_extensions: 48 | - pymdownx.inlinehilite 49 | - pymdownx.highlight: 50 | use_pygments: true 51 | anchor_linenums: true 52 | pygments_lang_class: true 53 | auto_title: true 54 | - pymdownx.superfences 55 | - codehilite: 56 | guess_lang: false 57 | - pymdownx.tabbed: 58 | alternate_style: true 59 | - pymdownx.critic 60 | - pymdownx.critic 61 | - admonition 62 | - pymdownx.details 63 | - toc: 64 | permalink: true 65 | - md_in_html 66 | - attr_list 67 | - pymdownx.emoji: 68 | emoji_index: !!python/name:materialx.emoji.twemoji 69 | emoji_generator: !!python/name:materialx.emoji.to_svg -------------------------------------------------------------------------------- /app/src/main/java/com/moove/app/di/DeepLinkModule.kt: -------------------------------------------------------------------------------- 1 | package com.moove.app.di 2 | 3 | import com.google.firebase.Firebase 4 | import com.google.firebase.dynamiclinks.dynamicLinks 5 | import com.moove.BuildConfig 6 | import com.moove.app.feature.deeplink.data.DeeplinkDataRepository 7 | import com.moove.app.feature.deeplink.data.DynamicLinkDataRepository 8 | import com.moove.app.feature.deeplink.data.local.AppDeepLinkLocalDataSource 9 | import com.moove.app.feature.deeplink.data.remote.FirebaseDynamicLinkDataSource 10 | import com.moove.app.feature.deeplink.presentation.DeepLinkAppNavigator 11 | import com.moove.shared.feature.deeplink.domain.DeeplinkRepository 12 | import com.moove.shared.feature.deeplink.domain.DynamicLinkRepository 13 | import com.moove.shared.feature.deeplink.domain.GetDeeplinkUseCase 14 | import com.moove.shared.feature.deeplink.domain.GetDynamicLinkUseCase 15 | import com.moove.shared.feature.deeplink.presentation.DeepLinkNavigator 16 | import org.koin.dsl.module 17 | 18 | val deepLinkModule = module { 19 | 20 | factory { DeeplinkDataRepository(get()) } 21 | factory { DynamicLinkDataRepository(get()) } 22 | factory { 23 | DeepLinkAppNavigator( 24 | ticketsNavigator = get(), 25 | globalAppNavigator = get(), 26 | ) 27 | } 28 | factory { 29 | FirebaseDynamicLinkDataSource( 30 | host = BuildConfig.FIREBASE_DYNAMIC_LINK_HOST, 31 | // firebaseDynamicLinks = Firebase.dynamicLinks 32 | ) 33 | } 34 | factory { AppDeepLinkLocalDataSource() } 35 | factory { 36 | GetDeeplinkUseCase( 37 | deeplinkRepository = get(), 38 | getDynamicLinkUseCase = get(), 39 | ) 40 | } 41 | factory { GetDynamicLinkUseCase(get()) } 42 | } 43 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 16 | 19 | 22 | 23 | 24 | 25 | 31 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/fare/FareListRoute.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.fare 2 | 3 | import androidx.compose.material.ScaffoldState 4 | import androidx.compose.material.rememberScaffoldState 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.ui.platform.LocalContext 8 | import com.moove.shared.presentation.compose.component.showGenericError 9 | import com.moove.shared.presentation.viewmodel.composableEffect 10 | import com.moove.shared.presentation.viewmodel.composableState 11 | import org.koin.androidx.compose.getViewModel 12 | import org.koin.androidx.compose.koinViewModel 13 | import org.koin.core.parameter.parametersOf 14 | 15 | @Composable 16 | fun FareListRoute( 17 | navigator: FareListNavigator, 18 | ryderId: String, 19 | viewModel: FareListViewModel = koinViewModel { parametersOf(ryderId) }, 20 | scaffoldState: ScaffoldState = rememberScaffoldState(), 21 | ) { 22 | val state by viewModel.composableState() 23 | 24 | FareListScreen( 25 | uiState = state, 26 | scaffoldState = scaffoldState, 27 | onFareClick = viewModel::onFareClick, 28 | ) 29 | 30 | viewModel.RenderEffect(scaffoldState = scaffoldState, navigator = navigator) 31 | } 32 | 33 | @Composable 34 | private fun FareListViewModel.RenderEffect( 35 | scaffoldState: ScaffoldState, 36 | navigator: FareListNavigator, 37 | ) { 38 | val context = LocalContext.current 39 | composableEffect { effect -> 40 | when (effect) { 41 | is FareListEffect.GoToConfirmation -> { 42 | navigator.goToConfirmation(ryderId = effect.ryderId, fare = effect.fare) 43 | } 44 | 45 | FareListEffect.ShowGenericError -> scaffoldState.showGenericError(context) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/src/main/java/com/moove/app/main/MainActivityViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.moove.app.main 2 | 3 | import android.content.Intent 4 | import androidx.lifecycle.ViewModel 5 | import com.moove.core.exception.ExceptionHandler 6 | import com.moove.core.exception.asCoroutineExceptionHandler 7 | import com.moove.shared.feature.deeplink.domain.GetDeeplinkUseCase 8 | import com.moove.shared.presentation.viewmodel.executeUseCase 9 | import org.orbitmvi.orbit.Container 10 | import org.orbitmvi.orbit.ContainerHost 11 | import org.orbitmvi.orbit.syntax.simple.intent 12 | import org.orbitmvi.orbit.syntax.simple.postSideEffect 13 | import org.orbitmvi.orbit.viewmodel.container 14 | 15 | class MainActivityViewModel( 16 | exceptionHandler: ExceptionHandler, 17 | private val getDeeplinkUseCase: GetDeeplinkUseCase, 18 | ) : ViewModel(), ContainerHost { 19 | 20 | override val container: Container = container( 21 | initialState = MainActivityState(), 22 | buildSettings = { 23 | this.exceptionHandler = exceptionHandler.asCoroutineExceptionHandler() 24 | }, 25 | ) 26 | 27 | fun handleIntent(intent: Intent?) = intent { 28 | if (intent == null) return@intent 29 | 30 | val uri = getDeepLinkFromIntent(intent) 31 | if (uri.isNullOrEmpty()) return@intent 32 | 33 | executeUseCase { getDeeplinkUseCase(uri) } 34 | .onSuccess { deeplink -> postSideEffect(MainActivityEffect.NavigateDeepLink(deeplink)) } 35 | .onFailure { postSideEffect(MainActivityEffect.ShowGenericError) } 36 | } 37 | 38 | private fun getDeepLinkFromIntent(intent: Intent): String? { 39 | return intent.takeIf { 40 | Intent.ACTION_VIEW == it.action || Intent.ACTION_MAIN == it.action 41 | }?.dataString 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Moove Android App 2 | 3 | ## 🏛 Architecture overview 4 | 5 | ### Modules 🐘 6 | 7 | The app consists of several gradle modules: 8 | - `app` – the main module which setups other modules together, along with the top-level navigation and DI. 9 | 10 | It also contains legacy features implementation that would be extracted in the future; 11 | 12 | - `tickets/featureX` - concrete standalone feature implementation, e.g. search, bookings, etc.; 13 | - `core` - project-agnostic language tools & extensions shared with other modules; 14 | - `shared` - basic components and business rules to re-use across the app and feature modules: 15 | - `features` - shared features which could be reused in the app directly 16 | as the business rules (use cases): _GetSessionUseCase_ or _SubscribeToAccountUseCase_, etc; 17 | - `presentation` - basic presentation-level reusable components; 18 | - `design-system` - common resources: styles, themes, drawables, strings; 19 | 20 | ### Components ❖ 21 | 22 | Check all the [dependencies](../gradle/libs.versions.toml) 23 | 24 | #### Core/Language 25 | - the app is written in `Kotlin`; 26 | - `Coroutines` is the only lib used for async operations; 27 | - Java17+ APIs are available via [desugaring](https://developer.android.com/studio/write/java8-support-table); 28 | 29 | #### Domain/Data 30 | Per Context (in terms of DDD) _UseCase->Repository->DataSource_ pattern is implemented with non-base own interfaces. 31 | 32 | #### Presentation 33 | The app is built mainly with `AndroidX` components: Navigation, Fragment and ViewModel. 34 | The ViewModel itself is often set up in a MVI manner with the help of [Orbit-MVI](https://github.com/orbit-mvi/orbit-mvi) 35 | 36 | #### DI 37 | The DI is built with [Koin](https://insert-koin.io/) and fully belongs to the _App_ module. 38 | 39 | #### Testing 40 | We do unit test only for new: JUnit4 is used with the help of `mockk`. -------------------------------------------------------------------------------- /.github/workflows/code_review.yml: -------------------------------------------------------------------------------- 1 | name: Code review 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.ref }} 5 | cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} 6 | 7 | on: 8 | workflow_dispatch: 9 | pull_request: 10 | types: [opened, ready_for_review, synchronize, labeled] 11 | branches: 12 | - master 13 | 14 | jobs: 15 | SetUp: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - id: setVariables 19 | name: Set variables 20 | run: | 21 | isFromMaster=${{ github.ref == 'refs/heads/master' }} 22 | isManual=${{ github.event_name == 'workflow_dispatch' }} 23 | 24 | if [ ${{ github.event_name }} == workflow_dispatch ] || [ ${{ github.event_name }} == push ] || ([ ${{ github.event_name }} == pull_request ] && [ ${{ github.event.pull_request.draft }} == false ]); then 25 | exit 0 26 | else 27 | exit 1 28 | fi 29 | 30 | BuildAndRunTests: 31 | needs: SetUp 32 | runs-on: ubuntu-latest 33 | timeout-minutes: 30 34 | steps: 35 | - uses: actions/checkout@v4 36 | - name: Job set up 37 | uses: ./.github/actions/job-set-up 38 | 39 | - name: Build 40 | run: ./gradlew assembleDebug 41 | 42 | - name: Run unit tests 43 | run: ./gradlew clean testDebugUnitTest 44 | 45 | AllowMerge: 46 | if: always() 47 | runs-on: ubuntu-latest 48 | needs: [ SetUp, BuildAndRunTests ] 49 | steps: 50 | - run: | 51 | if [ ${{ github.event_name }} == pull_request ] && [ ${{ join(github.event.pull_request.labels.*.name) == '' }} == true ]; then 52 | exit 1 53 | elif [ ${{ (contains(needs.Build.result, 'failure')) }} == true ] || [ ${{ (contains(needs.BuildAndRunTests.result, 'failure')) }} ]; then 54 | exit 1 55 | else 56 | exit 0 57 | fi -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.ref }} 5 | cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} 6 | 7 | on: 8 | workflow_dispatch: 9 | push: 10 | branches: 11 | - master 12 | 13 | permissions: 14 | contents: read 15 | packages: write 16 | pages: write 17 | id-token: write 18 | 19 | jobs: 20 | Deploy: 21 | environment: 22 | name: github-pages 23 | url: ${{ steps.deployment.outputs.page_url }} 24 | 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | 31 | - name: Job set up 32 | uses: ./.github/actions/job-set-up 33 | 34 | - uses: actions/setup-python@v5 35 | 36 | - name: Generate Docs 37 | run: ./.github/workflows/generate_docs.sh 38 | 39 | - name: Upload documentation zip archive 40 | uses: actions/upload-artifact@v4 41 | with: 42 | name: moove-android-docs 43 | path: android-docs/html 44 | 45 | - name: Setup Pages 46 | uses: actions/configure-pages@v4 47 | - name: Upload artifact 48 | uses: actions/upload-pages-artifact@v3 49 | with: 50 | path: './android-docs/html' 51 | - name: Deploy to GitHub Pages 52 | id: deployment 53 | uses: actions/deploy-pages@v4 54 | 55 | - name: Build Docker image 56 | run: docker build . --file android-docs/Dockerfile --tag moove-android-docs:lattest 57 | 58 | - name: Tag image 59 | run: docker tag moove-android-docs:lattest ghcr.io/mkhytarmkhoian/moove/android-docs:latest 60 | 61 | - name: Log in to registry 62 | run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u $ --password-stdin 63 | 64 | - name: Push image 65 | run: docker push ghcr.io/mkhytarmkhoian/moove/android-docs:latest -------------------------------------------------------------------------------- /android-docs/docs/index.md: -------------------------------------------------------------------------------- 1 | # Moove Android App 2 | 3 | ## 🏛 Architecture overview 4 | 5 | ### Modules 🐘 6 | 7 | The app consists of several gradle modules: 8 | - `app` – the main module which setups other modules together, along with the top-level navigation and DI. 9 | 10 | It also contains legacy features implementation that would be extracted in the future; 11 | 12 | - `tickets/featureX` - concrete standalone feature implementation, e.g. search, bookings, etc.; 13 | - `core` - project-agnostic language tools & extensions shared with other modules; 14 | - `shared` - basic components and business rules to re-use across the app and feature modules: 15 | - `features` - shared features which could be reused in the app directly 16 | as the business rules (use cases): _GetSessionUseCase_ or _SubscribeToAccountUseCase_, etc; 17 | - `presentation` - basic presentation-level reusable components; 18 | - `design-system` - common resources: styles, themes, drawables, strings; 19 | 20 | ### Components ❖ 21 | 22 | Check all the [dependencies](../gradle/libs.versions.toml) 23 | 24 | #### Core/Language 25 | - the app is written in `Kotlin`; 26 | - `Coroutines` is the only lib used for async operations; 27 | - Java17+ APIs are available via [desugaring](https://developer.android.com/studio/write/java8-support-table); 28 | 29 | #### Domain/Data 30 | Per Context (in terms of DDD) _UseCase->Repository->DataSource_ pattern is implemented with non-base own interfaces. 31 | 32 | #### Presentation 33 | The app is built mainly with `AndroidX` components: Navigation, Fragment and ViewModel. 34 | The ViewModel itself is often set up in a MVI manner with the help of [Orbit-MVI](https://github.com/orbit-mvi/orbit-mvi) 35 | 36 | #### DI 37 | The DI is built with [Koin](https://insert-koin.io/) and fully belongs to the _App_ module. 38 | 39 | #### Testing 40 | We do unit test only for new: JUnit4 is used with the help of `mockk`. -------------------------------------------------------------------------------- /design-system/src/main/java/com/moove/design_system/compose/moove/Colors.kt: -------------------------------------------------------------------------------- 1 | package com.moove.design_system.compose.moove 2 | 3 | import androidx.compose.material.lightColors 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.graphics.Color 6 | import com.moove.design_system.compose.AppColors 7 | 8 | internal val mooveColors: AppColors 9 | @Composable 10 | get() { 11 | val material = lightColors( 12 | primary = MooveColors.FloytGreen, 13 | primaryVariant = MooveColors.Mint, 14 | onPrimary = MooveColors.White, 15 | secondary = MooveColors.FloytGreen, 16 | onSecondary = MooveColors.White, 17 | surface = MooveColors.White, 18 | onSurface = MooveColors.Text, 19 | background = MooveColors.White, 20 | onBackground = MooveColors.Text, 21 | error = MooveColors.AccentRed, 22 | onError = MooveColors.White, 23 | ) 24 | return AppColors( 25 | material = material, 26 | neutral = mooveNeutralColors, 27 | buttons = mooveButtonsColors, 28 | ) 29 | } 30 | 31 | internal object MooveColors { 32 | val FloytGreen = Color(0xFF00977D) 33 | val Mint = Color(0xFF6EC2B7) 34 | val Mint30 = Color(0xFFD4EDE9) 35 | val Rose = Color(0xFFE4A5A3) 36 | val Orange = Color(0xFFFF4B19) 37 | val AccentGreen = Color(0xFF3C9307) 38 | val AccentYellow = Color(0xFFFFA800) 39 | val AccentRed = Color(0xFFD63F24) 40 | val AccentLightGreen = Color(0xFF75B21B) 41 | val AccentBlue = Color(0xFF3694BA) 42 | val Grey01 = Color(0xFF5A5A5A) 43 | val Grey02 = Color(0xFF909090) 44 | val Grey03 = Color(0xFFC8C8C8) 45 | val Grey04 = Color(0xFFE8E8E8) 46 | val Grey05 = Color(0xFFF3F3F3) 47 | val Black = Color(0xFF000000) 48 | val White = Color(0xFFFFFFFF) 49 | val Text = Color(0xFF222222) 50 | } 51 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/di/TicketsModule.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.di 2 | 3 | import com.moove.tickets.data.TicketsDataRepository 4 | import com.moove.tickets.data.local.TicketsLocalDataSource 5 | import com.moove.tickets.domain.TicketsRepository 6 | import com.moove.tickets.domain.use_cases.BuyTicketUseCase 7 | import com.moove.tickets.domain.use_cases.GetFaresByIdUseCase 8 | import com.moove.tickets.domain.use_cases.GetRydersUseCase 9 | import com.moove.tickets.presentation.confirmation.ConfirmationNavigator 10 | import com.moove.tickets.presentation.confirmation.ConfirmationViewModel 11 | import com.moove.tickets.presentation.fare.FareListNavigator 12 | import com.moove.tickets.presentation.fare.FareListViewModel 13 | import com.moove.tickets.presentation.list.RyderListNavigator 14 | import com.moove.tickets.presentation.list.RyderListViewModel 15 | import org.koin.androidx.viewmodel.dsl.viewModel 16 | import org.koin.dsl.module 17 | 18 | val ticketsModule = module { 19 | 20 | // region domain 21 | factory { GetRydersUseCase(get()) } 22 | factory { GetFaresByIdUseCase(get()) } 23 | factory { BuyTicketUseCase(get()) } 24 | // endregion 25 | 26 | // region data 27 | single { TicketsLocalDataSource(get()) } 28 | single { TicketsDataRepository(get()) } 29 | // endregion 30 | 31 | factory { RyderListNavigator(get(), get()) } 32 | viewModel { 33 | RyderListViewModel( 34 | exceptionHandler = get(), 35 | getRydersUseCase = get(), 36 | ) 37 | } 38 | 39 | factory { FareListNavigator(get(), get()) } 40 | viewModel { 41 | FareListViewModel( 42 | exceptionHandler = get(), 43 | ryderId = get(), 44 | getFaresByIdUseCase = get(), 45 | ) 46 | } 47 | 48 | factory { ConfirmationNavigator(get()) } 49 | viewModel { 50 | ConfirmationViewModel( 51 | exceptionHandler = get(), 52 | ryderId = get(), 53 | fare = get(), 54 | buyTicketUseCase = get(), 55 | ) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'kotlin-android' 4 | id 'kotlin-parcelize' 5 | id 'com.google.devtools.ksp' 6 | id 'androidx.navigation.safeargs.kotlin' 7 | } 8 | apply from: "${rootProject.projectDir}/gradle/base-android-config.gradle" 9 | apply from: "${rootProject.projectDir}/gradle/compose-android-config.gradle" 10 | 11 | android { 12 | namespace 'com.moove' 13 | 14 | defaultConfig { 15 | buildConfigField "String", "FIREBASE_DYNAMIC_LINK_HOST", "\"$firebase_dynamic_link_host\"" 16 | manifestPlaceholders = [ 17 | firebaseDynamicLinkHost: "\"$firebase_dynamic_link_host\"" 18 | ] 19 | } 20 | } 21 | 22 | dependencies { 23 | implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) 24 | 25 | // region Project 26 | implementation project(':shared') 27 | implementation project(':tickets') 28 | // endregion 29 | 30 | // region AndroidX 31 | implementation libs.androidx.appcompat 32 | implementation libs.androidx.core 33 | implementation libs.androidx.fragment 34 | implementation libs.bundles.androidx.navigation 35 | implementation libs.bundles.androidx.lifecycle 36 | implementation libs.bundles.androidx.compose 37 | // endregion 38 | 39 | // region Kotlin 40 | implementation libs.kotlin.coroutines.core 41 | implementation libs.kotlin.coroutines.android 42 | implementation libs.kotlin.reflect 43 | // endregion 44 | 45 | // region di 46 | implementation libs.bundles.koin 47 | // endregion 48 | 49 | // region presentation 50 | implementation libs.orbit.viewmodel 51 | testImplementation libs.orbit.test 52 | // endregion 53 | 54 | // region UI 55 | implementation libs.google.material 56 | implementation libs.androidx.splashscreen 57 | implementation libs.androidx.constraintlayout 58 | implementation libs.androidx.swiperefreshlayout 59 | // endregion 60 | 61 | // region Tests 62 | testImplementation libs.bundles.tests.unit 63 | testImplementation libs.bundles.tests.viewmodel 64 | androidTestImplementation libs.bundles.tests.android 65 | // endregion 66 | } -------------------------------------------------------------------------------- /tickets/src/main/res/navigation/tickets_navigation.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 13 | 14 | 17 | 20 | 21 | 22 | 23 | 28 | 29 | 32 | 33 | 36 | 39 | 40 | 43 | 44 | 45 | 46 | 51 | 52 | 55 | 56 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /shared/src/main/java/com/moove/shared/navigation/NavControllerExtension.kt: -------------------------------------------------------------------------------- 1 | package com.moove.shared.navigation 2 | 3 | import android.app.Activity 4 | import androidx.navigation.NavController 5 | import androidx.navigation.NavDirections 6 | import androidx.navigation.NavOptions 7 | import androidx.navigation.navOptions 8 | 9 | fun NavController.navigateSafely(direction: NavDirections, navOptions: NavOptions? = null) { 10 | currentDestination?.getAction(direction.actionId)?.run { 11 | val navOptionsWithDefaults = (navOptions ?: this.navOptions ?: navOptions {}).merge( 12 | navOptions { 13 | anim { 14 | enter = androidx.navigation.ui.R.anim.nav_default_enter_anim 15 | exit = androidx.navigation.ui.R.anim.nav_default_exit_anim 16 | popEnter = androidx.navigation.ui.R.anim.nav_default_pop_enter_anim 17 | popExit = androidx.navigation.ui.R.anim.nav_default_pop_exit_anim 18 | } 19 | } 20 | ) 21 | navigate(direction, navOptionsWithDefaults) 22 | } 23 | } 24 | 25 | /** Override only default (non-defined) parameters with new ones */ 26 | private fun NavOptions.merge(new: NavOptions) = navOptions { 27 | anim { 28 | enter = if (enterAnim == -1) new.enterAnim else enterAnim 29 | exit = if (exitAnim == -1) new.exitAnim else exitAnim 30 | popEnter = if (popEnterAnim == -1) new.popEnterAnim else popEnter 31 | popExit = if (popExitAnim == -1) new.popExitAnim else popExit 32 | } 33 | this.launchSingleTop = shouldLaunchSingleTop() || new.shouldLaunchSingleTop() 34 | restoreState = shouldRestoreState() || new.shouldRestoreState() 35 | if (this@merge.popUpToId != -1) popUpTo(this@merge.popUpToId) { 36 | this.inclusive = isPopUpToInclusive() 37 | this.saveState = shouldPopUpToSaveState() 38 | } else popUpTo(new.popUpToId) { 39 | this.inclusive = new.isPopUpToInclusive() 40 | this.saveState = new.shouldPopUpToSaveState() 41 | } 42 | } 43 | 44 | fun NavController.goBack(activity: Activity) { 45 | if (popBackStack()) { 46 | return 47 | } 48 | activity.finish() 49 | } 50 | -------------------------------------------------------------------------------- /app/src/main/java/com/moove/app/feature/home/HomeScreen.kt: -------------------------------------------------------------------------------- 1 | package com.moove.app.feature.home 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.layout.statusBarsPadding 8 | import androidx.compose.material.ScaffoldState 9 | import androidx.compose.material.Text 10 | import androidx.compose.material.rememberScaffoldState 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.tooling.preview.Preview 15 | import com.moove.design_system.compose.AppTheme 16 | import com.moove.design_system.compose.Button 17 | import com.moove.design_system.compose.Scaffold 18 | import com.moove.design_system.compose.Spacing 19 | 20 | @Composable 21 | fun HomeScreen( 22 | uiState: HomeState, 23 | scaffoldState: ScaffoldState = rememberScaffoldState(), 24 | onRyderClick: () -> Unit, 25 | ) { 26 | Scaffold( 27 | modifier = Modifier.statusBarsPadding(), 28 | scaffoldState = scaffoldState, 29 | content = { 30 | Box( 31 | modifier = Modifier.fillMaxSize(), 32 | contentAlignment = Alignment.Center 33 | ) { 34 | Button( 35 | modifier = Modifier 36 | .clickable(onClick = { onRyderClick() }) 37 | .padding(horizontal = Spacing.S, vertical = Spacing.S), 38 | onClick = onRyderClick 39 | ) { 40 | Text( 41 | text = "Go to Ryders list", 42 | style = AppTheme.typography.material.h1, 43 | maxLines = 1, 44 | ) 45 | } 46 | } 47 | } 48 | ) 49 | } 50 | 51 | @Preview(name = "Home Content", showBackground = true) 52 | @Composable 53 | fun PreviewHomeContent() { 54 | AppTheme { 55 | HomeScreen( 56 | uiState = HomeState(), 57 | onRyderClick = {} 58 | ) 59 | } 60 | } -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/data/local/TicketsLocalDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.data.local 2 | 3 | import com.moove.tickets.data.local.dto.RyderDTO 4 | import com.squareup.moshi.JsonAdapter 5 | import com.squareup.moshi.Moshi 6 | import com.squareup.moshi.Types 7 | 8 | class TicketsLocalDataSource( 9 | private val moshi: Moshi 10 | ) { 11 | 12 | // TODO json structure is bad 13 | private val data = "{\n" + 14 | " \"Adult\": {\n" + 15 | " \"fares\": [\n" + 16 | " { \"description\": \"2.5 Hour Ticket\", \"price\": 2.5 },\n" + 17 | " { \"description\": \"1 Day Pass\", \"price\": 5.0 },\n" + 18 | " { \"description\": \"30 Day Pass\", \"price\": 100 }\n" + 19 | " ],\n" + 20 | " \"subtext\": null\n" + 21 | " },\n" + 22 | " \"Child\": {\n" + 23 | " \"fares\": [\n" + 24 | " { \"description\": \"2.5 Hour Ticket\", \"price\": 1.5 },\n" + 25 | " { \"description\": \"1 Day Pass\", \"price\": 2.0 },\n" + 26 | " { \"description\": \"30 Day Pass\", \"price\": 40.0 }\n" + 27 | " ],\n" + 28 | " \"subtext\": \"Ages 8-17\"\n" + 29 | " },\n" + 30 | " \"Senior\": {\n" + 31 | " \"fares\": [\n" + 32 | " { \"description\": \"2.5 Hour Ticket\", \"price\": 1.0 },\n" + 33 | " { \"description\": \"1 Day Pass\", \"price\": 2.0 },\n" + 34 | " { \"description\": \"30 Day Pass\", \"price\": 40.0 }\n" + 35 | " ],\n" + 36 | " \"subtext\": \"Ages 60+\"\n" + 37 | " }\n" + 38 | "}\n" 39 | 40 | fun getData(): Map { 41 | val type = Types.newParameterizedType( 42 | Map::class.java, 43 | String::class.java, 44 | RyderDTO::class.java 45 | ) 46 | val adapter: JsonAdapter> = moshi.adapter>(type) 47 | .serializeNulls() 48 | .lenient() 49 | .nullSafe() 50 | 51 | return adapter.fromJson(data)!! 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /shared/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.library' 3 | id 'kotlin-android' 4 | id 'kotlin-parcelize' 5 | id 'com.google.devtools.ksp' 6 | id 'androidx.navigation.safeargs.kotlin' 7 | } 8 | 9 | apply from: "${rootProject.projectDir}/gradle/base-android-config.gradle" 10 | apply from: "${rootProject.projectDir}/gradle/compose-android-config.gradle" 11 | 12 | 13 | android { 14 | namespace 'com.moove.shared' 15 | } 16 | 17 | dependencies { 18 | 19 | api project(':core') 20 | api project(':design-system') 21 | 22 | // region kotlin 23 | implementation libs.kotlin.reflect 24 | implementation libs.kotlin.coroutines.playservices 25 | // endregion 26 | 27 | // region AndroidX 28 | api libs.androidx.appcompat 29 | api libs.androidx.core 30 | api libs.androidx.fragment 31 | api libs.bundles.androidx.navigation 32 | api libs.bundles.androidx.lifecycle 33 | api libs.bundles.androidx.compose 34 | api libs.paging.runtime 35 | api libs.androidx.browser 36 | // endregion 37 | 38 | // region presentation 39 | api libs.orbit.viewmodel 40 | api libs.orbit.compose 41 | // endregion 42 | 43 | // region ui 44 | api libs.google.material 45 | api libs.androidx.constraintlayout 46 | api libs.androidx.swiperefreshlayout 47 | api libs.androidx.viewpager 48 | api libs.bundles.google.accompanist 49 | api libs.bundles.paging 50 | api libs.coil 51 | // endregion 52 | 53 | // region di 54 | api libs.bundles.koin 55 | // endregion 56 | 57 | // region io 58 | api libs.retrofit.core 59 | api libs.retrofit.converter.moshi 60 | api libs.moshi.kotlin 61 | api libs.moshi.adapters 62 | api libs.moshi.adapters.x 63 | api libs.moshi.sealed.runtime 64 | ksp libs.moshi.compiler 65 | // endregion 66 | 67 | api platform(libs.firebase.platform) 68 | api libs.firebase.dynamic.links 69 | 70 | // region Tests 71 | api libs.javafaker 72 | 73 | testImplementation libs.bundles.tests.unit 74 | testImplementation libs.bundles.tests.viewmodel 75 | testImplementation libs.bundles.kointest 76 | androidTestImplementation libs.bundles.tests.android 77 | // endregion 78 | } -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/confirmation/ConfirmationRoute.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.confirmation 2 | 3 | import androidx.compose.material.ScaffoldState 4 | import androidx.compose.material.rememberScaffoldState 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.ui.platform.LocalContext 8 | import com.moove.shared.R 9 | import com.moove.shared.presentation.compose.component.showGenericError 10 | import com.moove.shared.presentation.compose.component.showSnackBar 11 | import com.moove.shared.presentation.viewmodel.composableEffect 12 | import com.moove.shared.presentation.viewmodel.composableState 13 | import com.moove.tickets.presentation.fare.model.FareModel 14 | import org.koin.androidx.compose.getViewModel 15 | import org.koin.androidx.compose.koinViewModel 16 | import org.koin.core.parameter.parametersOf 17 | 18 | @Composable 19 | fun FareListRoute( 20 | navigator: ConfirmationNavigator, 21 | ryderId: String, 22 | fare: FareModel, 23 | viewModel: ConfirmationViewModel = koinViewModel { parametersOf(ryderId, fare) }, 24 | scaffoldState: ScaffoldState = rememberScaffoldState(), 25 | ) { 26 | val state by viewModel.composableState() 27 | 28 | ConfirmationScreen( 29 | uiState = state, 30 | scaffoldState = scaffoldState, 31 | onIncrementTicketClick = viewModel::onIncrementTicketClick, 32 | onDecrementTicketClick = viewModel::onDecrementTicketClick, 33 | onConfirmClick = viewModel::onConfirmClick, 34 | ) 35 | 36 | viewModel.RenderEffect(scaffoldState = scaffoldState, navigator = navigator) 37 | } 38 | 39 | @Composable 40 | private fun ConfirmationViewModel.RenderEffect( 41 | scaffoldState: ScaffoldState, 42 | navigator: ConfirmationNavigator, 43 | ) { 44 | val context = LocalContext.current 45 | composableEffect { effect -> 46 | when (effect) { 47 | ConfirmationEffect.ShowGenericError -> scaffoldState.showGenericError(context) 48 | ConfirmationEffect.ShowSuccessMessage -> scaffoldState.showSnackBar( 49 | context.getString(R.string.confirm_success) 50 | ) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/list/RyderListViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.list 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.moove.core.exception.ExceptionHandler 5 | import com.moove.core.exception.asCoroutineExceptionHandler 6 | import com.moove.shared.presentation.compose.component.ScreenContentStatus 7 | import com.moove.shared.presentation.viewmodel.executeUseCase 8 | import com.moove.tickets.domain.use_cases.GetRydersUseCase 9 | import com.moove.tickets.presentation.list.model.RyderModel 10 | import com.moove.tickets.presentation.list.model.asPresentation 11 | import org.orbitmvi.orbit.Container 12 | import org.orbitmvi.orbit.ContainerHost 13 | import org.orbitmvi.orbit.syntax.simple.intent 14 | import org.orbitmvi.orbit.syntax.simple.postSideEffect 15 | import org.orbitmvi.orbit.syntax.simple.reduce 16 | import org.orbitmvi.orbit.viewmodel.container 17 | 18 | class RyderListViewModel( 19 | private val exceptionHandler: ExceptionHandler, 20 | private val getRydersUseCase: GetRydersUseCase, 21 | ) : ViewModel(), ContainerHost { 22 | 23 | override val container: Container = container( 24 | initialState = RyderListState(), 25 | buildSettings = { 26 | this.exceptionHandler = 27 | this@RyderListViewModel.exceptionHandler.asCoroutineExceptionHandler() 28 | }, 29 | ) { 30 | fetchRyders() 31 | } 32 | 33 | fun onRyderClick(ryder: RyderModel) = intent { 34 | postSideEffect(RyderListEffect.GoToFares(ryder.id)) 35 | } 36 | 37 | private fun fetchRyders() = intent { 38 | reduce { state.copy(status = ScreenContentStatus.Loading) } 39 | executeUseCase { getRydersUseCase() } 40 | .onSuccess { 41 | reduce { 42 | state.copy( 43 | status = ScreenContentStatus.Success, 44 | ryders = it.asPresentation() 45 | ) 46 | } 47 | } 48 | .onFailure { 49 | reduce { state.copy(status = ScreenContentStatus.Failure) } 50 | postSideEffect(RyderListEffect.ShowGenericError) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/fare/FareListViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.fare 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.moove.core.exception.ExceptionHandler 5 | import com.moove.core.exception.asCoroutineExceptionHandler 6 | import com.moove.shared.presentation.compose.component.ScreenContentStatus 7 | import com.moove.shared.presentation.viewmodel.executeUseCase 8 | import com.moove.tickets.domain.use_cases.GetFaresByIdUseCase 9 | import com.moove.tickets.presentation.fare.model.FareModel 10 | import com.moove.tickets.presentation.fare.model.asPresentation 11 | import org.orbitmvi.orbit.Container 12 | import org.orbitmvi.orbit.ContainerHost 13 | import org.orbitmvi.orbit.syntax.simple.intent 14 | import org.orbitmvi.orbit.syntax.simple.postSideEffect 15 | import org.orbitmvi.orbit.syntax.simple.reduce 16 | import org.orbitmvi.orbit.viewmodel.container 17 | 18 | class FareListViewModel( 19 | private val exceptionHandler: ExceptionHandler, 20 | private val ryderId: String, 21 | private val getFaresByIdUseCase: GetFaresByIdUseCase, 22 | ) : ViewModel(), ContainerHost { 23 | 24 | override val container: Container = container( 25 | initialState = FareListState(), 26 | buildSettings = { 27 | this.exceptionHandler = 28 | this@FareListViewModel.exceptionHandler.asCoroutineExceptionHandler() 29 | }, 30 | ) { 31 | fetchFares() 32 | } 33 | 34 | fun onFareClick(fare: FareModel) = intent { 35 | postSideEffect(FareListEffect.GoToConfirmation(ryderId, fare)) 36 | } 37 | 38 | private fun fetchFares() = intent { 39 | reduce { state.copy(status = ScreenContentStatus.Loading) } 40 | executeUseCase { getFaresByIdUseCase(ryderId) } 41 | .onSuccess { 42 | reduce { 43 | state.copy( 44 | status = ScreenContentStatus.Success, 45 | fares = it.asPresentation() 46 | ) 47 | } 48 | } 49 | .onFailure { 50 | reduce { state.copy(status = ScreenContentStatus.Failure) } 51 | postSideEffect(FareListEffect.ShowGenericError) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /shared/src/main/java/com/moove/shared/presentation/fragment/delegate/FragmentViewLifecycleAwareDelegate.kt: -------------------------------------------------------------------------------- 1 | package com.moove.shared.presentation.fragment.delegate 2 | 3 | import android.view.View 4 | import androidx.fragment.app.Fragment 5 | import androidx.lifecycle.DefaultLifecycleObserver 6 | import androidx.lifecycle.Lifecycle 7 | import androidx.lifecycle.LifecycleOwner 8 | import androidx.lifecycle.Observer 9 | import kotlin.properties.ReadOnlyProperty 10 | import kotlin.reflect.KProperty 11 | 12 | class FragmentViewLifecycleAwareDelegate( 13 | val fragment: Fragment, 14 | val itemFactory: (View) -> T, 15 | val tearDown: ((T) -> Unit)? = null, 16 | ) : ReadOnlyProperty { 17 | 18 | private var item: T? = null 19 | private val viewLifecycleOwnerLiveDataObserver = Observer { 20 | val viewLifecycleOwner = it ?: return@Observer 21 | 22 | viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { 23 | override fun onDestroy(owner: LifecycleOwner) { 24 | if (tearDown != null) item?.also(tearDown) 25 | item = null 26 | } 27 | }) 28 | } 29 | 30 | init { 31 | fragment.lifecycle.addObserver(object : DefaultLifecycleObserver { 32 | 33 | override fun onCreate(owner: LifecycleOwner) { 34 | fragment.viewLifecycleOwnerLiveData.observeForever( 35 | viewLifecycleOwnerLiveDataObserver 36 | ) 37 | } 38 | 39 | override fun onDestroy(owner: LifecycleOwner) { 40 | fragment.viewLifecycleOwnerLiveData.removeObserver( 41 | viewLifecycleOwnerLiveDataObserver 42 | ) 43 | } 44 | }) 45 | } 46 | 47 | override fun getValue(thisRef: Fragment, property: KProperty<*>): T { 48 | val binding = item 49 | if (binding != null) { 50 | return binding 51 | } 52 | val lifecycle = fragment.viewLifecycleOwner.lifecycle 53 | if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) { 54 | throw IllegalStateException("Should not attempt to get bindings when Fragment views are destroyed.") 55 | } 56 | 57 | return itemFactory(thisRef.requireView()).also { this.item = it } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /gradle/base-android-config.gradle: -------------------------------------------------------------------------------- 1 | android { 2 | compileSdkVersion androidSetup.compileSdkVersion 3 | 4 | defaultConfig { 5 | minSdkVersion androidSetup.minSdkVersion 6 | targetSdkVersion androidSetup.targetSdkVersion 7 | 8 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 9 | } 10 | 11 | buildTypes { 12 | debug { 13 | minifyEnabled false 14 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 15 | } 16 | review { 17 | minifyEnabled true 18 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 19 | matchingFallbacks = ['release'] 20 | } 21 | release { 22 | minifyEnabled true 23 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 24 | } 25 | } 26 | 27 | compileOptions { 28 | sourceCompatibility JavaVersion.VERSION_17 29 | targetCompatibility JavaVersion.VERSION_17 30 | coreLibraryDesugaringEnabled true 31 | } 32 | 33 | kotlinOptions { 34 | jvmTarget = '17' 35 | freeCompilerArgs += [ 36 | '-opt-in=kotlin.RequiresOptIn', 37 | '-opt-in=kotlin.ExperimentalStdlibApi', 38 | '-opt-in=kotlin.contracts.ExperimentalContracts', 39 | '-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', 40 | '-opt-in=kotlinx.coroutines.FlowPreview', 41 | ] 42 | } 43 | 44 | buildFeatures { 45 | buildConfig true 46 | dataBinding false 47 | viewBinding true 48 | } 49 | 50 | lintOptions { 51 | abortOnError true 52 | ignoreWarnings false 53 | disable 'WrongConstant', 54 | 'ContentDescription', 55 | 'VectorPath', 'VectorRaster', 56 | 'Typos', 'TypographyDashes', 'TypographyEllipsis', 'PluralsCandidate', 'ButtonCase' 57 | lintConfig file("${rootProject.projectDir}/lint.xml") 58 | } 59 | } 60 | 61 | tasks.withType(Test).configureEach { 62 | testLogging { 63 | events "failed", "standardError" 64 | } 65 | } 66 | 67 | dependencies { 68 | // region Tools 69 | coreLibraryDesugaring libs.android.tools.desugar 70 | // endregion 71 | } 72 | -------------------------------------------------------------------------------- /shared/src/main/java/com/moove/shared/presentation/compose/component/ScreenContent.kt: -------------------------------------------------------------------------------- 1 | package com.moove.shared.presentation.compose.component 2 | 3 | import android.os.Parcelable 4 | import androidx.compose.foundation.layout.BoxScope 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import kotlinx.parcelize.Parcelize 8 | 9 | @Composable 10 | fun ScreenContent( 11 | modifier: Modifier = Modifier, 12 | status: ScreenContentStatus, 13 | forceLoading: Boolean = false, 14 | onRetry: () -> Unit = {}, 15 | error: @Composable () -> Unit = { }, 16 | blockingSurface: @Composable BoxScope.() -> Unit = defaultBlockingSurface, 17 | content: @Composable BoxScope.() -> Unit, 18 | ) { 19 | val delay = if (forceLoading) BlockingBoxDefaults.zeroDelay else BlockingBoxDefaults.shortDelay 20 | BlockingBox( 21 | modifier = modifier, 22 | blocked = status.isLoading || forceLoading, 23 | delayBeforeBlock = delay, 24 | minTimeToShow = delay, 25 | blockingSurface = blockingSurface, 26 | ) { 27 | if (status.isFailing) { 28 | error() 29 | } else if (status.isSuccess || status.isRefreshing) { 30 | content() 31 | } 32 | } 33 | } 34 | 35 | sealed class ScreenContentStatus : Parcelable { 36 | 37 | companion object { 38 | fun asProgress(isRefreshing: Boolean): ScreenContentStatus = 39 | if (isRefreshing) Refreshing else Loading 40 | } 41 | 42 | @Parcelize 43 | object Idle : ScreenContentStatus() 44 | 45 | @Parcelize 46 | object Loading : ScreenContentStatus() 47 | 48 | @Parcelize 49 | object Refreshing : ScreenContentStatus() 50 | 51 | @Parcelize 52 | object Success : ScreenContentStatus() 53 | 54 | @Parcelize 55 | object Failure : ScreenContentStatus() 56 | } 57 | 58 | val ScreenContentStatus.inProgress: Boolean 59 | get() = this.isLoading || this.isRefreshing 60 | 61 | val ScreenContentStatus.isLoading: Boolean 62 | get() = this is ScreenContentStatus.Loading 63 | 64 | val ScreenContentStatus.isRefreshing: Boolean 65 | get() = this is ScreenContentStatus.Refreshing 66 | 67 | val ScreenContentStatus.isFailing: Boolean 68 | get() = this is ScreenContentStatus.Failure 69 | 70 | val ScreenContentStatus.isSuccess: Boolean 71 | get() = this == ScreenContentStatus.Success 72 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/fare/FareListScreen.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.fare 2 | 3 | import androidx.compose.foundation.layout.statusBarsPadding 4 | import androidx.compose.material.ScaffoldState 5 | import androidx.compose.material.Text 6 | import androidx.compose.material.TopAppBar 7 | import androidx.compose.material.rememberScaffoldState 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.res.stringResource 11 | import androidx.compose.ui.tooling.preview.Preview 12 | import com.moove.design_system.compose.AppTheme 13 | import com.moove.design_system.compose.Scaffold 14 | import com.moove.shared.R 15 | import com.moove.shared.presentation.compose.component.ScreenContent 16 | import com.moove.shared.presentation.compose.component.ScreenContentStatus 17 | import com.moove.shared.presentation.compose.component.isLoading 18 | import com.moove.tickets.presentation.fare.component.FareList 19 | import com.moove.tickets.presentation.fare.model.FareModel 20 | import com.moove.tickets.presentation.fare.model.fakeFareModels 21 | 22 | @Composable 23 | fun FareListScreen( 24 | uiState: FareListState, 25 | scaffoldState: ScaffoldState = rememberScaffoldState(), 26 | onFareClick: (FareModel) -> Unit, 27 | ) { 28 | Scaffold( 29 | modifier = Modifier.statusBarsPadding(), 30 | scaffoldState = scaffoldState, 31 | topBar = { 32 | TopAppBar( 33 | title = { 34 | Text(text = stringResource(id = R.string.select_fare_title)) 35 | }, 36 | backgroundColor = AppTheme.colors.material.surface, 37 | ) 38 | }, 39 | content = { 40 | ScreenContent( 41 | status = uiState.status, 42 | forceLoading = uiState.status.isLoading, 43 | ) { 44 | FareList( 45 | fares = uiState.fares, 46 | onClick = onFareClick 47 | ) 48 | } 49 | } 50 | ) 51 | } 52 | 53 | @Preview(name = "Fares Content", showBackground = true) 54 | @Composable 55 | fun PreviewRydersContent() { 56 | AppTheme { 57 | FareListScreen( 58 | uiState = FareListState( 59 | status = ScreenContentStatus.Success, 60 | fares = fakeFareModels, 61 | ), 62 | onFareClick = {} 63 | ) 64 | } 65 | } -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/list/RyderListScreen.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.list 2 | 3 | import androidx.compose.foundation.layout.statusBarsPadding 4 | import androidx.compose.material.ScaffoldState 5 | import androidx.compose.material.Text 6 | import androidx.compose.material.TopAppBar 7 | import androidx.compose.material.rememberScaffoldState 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.res.stringResource 11 | import androidx.compose.ui.tooling.preview.Preview 12 | import com.moove.design_system.compose.AppTheme 13 | import com.moove.design_system.compose.Scaffold 14 | import com.moove.shared.presentation.compose.component.ScreenContent 15 | import com.moove.shared.presentation.compose.component.ScreenContentStatus 16 | import com.moove.shared.presentation.compose.component.isLoading 17 | import com.moove.tickets.presentation.list.component.RyderList 18 | import com.moove.tickets.presentation.list.model.RyderModel 19 | import com.moove.tickets.presentation.list.model.fakeRyderModels 20 | 21 | @Composable 22 | fun RyderListScreen( 23 | uiState: RyderListState, 24 | scaffoldState: ScaffoldState = rememberScaffoldState(), 25 | onRyderClick: (RyderModel) -> Unit, 26 | ) { 27 | Scaffold( 28 | modifier = Modifier.statusBarsPadding(), 29 | scaffoldState = scaffoldState, 30 | topBar = { 31 | TopAppBar( 32 | title = { 33 | Text(text = stringResource(id = com.moove.shared.R.string.select_ryder_title)) 34 | }, 35 | backgroundColor = AppTheme.colors.material.surface, 36 | ) 37 | }, 38 | content = { 39 | ScreenContent( 40 | status = uiState.status, 41 | forceLoading = uiState.status.isLoading, 42 | ) { 43 | RyderList( 44 | ryders = uiState.ryders, 45 | onClick = onRyderClick 46 | ) 47 | } 48 | } 49 | ) 50 | } 51 | 52 | @Preview(name = "Ryders Content", showBackground = true) 53 | @Composable 54 | fun PreviewRydersContent() { 55 | AppTheme { 56 | RyderListScreen( 57 | uiState = RyderListState( 58 | status = ScreenContentStatus.Success, 59 | ryders = fakeRyderModels, 60 | ), 61 | onRyderClick = {} 62 | ) 63 | } 64 | } -------------------------------------------------------------------------------- /design-system/src/main/java/com/moove/design_system/compose/Scaffold.kt: -------------------------------------------------------------------------------- 1 | package com.moove.design_system.compose 2 | 3 | import androidx.compose.foundation.layout.ColumnScope 4 | import androidx.compose.foundation.layout.PaddingValues 5 | import androidx.compose.material.DrawerDefaults 6 | import androidx.compose.material.FabPosition 7 | import androidx.compose.material.MaterialTheme 8 | import androidx.compose.material.ScaffoldState 9 | import androidx.compose.material.SnackbarHost 10 | import androidx.compose.material.SnackbarHostState 11 | import androidx.compose.material.contentColorFor 12 | import androidx.compose.material.rememberScaffoldState 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.graphics.Color 16 | import androidx.compose.ui.graphics.Shape 17 | import androidx.compose.ui.unit.Dp 18 | import androidx.compose.material.Scaffold as MaterialScaffold 19 | 20 | @Composable 21 | fun Scaffold( 22 | modifier: Modifier = Modifier, 23 | scaffoldState: ScaffoldState = rememberScaffoldState(), 24 | topBar: @Composable () -> Unit = {}, 25 | bottomBar: @Composable () -> Unit = {}, 26 | snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) }, 27 | floatingActionButton: @Composable () -> Unit = {}, 28 | floatingActionButtonPosition: FabPosition = FabPosition.End, 29 | isFloatingActionButtonDocked: Boolean = false, 30 | drawerContent: @Composable (ColumnScope.() -> Unit)? = null, 31 | drawerGesturesEnabled: Boolean = true, 32 | drawerShape: Shape = MaterialTheme.shapes.large, 33 | drawerElevation: Dp = DrawerDefaults.Elevation, 34 | drawerBackgroundColor: Color = MaterialTheme.colors.surface, 35 | drawerContentColor: Color = contentColorFor(drawerBackgroundColor), 36 | drawerScrimColor: Color = DrawerDefaults.scrimColor, 37 | backgroundColor: Color = MaterialTheme.colors.background, 38 | contentColor: Color = contentColorFor(backgroundColor), 39 | content: @Composable (PaddingValues) -> Unit 40 | ) = MaterialScaffold( 41 | modifier, 42 | scaffoldState, 43 | topBar, 44 | bottomBar, 45 | snackbarHost, 46 | floatingActionButton, 47 | floatingActionButtonPosition, 48 | isFloatingActionButtonDocked, 49 | drawerContent, 50 | drawerGesturesEnabled, 51 | drawerShape, 52 | drawerElevation, 53 | drawerBackgroundColor, 54 | drawerContentColor, 55 | drawerScrimColor, 56 | backgroundColor, 57 | contentColor, 58 | content 59 | ) 60 | -------------------------------------------------------------------------------- /tickets/src/test/java/com/moove/tickets/data/TicketsDataRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.data 2 | 3 | import com.moove.tickets.data.dto.randomRyderDTO 4 | import com.moove.tickets.data.local.TicketsLocalDataSource 5 | import com.moove.tickets.data.local.dto.RyderDTO 6 | import com.moove.tickets.data.local.dto.asDomain 7 | import com.moove.tickets.domain.model.Fare 8 | import com.moove.tickets.domain.model.Ryder 9 | import io.mockk.coEvery 10 | import io.mockk.coVerify 11 | import io.mockk.mockk 12 | import kotlinx.coroutines.test.runTest 13 | import org.junit.Test 14 | import kotlin.test.assertEquals 15 | 16 | class TicketsDataRepositoryTest { 17 | 18 | private val ticketsLocalDataSource: TicketsLocalDataSource = mockk(relaxed = true) 19 | 20 | private val ticketsRepository = TicketsDataRepository(ticketsLocalDataSource) 21 | 22 | @Test 23 | fun `On get data should return correct result`() = runTest { 24 | val ryder1Id = "Ryder1" 25 | val ryder2Id = "Ryder2" 26 | val ryder3Id = "Ryder3" 27 | 28 | val ryder1 = randomRyderDTO() 29 | val ryder2 = randomRyderDTO() 30 | val ryder3 = randomRyderDTO() 31 | 32 | val ryders: List = listOf( 33 | ryder1.asDomain(ryder1Id), 34 | ryder2.asDomain(ryder2Id), 35 | ryder3.asDomain(ryder3Id), 36 | ) 37 | 38 | val data: Map = mapOf( 39 | ryder1Id to ryder1, 40 | ryder2Id to ryder2, 41 | ryder3Id to ryder3, 42 | ) 43 | 44 | coEvery { ticketsLocalDataSource.getData() } returns data 45 | 46 | val result = ticketsRepository.getRyders() 47 | 48 | assertEquals(ryders, result) 49 | 50 | coVerify { ticketsLocalDataSource.getData() } 51 | } 52 | 53 | @Test 54 | fun `On get fares should return correct result`() = runTest { 55 | val ryder1Id = "Ryder1" 56 | val ryder2Id = "Ryder2" 57 | val ryder3Id = "Ryder3" 58 | 59 | val ryder1 = randomRyderDTO() 60 | val ryder2 = randomRyderDTO() 61 | val ryder3 = randomRyderDTO() 62 | 63 | val fares: List = ryder2.asDomain(ryder2Id).fares 64 | 65 | val data: Map = mapOf( 66 | ryder1Id to ryder1, 67 | ryder2Id to ryder2, 68 | ryder3Id to ryder3, 69 | ) 70 | 71 | coEvery { ticketsLocalDataSource.getData() } returns data 72 | 73 | val result = ticketsRepository.getFares(ryder2Id) 74 | 75 | assertEquals(fares, result) 76 | 77 | coVerify { ticketsLocalDataSource.getData() } 78 | } 79 | } -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/confirmation/component/ConfirmationItem.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.confirmation.component 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.tooling.preview.Preview 13 | import com.moove.design_system.compose.AppTheme 14 | import com.moove.design_system.compose.Button 15 | import com.moove.design_system.compose.Spacing 16 | import com.moove.tickets.presentation.fare.model.FareModel 17 | import com.moove.tickets.presentation.fare.model.fakeFareModels 18 | 19 | @Composable 20 | fun ConfirmationItem( 21 | modifier: Modifier = Modifier, 22 | ryderId: String, 23 | fare: FareModel, 24 | ticketCount: Int, 25 | onIncrementTicketClick: () -> Unit, 26 | onDecrementTicketClick: () -> Unit, 27 | ) { 28 | Row( 29 | modifier = modifier 30 | .fillMaxWidth() 31 | .padding(horizontal = Spacing.S, vertical = Spacing.S), 32 | verticalAlignment = Alignment.CenterVertically, 33 | horizontalArrangement = Arrangement.SpaceBetween 34 | ) { 35 | Column( 36 | modifier = modifier.weight(1f) 37 | ) { 38 | Text( 39 | text = ryderId, 40 | style = AppTheme.typography.material.h1, 41 | maxLines = 1, 42 | ) 43 | Text( 44 | text = fare.description, 45 | style = AppTheme.typography.material.subtitle1, 46 | maxLines = 1, 47 | ) 48 | } 49 | 50 | Column( 51 | horizontalAlignment = Alignment.CenterHorizontally 52 | ) { 53 | Button( 54 | onClick = onIncrementTicketClick, 55 | content = { Text(text = "+") } 56 | ) 57 | Text( 58 | text = ticketCount.toString(), 59 | style = AppTheme.typography.material.h3, 60 | maxLines = 1, 61 | ) 62 | Button( 63 | onClick = onDecrementTicketClick, 64 | content = { Text(text = "-") } 65 | ) 66 | } 67 | } 68 | } 69 | 70 | @Preview(name = "Confirmation Item", showBackground = true) 71 | @Composable 72 | private fun PreviewConfirmationItem() { 73 | AppTheme { 74 | ConfirmationItem( 75 | ryderId = "Adult", 76 | fare = fakeFareModels.first(), 77 | ticketCount = 2, 78 | onIncrementTicketClick = {}, 79 | onDecrementTicketClick = {} 80 | ) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /shared/src/main/java/com/moove/shared/presentation/fragment/delegate/FragmentLifecycleAwareExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.moove.shared.presentation.fragment.delegate 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.IntentFilter 5 | import androidx.core.view.WindowCompat 6 | import androidx.fragment.app.Fragment 7 | import androidx.lifecycle.DefaultLifecycleObserver 8 | import androidx.lifecycle.Lifecycle 9 | import androidx.lifecycle.LifecycleOwner 10 | 11 | fun Fragment.registerLifecycleAwareItem( 12 | state: Lifecycle.State = Lifecycle.State.CREATED, 13 | itemFactory: () -> T, 14 | tearDown: ((T) -> Unit)? = null, 15 | ) { 16 | var item: T? = null 17 | 18 | fun applyFactory() { 19 | item = itemFactory() 20 | } 21 | 22 | fun applyTearDown() { 23 | item = item?.let { tearDown?.invoke(it); null } 24 | } 25 | lifecycle.addObserver(object : DefaultLifecycleObserver { 26 | override fun onCreate(owner: LifecycleOwner) { 27 | if (state == Lifecycle.State.CREATED) applyFactory() 28 | } 29 | 30 | override fun onStart(owner: LifecycleOwner) { 31 | if (state == Lifecycle.State.STARTED) applyFactory() 32 | } 33 | 34 | override fun onResume(owner: LifecycleOwner) { 35 | if (state == Lifecycle.State.RESUMED) applyFactory() 36 | } 37 | 38 | override fun onPause(owner: LifecycleOwner) { 39 | if (state == Lifecycle.State.RESUMED) applyTearDown() 40 | } 41 | 42 | override fun onStop(owner: LifecycleOwner) { 43 | if (state == Lifecycle.State.STARTED) applyTearDown() 44 | } 45 | 46 | override fun onDestroy(owner: LifecycleOwner) { 47 | if (state == Lifecycle.State.CREATED) applyTearDown() 48 | } 49 | }) 50 | } 51 | 52 | /** 53 | * Create Delegate to apply/revert activity-wide [WindowCompat.setDecorFitsSystemWindows] 54 | * on fragment start-stop lifecycle. 55 | * @param revertOnDestroy if false, no tearDown is applied: 56 | * useful if the next navigation destination also applies this method (due to [Fragment] lifecycle). 57 | */ 58 | fun Fragment.setDecorFitsSystemWindows(fits: Boolean, revertOnDestroy: Boolean = true) = 59 | registerLifecycleAwareItem( 60 | state = Lifecycle.State.STARTED, 61 | itemFactory = { WindowCompat.setDecorFitsSystemWindows(requireActivity().window, fits) }, 62 | tearDown = { if (revertOnDestroy) WindowCompat.setDecorFitsSystemWindows(requireActivity().window, !fits) }, 63 | ) 64 | 65 | fun Fragment.registerBroadcastReceiver(receiver: T, filter: IntentFilter = IntentFilter()) = 66 | registerLifecycleAwareItem( 67 | state = Lifecycle.State.CREATED, 68 | itemFactory = { 69 | requireContext().registerReceiver(receiver, filter) 70 | Unit 71 | }, 72 | tearDown = { requireContext().unregisterReceiver(receiver) } 73 | ) 74 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /core/src/main/java/com/moove/core/compose/AnnotatedString.kt: -------------------------------------------------------------------------------- 1 | package com.moove.core.compose 2 | 3 | import android.graphics.Typeface 4 | import android.text.Spanned 5 | import android.text.style.BulletSpan 6 | import android.text.style.ForegroundColorSpan 7 | import android.text.style.StyleSpan 8 | import android.text.style.UnderlineSpan 9 | import androidx.compose.ui.graphics.Color 10 | import androidx.compose.ui.text.AnnotatedString 11 | import androidx.compose.ui.text.ParagraphStyle 12 | import androidx.compose.ui.text.SpanStyle 13 | import androidx.compose.ui.text.buildAnnotatedString 14 | import androidx.compose.ui.text.font.FontStyle 15 | import androidx.compose.ui.text.font.FontWeight 16 | import androidx.compose.ui.text.style.TextDecoration 17 | import androidx.compose.ui.text.style.TextIndent 18 | import androidx.compose.ui.unit.sp 19 | import androidx.core.text.HtmlCompat 20 | 21 | object Html { 22 | fun fromHtml(source: String): AnnotatedString { 23 | val textWithSymbols = replaceSymbols(source) 24 | return HtmlCompat.fromHtml(textWithSymbols, HtmlCompat.FROM_HTML_MODE_LEGACY).toAnnotatedString() 25 | } 26 | 27 | private fun replaceSymbols(source: String): String { 28 | return source.replace("
  • ", "
  • •\t\t") 29 | } 30 | 31 | private fun Spanned.toAnnotatedString(): AnnotatedString = buildAnnotatedString { 32 | val spanned = this@toAnnotatedString 33 | append(spanned.toString()) 34 | getSpans(0, spanned.length, Any::class.java).forEach { span -> 35 | val start = getSpanStart(span) 36 | val end = getSpanEnd(span) 37 | when (span) { 38 | is StyleSpan -> addStyleSpan(span, start, end) 39 | is UnderlineSpan -> addUnderlineSpan(start, end) 40 | is ForegroundColorSpan -> addForegroundColorSpan(span, start, end) 41 | is BulletSpan -> addListSpan(start, end) 42 | } 43 | } 44 | } 45 | 46 | private fun AnnotatedString.Builder.addStyleSpan(span: StyleSpan, start: Int, end: Int) { 47 | when (span.style) { 48 | Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) 49 | Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end) 50 | Typeface.BOLD_ITALIC -> addStyle( 51 | SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic), 52 | start, 53 | end 54 | ) 55 | } 56 | } 57 | 58 | private fun AnnotatedString.Builder.addUnderlineSpan(start: Int, end: Int) { 59 | addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end) 60 | } 61 | 62 | private fun AnnotatedString.Builder.addForegroundColorSpan(span: ForegroundColorSpan, start: Int, end: Int) { 63 | addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end) 64 | } 65 | 66 | private fun AnnotatedString.Builder.addListSpan(start: Int, end: Int) { 67 | addStyle(ParagraphStyle(textIndent = TextIndent(restLine = 14.sp)), start, end) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/confirmation/ConfirmationViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.confirmation 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.moove.core.exception.ExceptionHandler 5 | import com.moove.core.exception.asCoroutineExceptionHandler 6 | import com.moove.shared.presentation.compose.component.ScreenContentStatus 7 | import com.moove.shared.presentation.viewmodel.executeUseCase 8 | import com.moove.tickets.domain.use_cases.BuyTicketUseCase 9 | import com.moove.tickets.presentation.fare.model.FareModel 10 | import com.moove.tickets.presentation.fare.model.asDomain 11 | import org.orbitmvi.orbit.Container 12 | import org.orbitmvi.orbit.ContainerHost 13 | import org.orbitmvi.orbit.syntax.simple.intent 14 | import org.orbitmvi.orbit.syntax.simple.postSideEffect 15 | import org.orbitmvi.orbit.syntax.simple.reduce 16 | import org.orbitmvi.orbit.viewmodel.container 17 | 18 | class ConfirmationViewModel( 19 | private val exceptionHandler: ExceptionHandler, 20 | ryderId: String, 21 | fare: FareModel, 22 | private val buyTicketUseCase: BuyTicketUseCase 23 | ) : ViewModel(), ContainerHost { 24 | 25 | override val container: Container = container( 26 | initialState = ConfirmationState( 27 | ryderId = ryderId, 28 | fare = fare, 29 | ticketCount = 1, 30 | totalPrice = fare.price, 31 | ), 32 | buildSettings = { 33 | this.exceptionHandler = 34 | this@ConfirmationViewModel.exceptionHandler.asCoroutineExceptionHandler() 35 | }, 36 | ) 37 | 38 | fun onIncrementTicketClick() = intent { 39 | val ticketCount = state.ticketCount + 1 40 | reduce { 41 | state.copy( 42 | ticketCount = ticketCount, 43 | totalPrice = state.fare.price * ticketCount 44 | ) 45 | } 46 | } 47 | 48 | fun onDecrementTicketClick() = intent { 49 | if (state.ticketCount == 0) return@intent 50 | val ticketCount = state.ticketCount - 1 51 | reduce { 52 | state.copy( 53 | ticketCount = ticketCount, 54 | totalPrice = state.fare.price * ticketCount 55 | ) 56 | } 57 | } 58 | 59 | fun onConfirmClick() = intent { 60 | reduce { state.copy(status = ScreenContentStatus.Loading) } 61 | executeUseCase { 62 | buyTicketUseCase( 63 | ryderId = state.ryderId, 64 | fare = state.fare.asDomain(), 65 | totalCount = state.ticketCount 66 | ) 67 | } 68 | .onSuccess { 69 | reduce { state.copy(status = ScreenContentStatus.Success) } 70 | postSideEffect(ConfirmationEffect.ShowSuccessMessage) 71 | } 72 | .onFailure { 73 | reduce { state.copy(status = ScreenContentStatus.Failure) } 74 | postSideEffect(ConfirmationEffect.ShowGenericError) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /app/src/main/java/com/moove/app/main/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.moove.app.main 2 | 3 | import android.content.Intent 4 | import android.os.Build 5 | import android.os.Bundle 6 | import android.widget.Toast 7 | import androidx.activity.addCallback 8 | import androidx.appcompat.app.AppCompatActivity 9 | import androidx.core.view.WindowCompat 10 | import androidx.lifecycle.Lifecycle 11 | import androidx.lifecycle.lifecycleScope 12 | import androidx.lifecycle.repeatOnLifecycle 13 | import androidx.navigation.fragment.NavHostFragment 14 | import com.moove.R 15 | import com.moove.databinding.ActivityMainBinding 16 | import kotlinx.coroutines.launch 17 | import org.koin.android.ext.android.inject 18 | import org.koin.androidx.viewmodel.ext.android.viewModel 19 | import org.koin.core.parameter.parametersOf 20 | 21 | class MainActivity : AppCompatActivity() { 22 | 23 | private val mainViewModel: MainActivityViewModel by viewModel() 24 | private val navigator: MainNavigator by inject { 25 | parametersOf( 26 | (supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment) 27 | .navController, 28 | lifecycleScope, 29 | this 30 | ) 31 | } 32 | private lateinit var viewBinding: ActivityMainBinding 33 | 34 | override fun onCreate(savedInstanceState: Bundle?) { 35 | WindowCompat.setDecorFitsSystemWindows(window, false) 36 | super.onCreate(savedInstanceState) 37 | viewBinding = ActivityMainBinding.inflate(layoutInflater) 38 | setContentView(viewBinding.root) 39 | 40 | mainViewModel.handleIntent(intent) 41 | 42 | observeSideEffect() 43 | } 44 | 45 | override fun onNewIntent(intent: Intent) { 46 | super.onNewIntent(intent) 47 | mainViewModel.handleIntent(intent) 48 | } 49 | 50 | private fun observeSideEffect() { 51 | lifecycleScope.launch { 52 | val sideEffectFlow = mainViewModel.container.sideEffectFlow 53 | lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { 54 | sideEffectFlow.collect { effect -> 55 | when (effect) { 56 | is MainActivityEffect.NavigateDeepLink -> navigator.navigateDeepLink(effect.deepLink) 57 | MainActivityEffect.ShowGenericError -> Toast.makeText( 58 | this@MainActivity, 59 | "Generic Error", 60 | Toast.LENGTH_SHORT 61 | ) 62 | } 63 | } 64 | } 65 | } 66 | } 67 | 68 | override fun onBackPressed() { 69 | val backStackEntryCount = supportFragmentManager.primaryNavigationFragment 70 | ?.childFragmentManager 71 | ?.backStackEntryCount ?: 0 72 | 73 | if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q && 74 | isTaskRoot && 75 | backStackEntryCount == 0 && supportFragmentManager.backStackEntryCount == 0 76 | ) { 77 | finishAfterTransition() 78 | } else { 79 | super.onBackPressed() 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /shared/src/main/java/com/moove/shared/presentation/compose/component/BlockingBox.kt: -------------------------------------------------------------------------------- 1 | package com.moove.shared.presentation.compose.component 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.animation.fadeIn 5 | import androidx.compose.animation.fadeOut 6 | import androidx.compose.foundation.clickable 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.BoxScope 9 | import androidx.compose.foundation.layout.fillMaxSize 10 | import androidx.compose.material.Surface 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.LaunchedEffect 13 | import androidx.compose.runtime.getValue 14 | import androidx.compose.runtime.mutableStateOf 15 | import androidx.compose.runtime.remember 16 | import androidx.compose.runtime.setValue 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | import com.moove.design_system.compose.AppTheme 20 | import com.moove.design_system.compose.CircularProgressIndicator 21 | import kotlinx.coroutines.delay 22 | import kotlin.math.max 23 | 24 | @Composable 25 | fun BlockingBox( 26 | modifier: Modifier = Modifier, 27 | blocked: Boolean, 28 | blockSilentlyImmediately: Boolean = true, 29 | delayBeforeBlock: Long = BlockingBoxDefaults.longDelay, 30 | minTimeToShow: Long = BlockingBoxDefaults.longDelay, 31 | blockingSurface: @Composable BoxScope.() -> Unit = defaultBlockingSurface, 32 | content: @Composable BoxScope.() -> Unit, 33 | ) { 34 | Box( 35 | modifier = modifier, 36 | ) { 37 | content() 38 | var showSurface by remember { mutableStateOf(false) } 39 | var surfaceShownAt by remember { mutableStateOf(0L) } 40 | LaunchedEffect(key1 = blocked) { 41 | if (blocked) { 42 | delay(delayBeforeBlock) 43 | surfaceShownAt = System.currentTimeMillis() 44 | showSurface = true 45 | } else if (showSurface) { 46 | val shownFor = System.currentTimeMillis() - surfaceShownAt 47 | delay(max(0, minTimeToShow - shownFor)) 48 | showSurface = false 49 | } 50 | } 51 | if (blocked && blockSilentlyImmediately) { 52 | Box( 53 | modifier = Modifier 54 | .fillMaxSize() 55 | .clickable(enabled = false) { /*do nothing*/ } 56 | ) 57 | } 58 | AnimatedVisibility( 59 | visible = showSurface, 60 | enter = fadeIn(), 61 | exit = fadeOut(), 62 | ) { 63 | blockingSurface() 64 | } 65 | } 66 | } 67 | 68 | object BlockingBoxDefaults { 69 | const val zeroDelay = 0L 70 | const val shortDelay = 300L 71 | const val mediumDelay = 750L 72 | const val longDelay = 1000L 73 | } 74 | 75 | internal val defaultBlockingSurface: @Composable BoxScope.() -> Unit = { 76 | Surface( 77 | modifier = Modifier.fillMaxSize(), 78 | color = AppTheme.colors.material.surface.copy(alpha = 0.6f) 79 | ) { 80 | Box(contentAlignment = Alignment.Center) { 81 | CircularProgressIndicator() 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 19 | 20 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /tickets/src/test/java/com/moove/tickets/presentation/list/RyderListViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.list 2 | 3 | import com.moove.core.exception.ExceptionHandler 4 | import com.moove.shared.presentation.compose.component.ScreenContentStatus 5 | import com.moove.tickets.domain.model.Ryder 6 | import com.moove.tickets.domain.model.randomRyder 7 | import com.moove.tickets.domain.use_cases.GetRydersUseCase 8 | import com.moove.tickets.presentation.list.model.asPresentation 9 | import io.mockk.coEvery 10 | import io.mockk.coVerify 11 | import io.mockk.mockk 12 | import kotlinx.coroutines.test.TestScope 13 | import kotlinx.coroutines.test.runTest 14 | import org.junit.Test 15 | import org.orbitmvi.orbit.test 16 | 17 | class RyderListViewModelTest { 18 | 19 | companion object { 20 | private val ryder: Ryder = randomRyder() 21 | private val rydersLIst = listOf( 22 | randomRyder(), 23 | ryder, 24 | randomRyder(), 25 | ) 26 | private val defaultState = RyderListState( 27 | status = ScreenContentStatus.Idle, 28 | ryders = emptyList(), 29 | ) 30 | } 31 | 32 | private val getRydersUseCase: GetRydersUseCase = mockk(relaxed = true) 33 | private val exceptionHandler = mockk(relaxed = true) 34 | 35 | private fun TestScope.createViewModel( 36 | state: RyderListState = defaultState, 37 | ) = RyderListViewModel( 38 | exceptionHandler = exceptionHandler, 39 | getRydersUseCase = getRydersUseCase, 40 | ).test( 41 | initialState = state, 42 | buildSettings = { isolateFlow = false } 43 | ) 44 | 45 | @Test 46 | fun `On init fetch ryders successfully`() = runTest { 47 | coEvery { getRydersUseCase() } returns rydersLIst 48 | createViewModel() 49 | .runOnCreate() 50 | .assert(defaultState) { 51 | states( 52 | { copy(status = ScreenContentStatus.Loading) }, 53 | { 54 | copy( 55 | status = ScreenContentStatus.Success, 56 | ryders = rydersLIst.asPresentation() 57 | ) 58 | } 59 | ) 60 | } 61 | coVerify { getRydersUseCase() } 62 | } 63 | 64 | @Test 65 | fun `On init fetch ryders with error should show error message`() = runTest { 66 | val error = RuntimeException("test") 67 | coEvery { getRydersUseCase() } throws error 68 | createViewModel() 69 | .runOnCreate() 70 | .assert(defaultState) { 71 | states( 72 | { copy(status = ScreenContentStatus.Loading) }, 73 | { copy(status = ScreenContentStatus.Failure) } 74 | ) 75 | postedSideEffects(RyderListEffect.ShowGenericError) 76 | } 77 | coVerify { getRydersUseCase() } 78 | } 79 | 80 | @Test 81 | fun `On Ryder click post effect`() = runTest { 82 | createViewModel(defaultState) 83 | .testIntent { onRyderClick(ryder.asPresentation()) } 84 | .assert(defaultState) { 85 | postedSideEffects(RyderListEffect.GoToFares(ryder.id)) 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /design-system/src/main/java/com/moove/design_system/compose/ProgressIndicator.kt: -------------------------------------------------------------------------------- 1 | package com.moove.design_system.compose 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Spacer 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.height 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.rememberScrollState 9 | import androidx.compose.foundation.verticalScroll 10 | import androidx.compose.material.MaterialTheme 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.graphics.Color 15 | import androidx.compose.ui.tooling.preview.Devices 16 | import androidx.compose.ui.tooling.preview.Preview 17 | import androidx.compose.ui.unit.Dp 18 | import androidx.compose.ui.unit.dp 19 | import androidx.compose.material.CircularProgressIndicator as MaterialCircularProgressIndicator 20 | import androidx.compose.material.LinearProgressIndicator as MaterialLinearProgressIndicator 21 | 22 | /** 23 | * @param progress in range (0..1.0) 24 | */ 25 | @Composable 26 | fun LinearProgressIndicator( 27 | progress: Float, 28 | modifier: Modifier = Modifier, 29 | color: Color = MaterialTheme.colors.primary, 30 | backgroundColor: Color = MaterialTheme.colors.primaryVariant 31 | ) = MaterialLinearProgressIndicator( 32 | progress, 33 | modifier, 34 | color, 35 | backgroundColor 36 | ) 37 | 38 | @Composable 39 | fun LinearProgressIndicator( 40 | modifier: Modifier = Modifier, 41 | color: Color = MaterialTheme.colors.primary, 42 | backgroundColor: Color = MaterialTheme.colors.primaryVariant 43 | ) = MaterialLinearProgressIndicator( 44 | modifier, 45 | color, 46 | backgroundColor 47 | ) 48 | 49 | @Composable 50 | fun CircularProgressIndicator( 51 | /*@FloatRange(from = 0.0, to = 1.0)*/ 52 | progress: Float, 53 | modifier: Modifier = Modifier, 54 | color: Color = MaterialTheme.colors.primary, 55 | strokeWidth: Dp = ProgressIndicatorDefaults.StrokeWidth 56 | ) = MaterialCircularProgressIndicator( 57 | progress, 58 | modifier, 59 | color, 60 | strokeWidth 61 | ) 62 | 63 | @Composable 64 | fun CircularProgressIndicator( 65 | modifier: Modifier = Modifier, 66 | color: Color = MaterialTheme.colors.primary, 67 | strokeWidth: Dp = ProgressIndicatorDefaults.StrokeWidth 68 | ) = MaterialCircularProgressIndicator( 69 | modifier, 70 | color, 71 | strokeWidth 72 | ) 73 | 74 | object ProgressIndicatorDefaults { 75 | val StrokeWidth = 4.dp 76 | } 77 | 78 | @Preview(showBackground = true, device = Devices.PIXEL_3_XL) 79 | @Composable 80 | private fun ProgressIndicatorPreview() { 81 | val scrollState = rememberScrollState() 82 | AppTheme { 83 | Column( 84 | modifier = Modifier 85 | .verticalScroll(scrollState) 86 | .fillMaxSize() 87 | .padding(Spacing.L), 88 | horizontalAlignment = Alignment.CenterHorizontally, 89 | ) { 90 | LinearProgressIndicator(progress = 0.5F) 91 | Spacer(Modifier.height(Spacing.M)) 92 | CircularProgressIndicator(progress = 0.5F) 93 | Spacer(Modifier.height(Spacing.M)) 94 | CircularProgressIndicator() 95 | Spacer(Modifier.height(Spacing.M)) 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /design-system/src/main/java/com/moove/design_system/compose/moove/Typography.kt: -------------------------------------------------------------------------------- 1 | package com.moove.design_system.compose.moove 2 | 3 | import androidx.compose.material.Typography 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.text.TextStyle 6 | import androidx.compose.ui.text.font.Font 7 | import androidx.compose.ui.text.font.FontFamily 8 | import androidx.compose.ui.text.font.FontWeight 9 | import androidx.compose.ui.text.style.TextAlign 10 | import androidx.compose.ui.unit.sp 11 | import com.moove.design_system.R 12 | import com.moove.design_system.compose.AppTypography 13 | import com.moove.design_system.compose.TopAppBarTypography 14 | 15 | internal val mooveTypography: AppTypography 16 | @Composable 17 | get() = AppTypography( 18 | material = MooveTextStyles.run { 19 | Typography( 20 | h1 = H1, 21 | h2 = H2, 22 | h3 = H3, 23 | h4 = H4, 24 | subtitle1 = BodyHighlighted, 25 | subtitle2 = ButtonLabel, 26 | body1 = Body, 27 | body2 = BodySmall, 28 | button = ButtonLabel, 29 | caption = BodySmall, 30 | overline = LabelSuperSmall, 31 | ) 32 | }, 33 | appBar = MooveTextStyles.run { 34 | TopAppBarTypography( 35 | title = H4, 36 | titleExpanded = H1, 37 | ) 38 | } 39 | ) 40 | 41 | private object MooveFontFamilies { 42 | 43 | val Quicksand = FontFamily( 44 | Font(R.font.quicksand_bold, weight = FontWeight.Bold) 45 | ) 46 | 47 | val SourceSansPro = FontFamily( 48 | Font(R.font.source_sans_pro), 49 | Font(R.font.source_sans_pro_semibold, weight = FontWeight.SemiBold), 50 | ) 51 | } 52 | 53 | private object MooveTextStyles { 54 | 55 | private val commonBoldTextStyle = TextStyle( 56 | fontFamily = MooveFontFamilies.Quicksand, 57 | fontWeight = FontWeight.Bold, 58 | ) 59 | 60 | private val commonRegularTextStyle = TextStyle( 61 | fontFamily = MooveFontFamilies.SourceSansPro, 62 | fontWeight = FontWeight.Normal, 63 | ) 64 | 65 | val H1 = commonBoldTextStyle.copy( 66 | fontSize = 30.sp, 67 | lineHeight = 45.sp, 68 | ) 69 | 70 | val H2 = commonBoldTextStyle.copy( 71 | fontSize = 26.sp, 72 | lineHeight = 39.sp, 73 | ) 74 | 75 | val H3 = commonBoldTextStyle.copy( 76 | fontSize = 22.sp, 77 | lineHeight = 33.sp, 78 | ) 79 | 80 | val H4 = commonBoldTextStyle.copy( 81 | fontSize = 18.sp, 82 | lineHeight = 27.sp, 83 | ) 84 | 85 | val Body = commonRegularTextStyle.copy( 86 | fontSize = 16.sp, 87 | lineHeight = 24.sp, 88 | ) 89 | 90 | val BodyHighlighted = Body.copy( 91 | fontWeight = FontWeight.SemiBold, 92 | ) 93 | 94 | val BodySmall = Body.copy( 95 | fontSize = 14.sp, 96 | lineHeight = 21.sp, 97 | ) 98 | 99 | val LabelSuperSmall = commonRegularTextStyle.copy( 100 | fontSize = 12.sp, 101 | fontWeight = FontWeight.SemiBold, 102 | lineHeight = 18.sp, 103 | ) 104 | 105 | val ButtonLabel = commonBoldTextStyle.copy( 106 | fontSize = 16.sp, 107 | textAlign = TextAlign.Center, 108 | lineHeight = 24.sp, 109 | ) 110 | } 111 | -------------------------------------------------------------------------------- /tickets/src/test/java/com/moove/tickets/presentation/fare/FareListViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.fare 2 | 3 | import com.moove.core.exception.ExceptionHandler 4 | import com.moove.shared.presentation.compose.component.ScreenContentStatus 5 | import com.moove.tickets.domain.model.Fare 6 | import com.moove.tickets.domain.model.Ryder 7 | import com.moove.tickets.domain.model.randomRyder 8 | import com.moove.tickets.domain.use_cases.GetFaresByIdUseCase 9 | import com.moove.tickets.presentation.fare.model.asPresentation 10 | import io.mockk.coEvery 11 | import io.mockk.coVerify 12 | import io.mockk.mockk 13 | import kotlinx.coroutines.test.TestScope 14 | import kotlinx.coroutines.test.runTest 15 | import org.junit.Test 16 | import org.orbitmvi.orbit.test 17 | 18 | class FareListViewModelTest { 19 | 20 | companion object { 21 | private val ryder: Ryder = randomRyder() 22 | private val ryderId: String = ryder.id 23 | private val faresList: List = ryder.fares 24 | private val fare: Fare = ryder.fares.first() 25 | 26 | private val defaultState = FareListState( 27 | status = ScreenContentStatus.Idle, 28 | fares = emptyList(), 29 | ) 30 | } 31 | 32 | private val getFaresByIdUseCase: GetFaresByIdUseCase = mockk(relaxed = true) 33 | private val exceptionHandler = mockk(relaxed = true) 34 | 35 | private fun TestScope.createViewModel( 36 | state: FareListState = defaultState, 37 | ) = FareListViewModel( 38 | exceptionHandler = exceptionHandler, 39 | getFaresByIdUseCase = getFaresByIdUseCase, 40 | ryderId = ryderId, 41 | ).test( 42 | initialState = state, 43 | buildSettings = { isolateFlow = false } 44 | ) 45 | 46 | @Test 47 | fun `On init fetch fares successfully`() = runTest { 48 | coEvery { getFaresByIdUseCase(ryderId) } returns faresList 49 | createViewModel() 50 | .runOnCreate() 51 | .assert(defaultState) { 52 | states( 53 | { copy(status = ScreenContentStatus.Loading) }, 54 | { 55 | copy( 56 | status = ScreenContentStatus.Success, 57 | fares = faresList.asPresentation() 58 | ) 59 | } 60 | ) 61 | } 62 | coVerify { getFaresByIdUseCase(ryderId) } 63 | } 64 | 65 | @Test 66 | fun `On init fetch fares with error should show error message`() = runTest { 67 | val error = RuntimeException("test") 68 | coEvery { getFaresByIdUseCase(ryderId) } throws error 69 | createViewModel() 70 | .runOnCreate() 71 | .assert(defaultState) { 72 | states( 73 | { copy(status = ScreenContentStatus.Loading) }, 74 | { copy(status = ScreenContentStatus.Failure) } 75 | ) 76 | postedSideEffects(FareListEffect.ShowGenericError) 77 | } 78 | coVerify { getFaresByIdUseCase(ryderId) } 79 | } 80 | 81 | @Test 82 | fun `On Fare click post effect`() = runTest { 83 | createViewModel(defaultState) 84 | .testIntent { onFareClick(fare.asPresentation()) } 85 | .assert(defaultState) { 86 | postedSideEffects( 87 | FareListEffect.GoToConfirmation( 88 | ryderId = ryder.id, 89 | fare = fare.asPresentation() 90 | ) 91 | ) 92 | } 93 | } 94 | } -------------------------------------------------------------------------------- /app/src/main/java/com/moove/app/feature/deeplink/data/local/AppDeepLinkLocalDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.moove.app.feature.deeplink.data.local 2 | 3 | import com.moove.app.feature.deeplink.domain.AppDeepLink 4 | import com.moove.core.kotlin.text.matchesPattern 5 | import com.moove.shared.feature.deeplink.domain.DeepLink 6 | import com.moove.tickets.domain.model.Fare 7 | import kotlinx.coroutines.CoroutineDispatcher 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.withContext 10 | import java.net.URI 11 | import java.net.URLDecoder 12 | import java.nio.charset.StandardCharsets 13 | 14 | class AppDeepLinkLocalDataSource( 15 | private val backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO, 16 | ) { 17 | 18 | companion object { 19 | private const val RYDER_ID = "ryderId" 20 | private const val PRICE = "price" 21 | 22 | const val HOME = "moove://app/home" 23 | const val FARE_LIST = "moove://app/fare_list" 24 | const val CONFIRM_CONFIRMATION = "/ticket/confirmation" 25 | const val MOOVE_CONFIRM_CONFIRMATION = "moove://app/confirmation" 26 | } 27 | 28 | suspend fun getDeepLinkData(uri: String): DeepLink = withContext(backgroundDispatcher) { 29 | when { 30 | uri.matchesPattern(CONFIRM_CONFIRMATION) -> { 31 | val innerUri = URI.create(uri) 32 | val params = getQueryParams(innerUri) 33 | AppDeepLink.Confirmation( 34 | ryderId = params[RYDER_ID]!!, 35 | fare = Fare( 36 | description = "", 37 | price = params[PRICE]?.toFloat()!! 38 | ), 39 | ) 40 | } 41 | 42 | uri.matchesPattern(MOOVE_CONFIRM_CONFIRMATION) -> { 43 | val innerUri = URI.create(uri) 44 | val params = getQueryParams(innerUri) 45 | AppDeepLink.Confirmation( 46 | ryderId = params[RYDER_ID]!!, 47 | fare = Fare( 48 | description = "", 49 | price = params[PRICE]?.toFloat()!! 50 | ), 51 | ) 52 | } 53 | 54 | uri.isThat(FARE_LIST) -> { 55 | val innerUri = URI.create(uri) 56 | val params = getQueryParams(innerUri) 57 | AppDeepLink.FareList(ryderId = params[RYDER_ID]!!) 58 | } 59 | 60 | uri.isThat(HOME) || uri.matchesPattern(HOME) -> AppDeepLink.Home 61 | else -> AppDeepLink.Unknown 62 | } 63 | } 64 | 65 | private fun String.isThat(type: String): Boolean { 66 | /** 67 | * Handle two cases with slash symbol at the end and without it 68 | * app/home/ and app/home 69 | */ 70 | return contains(type, ignoreCase = true) 71 | } 72 | 73 | private fun getQueryParams(url: URI): Map { 74 | val query = url.query ?: return emptyMap() 75 | return query 76 | .split("&".toRegex()) 77 | .filter { it.isNotEmpty() } 78 | .map(::mapQueryParameter) 79 | .associateBy(keySelector = { it.first }, valueTransform = { it.second }) 80 | } 81 | 82 | private fun mapQueryParameter(query: String): Pair { 83 | val index = query.indexOf("=") 84 | val key = if (index > 0) query.substring(0, index) else query 85 | val value = if (index > 0 && query.length > index + 1) { 86 | query.substring(index + 1) 87 | } else null 88 | return Pair( 89 | URLDecoder.decode(key, StandardCharsets.UTF_8.name()), 90 | URLDecoder.decode(value, StandardCharsets.UTF_8.name()) 91 | ) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /tickets/src/main/java/com/moove/tickets/presentation/confirmation/ConfirmationScreen.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.confirmation 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.navigationBarsPadding 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.layout.statusBarsPadding 9 | import androidx.compose.material.ScaffoldState 10 | import androidx.compose.material.Text 11 | import androidx.compose.material.TopAppBar 12 | import androidx.compose.material.rememberScaffoldState 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.res.stringResource 17 | import androidx.compose.ui.tooling.preview.Preview 18 | import com.moove.design_system.compose.AppButtonDefaults 19 | import com.moove.design_system.compose.AppTheme 20 | import com.moove.design_system.compose.PrimaryButton 21 | import com.moove.design_system.compose.Scaffold 22 | import com.moove.design_system.compose.Spacing 23 | import com.moove.shared.R 24 | import com.moove.shared.presentation.compose.component.ScreenContentStatus 25 | import com.moove.tickets.presentation.confirmation.component.ConfirmationItem 26 | import com.moove.tickets.presentation.fare.model.fakeFareModels 27 | 28 | @Composable 29 | fun ConfirmationScreen( 30 | uiState: ConfirmationState, 31 | scaffoldState: ScaffoldState = rememberScaffoldState(), 32 | onIncrementTicketClick: () -> Unit, 33 | onDecrementTicketClick: () -> Unit, 34 | onConfirmClick: () -> Unit, 35 | ) { 36 | Scaffold( 37 | modifier = Modifier 38 | .statusBarsPadding() 39 | .navigationBarsPadding(), 40 | scaffoldState = scaffoldState, 41 | topBar = { 42 | TopAppBar( 43 | title = { 44 | Text(text = stringResource(id = R.string.confirm_selection_title)) 45 | }, 46 | backgroundColor = AppTheme.colors.material.surface, 47 | ) 48 | }, 49 | content = { 50 | Box( 51 | modifier = Modifier 52 | .fillMaxSize() 53 | .padding(bottom = Spacing.S) 54 | ) { 55 | ConfirmationItem( 56 | modifier = Modifier.align(Alignment.TopStart), 57 | ryderId = uiState.ryderId, 58 | fare = uiState.fare, 59 | ticketCount = uiState.ticketCount, 60 | onIncrementTicketClick = onIncrementTicketClick, 61 | onDecrementTicketClick = onDecrementTicketClick 62 | ) 63 | 64 | PrimaryButton( 65 | modifier = AppButtonDefaults.Modifier 66 | .fillMaxWidth() 67 | .align(Alignment.BottomCenter) 68 | .padding(horizontal = Spacing.S), 69 | onClick = onConfirmClick, 70 | content = { 71 | Text( 72 | text = stringResource( 73 | id = R.string.confirm_button_text, 74 | uiState.ticketCount, 75 | "$${uiState.totalPrice}" 76 | ) 77 | ) 78 | }, 79 | ) 80 | } 81 | } 82 | ) 83 | } 84 | 85 | @Preview(name = "Confirmation Content", showBackground = true) 86 | @Composable 87 | fun PreviewConfirmationContent() { 88 | AppTheme { 89 | ConfirmationScreen( 90 | uiState = ConfirmationState( 91 | status = ScreenContentStatus.Success, 92 | ryderId = "Adult", 93 | fare = fakeFareModels.first(), 94 | ticketCount = 2, 95 | totalPrice = fakeFareModels.first().price * 2 96 | ), 97 | onIncrementTicketClick = {}, 98 | onDecrementTicketClick = {}, 99 | onConfirmClick = {}, 100 | ) 101 | } 102 | } -------------------------------------------------------------------------------- /shared/src/main/java/com/moove/shared/presentation/compose/component/GenericError.kt: -------------------------------------------------------------------------------- 1 | package com.moove.shared.presentation.compose.component 2 | 3 | import android.content.Context 4 | import androidx.compose.material.BottomSheetScaffoldState 5 | import androidx.compose.material.ExperimentalMaterialApi 6 | import androidx.compose.material.Scaffold 7 | import androidx.compose.material.ScaffoldState 8 | import androidx.compose.material.SnackbarDuration 9 | import androidx.compose.material.SnackbarHost 10 | import androidx.compose.material.SnackbarResult 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.LaunchedEffect 13 | import androidx.compose.ui.res.stringResource 14 | import com.moove.shared.R 15 | 16 | /** 17 | * Shows a generic error [Snackbar] at the bottom of the [Scaffold]. 18 | * 19 | * @param message text to be shown in the [Snackbar] 20 | * @param duration duration to control how long snackbar will be shown in [SnackbarHost], either 21 | * [SnackbarDuration.Short], [SnackbarDuration.Long] or [SnackbarDuration.Indefinite] 22 | * @param onGenericErrorDismissed function executed when the [Snackbar] is dismissed because 23 | * the duration expires or the user close it 24 | */ 25 | @Composable 26 | fun ScaffoldState.ShowGenericError( 27 | message: String = genericErrorString, 28 | duration: SnackbarDuration = SnackbarDuration.Short, 29 | onGenericErrorDismissed: () -> Unit = {} 30 | ) = LaunchedEffect(snackbarHostState) { 31 | snackbarHostState.showSnackbar( 32 | message = message, 33 | duration = duration, 34 | ).let { 35 | if (it == SnackbarResult.Dismissed) { 36 | onGenericErrorDismissed() 37 | } 38 | } 39 | } 40 | 41 | /** 42 | * Shows a generic error [Snackbar] at the bottom of the [Scaffold]. 43 | * 44 | * @param duration duration to control how long snackbar will be shown in [SnackbarHost], either 45 | * [SnackbarDuration.Short], [SnackbarDuration.Long] or [SnackbarDuration.Indefinite] 46 | * @param onGenericErrorDismissed function executed when the [Snackbar] is dismissed because 47 | * the duration expires or the user close it 48 | */ 49 | suspend fun ScaffoldState.showGenericError( 50 | context: Context, 51 | duration: SnackbarDuration = SnackbarDuration.Short, 52 | onGenericErrorDismissed: () -> Unit = {} 53 | ) { 54 | val error = context.getString(R.string.global_snackbar_genericerror_title) 55 | showSnackBar(error, duration, onGenericErrorDismissed) 56 | } 57 | 58 | /** 59 | * Shows a generic error [Snackbar] at the bottom of the [BottomSheetScaffold]. 60 | * 61 | * @param duration duration to control how long snackbar will be shown in [SnackbarHost], either 62 | * [SnackbarDuration.Short], [SnackbarDuration.Long] or [SnackbarDuration.Indefinite] 63 | * @param onGenericErrorDismissed function executed when the [Snackbar] is dismissed because 64 | * the duration expires or the user close it 65 | */ 66 | @OptIn(ExperimentalMaterialApi::class) 67 | suspend fun BottomSheetScaffoldState.showGenericError( 68 | context: Context, 69 | duration: SnackbarDuration = SnackbarDuration.Short, 70 | onGenericErrorDismissed: () -> Unit = {} 71 | ) { 72 | val error = context.getString(R.string.global_snackbar_genericerror_title) 73 | showSnackBar(error, duration, onGenericErrorDismissed) 74 | } 75 | 76 | /** 77 | * Shows a [Snackbar] at the bottom of the [Scaffold]. 78 | * 79 | * @param message text to be shown in the [Snackbar] 80 | * @param duration duration to control how long snackbar will be shown in [SnackbarHost], either 81 | * [SnackbarDuration.Short], [SnackbarDuration.Long] or [SnackbarDuration.Indefinite] 82 | * @param onDismissed function executed when the [Snackbar] is dismissed because 83 | * the duration expires or the user close it 84 | */ 85 | suspend fun ScaffoldState.showSnackBar( 86 | message: String, 87 | duration: SnackbarDuration = SnackbarDuration.Short, 88 | onDismissed: () -> Unit = {} 89 | ) = snackbarHostState.showSnackbar( 90 | message = message, 91 | duration = duration, 92 | ).let { 93 | if (it == SnackbarResult.Dismissed) { 94 | onDismissed() 95 | } 96 | } 97 | 98 | /** 99 | * Shows a [Snackbar] at the bottom of the [BottomSheetScaffold]. 100 | * 101 | * @param message text to be shown in the [Snackbar] 102 | * @param duration duration to control how long snackbar will be shown in [SnackbarHost], either 103 | * [SnackbarDuration.Short], [SnackbarDuration.Long] or [SnackbarDuration.Indefinite] 104 | * @param onDismissed function executed when the [Snackbar] is dismissed because 105 | * the duration expires or the user close it 106 | */ 107 | @OptIn(ExperimentalMaterialApi::class) 108 | suspend fun BottomSheetScaffoldState.showSnackBar( 109 | message: String, 110 | duration: SnackbarDuration = SnackbarDuration.Short, 111 | onDismissed: () -> Unit = {} 112 | ) = snackbarHostState.showSnackbar( 113 | message = message, 114 | duration = duration, 115 | ).let { 116 | if (it == SnackbarResult.Dismissed) { 117 | onDismissed() 118 | } 119 | } 120 | 121 | val genericErrorString: String 122 | @Composable 123 | get() = stringResource(id = R.string.global_snackbar_genericerror_title) 124 | -------------------------------------------------------------------------------- /tickets/src/test/java/com/moove/tickets/presentation/confirmation/ConfirmationViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.moove.tickets.presentation.confirmation 2 | 3 | import com.moove.core.exception.ExceptionHandler 4 | import com.moove.shared.presentation.compose.component.ScreenContentStatus 5 | import com.moove.tickets.domain.model.Fare 6 | import com.moove.tickets.domain.model.Ryder 7 | import com.moove.tickets.domain.model.randomRyder 8 | import com.moove.tickets.domain.use_cases.BuyTicketUseCase 9 | import com.moove.tickets.presentation.fare.model.FareModel 10 | import com.moove.tickets.presentation.fare.model.asDomain 11 | import com.moove.tickets.presentation.fare.model.asPresentation 12 | import io.mockk.coEvery 13 | import io.mockk.coVerify 14 | import io.mockk.mockk 15 | import kotlinx.coroutines.test.TestScope 16 | import kotlinx.coroutines.test.runTest 17 | import org.junit.Test 18 | import org.orbitmvi.orbit.test 19 | 20 | class ConfirmationViewModelTest { 21 | 22 | companion object { 23 | private val ryder: Ryder = randomRyder() 24 | private val ryderId: String = ryder.id 25 | private val faresList: List = ryder.fares 26 | private val fare: FareModel = ryder.fares.first().asPresentation() 27 | 28 | private val defaultState = ConfirmationState( 29 | status = ScreenContentStatus.Idle, 30 | ryderId = ryderId, 31 | fare = fare, 32 | ticketCount = 1, 33 | totalPrice = 0f, 34 | ) 35 | } 36 | 37 | private val buyTicketUseCase: BuyTicketUseCase = mockk(relaxed = true) 38 | private val exceptionHandler = mockk(relaxed = true) 39 | 40 | private fun TestScope.createViewModel( 41 | state: ConfirmationState = defaultState, 42 | ) = ConfirmationViewModel( 43 | exceptionHandler = exceptionHandler, 44 | buyTicketUseCase = buyTicketUseCase, 45 | ryderId = ryderId, 46 | fare = fare, 47 | ).test( 48 | initialState = state, 49 | buildSettings = { isolateFlow = false } 50 | ) 51 | 52 | @Test 53 | fun `On Confirm click post effect`() = runTest { 54 | createViewModel(defaultState) 55 | .testIntent { onConfirmClick() } 56 | .assert(defaultState) { 57 | states( 58 | { copy(status = ScreenContentStatus.Loading) }, 59 | { copy(status = ScreenContentStatus.Success) } 60 | ) 61 | postedSideEffects(ConfirmationEffect.ShowSuccessMessage) 62 | } 63 | 64 | coVerify { 65 | buyTicketUseCase( 66 | ryderId = ryderId, 67 | fare = fare.asDomain(), 68 | totalCount = defaultState.ticketCount 69 | ) 70 | } 71 | } 72 | 73 | @Test 74 | fun `On Confirm click get error should post effect`() = runTest { 75 | val error = RuntimeException("test") 76 | coEvery { 77 | buyTicketUseCase( 78 | ryderId = ryderId, 79 | fare = fare.asDomain(), 80 | totalCount = defaultState.ticketCount 81 | ) 82 | } throws error 83 | 84 | createViewModel(defaultState) 85 | .testIntent { onConfirmClick() } 86 | .assert(defaultState) { 87 | states( 88 | { copy(status = ScreenContentStatus.Loading) }, 89 | { copy(status = ScreenContentStatus.Failure) } 90 | ) 91 | postedSideEffects(ConfirmationEffect.ShowGenericError) 92 | } 93 | 94 | coVerify { 95 | buyTicketUseCase( 96 | ryderId = ryderId, 97 | fare = fare.asDomain(), 98 | totalCount = defaultState.ticketCount 99 | ) 100 | } 101 | } 102 | 103 | @Test 104 | fun `On Increment Ticket click should update state`() = runTest { 105 | val ticketCount = defaultState.ticketCount + 1 106 | val totalPrice = defaultState.fare.price * ticketCount 107 | 108 | createViewModel(defaultState) 109 | .testIntent { onIncrementTicketClick() } 110 | .assert(defaultState) { 111 | states( 112 | { 113 | copy( 114 | ticketCount = ticketCount, 115 | totalPrice = totalPrice 116 | ) 117 | }, 118 | ) 119 | } 120 | } 121 | 122 | @Test 123 | fun `On Decrement Ticket click should update state`() = runTest { 124 | val ticketCount = defaultState.ticketCount - 1 125 | val totalPrice = defaultState.fare.price * ticketCount 126 | 127 | createViewModel(defaultState) 128 | .testIntent { onDecrementTicketClick() } 129 | .assert(defaultState) { 130 | states( 131 | { 132 | copy( 133 | ticketCount = ticketCount, 134 | totalPrice = totalPrice 135 | ) 136 | }, 137 | ) 138 | } 139 | } 140 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | 16 | 21 | 26 | 31 | 36 | 41 | 46 | 51 | 56 | 61 | 66 | 71 | 76 | 81 | 86 | 91 | 96 | 101 | 106 | 111 | 116 | 121 | 126 | 131 | 136 | 141 | 146 | 151 | 156 | 161 | 166 | 171 | 172 | --------------------------------------------------------------------------------