├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── kotlin │ └── com │ │ └── redmadrobot │ │ └── mad │ │ ├── AppRouter.kt │ │ ├── MADApp.kt │ │ ├── MainActivity.kt │ │ └── di │ │ └── modules │ │ ├── AppModule.kt │ │ └── NetworkModule.kt │ └── res │ ├── drawable-v24 │ └── ic_launcher_foreground.xml │ ├── drawable │ └── ic_launcher_background.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-mdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ └── values │ ├── colors.xml │ ├── strings.xml │ └── themes.xml ├── base └── base_cards │ ├── build.gradle.kts │ └── src │ └── main │ ├── AndroidManifest.xml │ ├── kotlin │ └── com │ │ └── redmadrobot │ │ └── base_cards │ │ ├── CardViewStateConverterExt.kt │ │ └── model │ │ ├── CardBorderColors.kt │ │ ├── CardBorderGradient.kt │ │ ├── CardColors.kt │ │ ├── CardGradient.kt │ │ ├── CardTextNumberColor.kt │ │ ├── CardTypeIcon.kt │ │ └── CardViewState.kt │ └── res │ └── drawable │ ├── ic_circle_arrow.xml │ ├── ic_iron_man.xml │ ├── ic_master.xml │ ├── ic_subtract.xml │ └── ic_visa.xml ├── build.gradle.kts ├── buildSrc ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── kotlin │ ├── AndroidConfig.kt │ ├── CoreDependency.kt │ ├── GradlePluginDependency.kt │ ├── NetworkDependency.kt │ ├── PresentationDependency.kt │ └── com │ └── redmadrobot │ └── mad │ └── plugins │ ├── CommonAndroidConfigPlugin.kt │ └── Extensions.kt ├── core ├── core │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ └── kotlin │ │ └── com │ │ └── redmadrobot │ │ └── core │ │ └── extensions │ │ └── CoroutinesExtensions.kt ├── core_navigation │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ └── kotlin │ │ └── com │ │ └── redmadrobot │ │ └── core_navigation │ │ └── navigation │ │ ├── Route.kt │ │ ├── Router.kt │ │ └── screens │ │ └── Routes.kt ├── core_network │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ ├── graphql │ │ └── com │ │ │ └── redmadrobot │ │ │ └── core_network │ │ │ ├── .graphqlconfig │ │ │ ├── queries.graphql │ │ │ └── schema.json │ │ ├── kotlin │ │ └── com │ │ │ └── redmadrobot │ │ │ └── core_network │ │ │ ├── ApiFactory.kt │ │ │ ├── ApolloApi.kt │ │ │ ├── NetworkConstants.kt │ │ │ └── exception │ │ │ ├── NetworkException.kt │ │ │ ├── NetworkExceptionHandler.kt │ │ │ ├── ServerException.kt │ │ │ └── ServerExceptionFactory.kt │ │ └── res │ │ └── values │ │ └── strings.xml └── core_presentation │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ └── main │ ├── AndroidManifest.xml │ └── kotlin │ └── com │ └── redmadrobot │ └── core_presentation │ ├── extensions │ └── StateFlowExtensions.kt │ └── model │ └── State.kt ├── fake_server ├── .gitignore ├── README.md ├── accounts │ ├── index.js │ └── permissions.js ├── config.js ├── data.js ├── index.js ├── package-lock.json └── package.json ├── features ├── auth │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ └── com │ │ │ └── redmadrobot │ │ │ └── auth │ │ │ └── presentation │ │ │ ├── AuthScreen.kt │ │ │ ├── AuthState.kt │ │ │ └── AuthViewModel.kt │ │ └── res │ │ └── values │ │ └── strings.xml ├── details │ ├── build.gradle.kts │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ └── com │ │ │ └── redmadrobot │ │ │ └── details │ │ │ └── presentation │ │ │ ├── DetailsScreen.kt │ │ │ ├── DetailsViewModel.kt │ │ │ ├── DetailsViewState.kt │ │ │ ├── model │ │ │ └── CardDetailsParams.kt │ │ │ └── view │ │ │ └── CardDetailsView.kt │ │ └── res │ │ └── drawable │ │ ├── ic_circle_arrow.xml │ │ └── ic_iron_man.xml └── home │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ └── main │ ├── AndroidManifest.xml │ └── kotlin │ └── com │ └── redmadrobot │ └── home │ └── presentation │ ├── HomeScreen.kt │ ├── HomeViewModel.kt │ ├── state │ └── HomeViewState.kt │ └── view │ └── CardView.kt ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Android template 3 | # Built application files 4 | *.apk 5 | *.ap_ 6 | *.aab 7 | 8 | # Files for the ART/Dalvik VM 9 | *.dex 10 | 11 | # Java class files 12 | *.class 13 | 14 | # Generated files 15 | bin/ 16 | gen/ 17 | out/ 18 | release/ 19 | 20 | # Gradle files 21 | .gradle/ 22 | build/ 23 | 24 | # Local configuration file (sdk path, etc) 25 | local.properties 26 | 27 | # Log Files 28 | *.log 29 | 30 | # Android Studio Navigation editor temp files 31 | .navigation/ 32 | 33 | # Android Studio captures folder 34 | captures/ 35 | 36 | # IntelliJ 37 | *.iml 38 | .idea/ 39 | 40 | # Keystore files 41 | # Uncomment the following lines if you do not want to check your keystore files in. 42 | #*.jks 43 | #*.keystore 44 | 45 | # External native build folder generated in Android Studio 2.2 and later 46 | .externalNativeBuild 47 | 48 | # Google Services (e.g. APIs or Firebase) 49 | # google-services.json 50 | 51 | # Freeline 52 | freeline.py 53 | freeline/ 54 | freeline_project_description.json 55 | 56 | # fastlane 57 | fastlane/report.xml 58 | fastlane/Preview.html 59 | fastlane/screenshots 60 | fastlane/test_output 61 | fastlane/readme.md 62 | 63 | # Version control 64 | vcs.xml 65 | 66 | # lint 67 | lint/intermediates/ 68 | lint/generated/ 69 | lint/outputs/ 70 | lint/tmp/ 71 | # lint/reports/ 72 | 73 | # Crashlytics plugin (for Android Studio and IntelliJ) 74 | com_crashlytics_export_strings.xml 75 | crashlytics.properties 76 | crashlytics-build.properties 77 | .env 78 | fabric.properties -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Redmadrobot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Android MAD Showcase 2 | 3 | Showcase is a sample Android project that presents Modern Android Development (MAD) libraries and practices. 4 | 5 | The goal of the project is to demonstrate modern tech-stack and take a fresh look at the development of Android applications. 6 | 7 | # Latest News 8 | 9 | **February 11, 2023** Updated to Compose 1.3.3 🎉🎉🎉 10 | 11 | **September 09, 2022** Updated to Compose 1.2.1 12 | 13 | **August 04, 2021** Updated to Compose 1.0.0 14 | 15 | ## Stack 16 | 17 | * Kotlin 18 | * Compose 19 | * Compose Navigation 20 | * Coroutines/Flow 21 | * GraphQl/Appollo 22 | * Jetpack ViewModel 23 | * Hilt 24 | * Gradle Kotlin DSL 25 | 26 | ## Known Issues 27 | 28 | * Cannot use Hilt in a layered multi-module project with `api` gradle configuration https://dagger.dev/hilt/gradle-setup#classpath-aggregation 29 | 30 | ## License 31 | ``` 32 | MIT License 33 | 34 | Copyright (c) 2023 Redmadrobot 35 | 36 | Permission is hereby granted, free of charge, to any person obtaining a copy 37 | of this software and associated documentation files (the "Software"), to deal 38 | in the Software without restriction, including without limitation the rights 39 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 40 | copies of the Software, and to permit persons to whom the Software is 41 | furnished to do so, subject to the following conditions: 42 | 43 | The above copyright notice and this permission notice shall be included in all 44 | copies or substantial portions of the Software. 45 | 46 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 47 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 48 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 49 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 50 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 51 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 52 | SOFTWARE. 53 | ``` 54 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id(GradlePluginId.ANDROID_APPLICATION) 3 | id(GradlePluginId.ANDROID_COMMON_CONFIG) 4 | kotlin(GradlePluginId.KAPT) 5 | id(GradlePluginId.HILT) 6 | } 7 | 8 | android { 9 | defaultConfig { 10 | applicationId = "com.redmadrobot.mad" 11 | } 12 | } 13 | 14 | dependencies { 15 | implementation(project(":core:core")) 16 | implementation(project(":base:base_cards")) 17 | 18 | implementation(project(":features:auth")) 19 | implementation(project(":features:home")) 20 | implementation(project(":features:details")) 21 | 22 | implementation(CoreDependency.HILT) 23 | kapt(CoreDependency.HILT_COMPILER) 24 | } 25 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 15 | 16 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/redmadrobot/mad/AppRouter.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.mad 2 | 3 | import androidx.navigation.NavController 4 | import com.redmadrobot.core_navigation.navigation.Router 5 | import com.redmadrobot.core_navigation.navigation.Route 6 | 7 | class AppRouter : Router { 8 | 9 | private lateinit var navController: NavController 10 | 11 | fun init(navController: NavController) { 12 | this.navController = navController 13 | } 14 | 15 | override fun navigate(route: Route) { 16 | navController.navigate(route.name) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/redmadrobot/mad/MADApp.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.mad 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | @HiltAndroidApp 7 | class MADApp : Application() { 8 | 9 | val appRouter = AppRouter() 10 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/redmadrobot/mad/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.mad 2 | 3 | import android.os.Bundle 4 | import androidx.activity.compose.setContent 5 | import androidx.appcompat.app.AppCompatActivity 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.ExperimentalComposeUiApi 8 | import androidx.hilt.navigation.compose.hiltViewModel 9 | import androidx.navigation.compose.NavHost 10 | import androidx.navigation.compose.rememberNavController 11 | import com.redmadrobot.auth.presentation.AuthRoute 12 | import com.redmadrobot.core_navigation.navigation.composableScreen 13 | import com.redmadrobot.core_navigation.navigation.screens.Routes 14 | import com.redmadrobot.details.presentation.DetailsRoute 15 | import com.redmadrobot.home.presentation.HomeRoute 16 | import com.redmadrobot.home.presentation.HomeScreen 17 | import dagger.hilt.android.AndroidEntryPoint 18 | 19 | @AndroidEntryPoint 20 | @ExperimentalComposeUiApi 21 | class MainActivity : AppCompatActivity() { 22 | 23 | private val router: AppRouter 24 | get() = (application as MADApp).appRouter 25 | 26 | override fun onCreate(savedInstanceState: Bundle?) { 27 | super.onCreate(savedInstanceState) 28 | setContent { 29 | Content() 30 | } 31 | } 32 | 33 | @Composable 34 | fun Content() { 35 | val navController = rememberNavController() 36 | router.init(navController) 37 | NavHost(navController, startDestination = Routes.Auth.name) { 38 | composableScreen(Routes.Auth) { 39 | AuthRoute() 40 | } 41 | composableScreen(Routes.Home) { 42 | HomeRoute() 43 | } 44 | composableScreen(Routes.Details) { 45 | DetailsRoute() 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/redmadrobot/mad/di/modules/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.mad.di.modules 2 | 3 | import android.app.Application 4 | import com.redmadrobot.core_navigation.navigation.Router 5 | import com.redmadrobot.mad.MADApp 6 | import dagger.Module 7 | import dagger.Provides 8 | import dagger.hilt.InstallIn 9 | import dagger.hilt.components.SingletonComponent 10 | 11 | @Module 12 | @InstallIn(SingletonComponent::class) 13 | class AppModule { 14 | 15 | @Provides 16 | fun provideRouter(app: Application): Router { 17 | return (app as MADApp).appRouter 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/redmadrobot/mad/di/modules/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.mad.di.modules 2 | 3 | import android.content.Context 4 | import com.redmadrobot.core_network.ApiFactory 5 | import com.redmadrobot.core_network.ApolloApi 6 | import com.redmadrobot.core_network.exception.NetworkExceptionHandler 7 | import dagger.Module 8 | import dagger.Provides 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.android.qualifiers.ApplicationContext 11 | import dagger.hilt.components.SingletonComponent 12 | 13 | @Module 14 | @InstallIn(SingletonComponent::class) 15 | class NetworkModule { 16 | 17 | @Provides 18 | fun provideApi(networkExceptionHandler: NetworkExceptionHandler): ApolloApi { 19 | return ApiFactory.provideApi(networkExceptionHandler) 20 | } 21 | 22 | @Provides 23 | fun provideNetworkExceptionHandler(@ApplicationContext context: Context): NetworkExceptionHandler { 24 | return NetworkExceptionHandler(context) 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedMadRobot/android-mad-showcase/9e90bf0432eb9170ce7e73e0fa779ea4e7f8cd6b/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedMadRobot/android-mad-showcase/9e90bf0432eb9170ce7e73e0fa779ea4e7f8cd6b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedMadRobot/android-mad-showcase/9e90bf0432eb9170ce7e73e0fa779ea4e7f8cd6b/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedMadRobot/android-mad-showcase/9e90bf0432eb9170ce7e73e0fa779ea4e7f8cd6b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedMadRobot/android-mad-showcase/9e90bf0432eb9170ce7e73e0fa779ea4e7f8cd6b/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedMadRobot/android-mad-showcase/9e90bf0432eb9170ce7e73e0fa779ea4e7f8cd6b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedMadRobot/android-mad-showcase/9e90bf0432eb9170ce7e73e0fa779ea4e7f8cd6b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedMadRobot/android-mad-showcase/9e90bf0432eb9170ce7e73e0fa779ea4e7f8cd6b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedMadRobot/android-mad-showcase/9e90bf0432eb9170ce7e73e0fa779ea4e7f8cd6b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedMadRobot/android-mad-showcase/9e90bf0432eb9170ce7e73e0fa779ea4e7f8cd6b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | #434A74 11 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | MAD 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | -------------------------------------------------------------------------------- /base/base_cards/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id(GradlePluginId.ANDROID_LIBRARY) 3 | id(GradlePluginId.ANDROID_COMMON_CONFIG) 4 | } 5 | 6 | dependencies { 7 | implementation(project(":core:core")) 8 | } -------------------------------------------------------------------------------- /base/base_cards/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /base/base_cards/src/main/kotlin/com/redmadrobot/base_cards/CardViewStateConverterExt.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.base_cards 2 | 3 | import androidx.compose.ui.graphics.Brush 4 | import androidx.compose.ui.graphics.Color 5 | import com.redmadrobot.base_cards.model.* 6 | import com.redmadrobot.core_network.CardDetailsQuery 7 | import com.redmadrobot.core_network.CardsListQuery 8 | 9 | // TODO: split two methods, and work with one data stucture by using fragments from appolo library 10 | 11 | fun CardsListQuery.Card.toCardViewState(): CardViewState { 12 | return CardViewState( 13 | id = id, 14 | number = number, 15 | cardColors = toCardColors(color), 16 | cardBorderColors = toCardBorderCardColors(color), 17 | cardTextNumberColor = toCardTextNumberColor(color), 18 | cardGradient = toCardGradient(color), 19 | borderGradient = toCardBorderGradient(color), 20 | iconRes = toIconRes(type), 21 | iconColor = toIconColor(color), 22 | ) 23 | } 24 | 25 | fun CardDetailsQuery.Card.toCardViewState(): CardViewState { 26 | return CardViewState( 27 | id = id, 28 | number = number, 29 | cardColors = toCardColors(color), 30 | cardBorderColors = toCardBorderCardColors(color), 31 | cardTextNumberColor = toCardTextNumberColor(color), 32 | cardGradient = toCardGradient(color), 33 | borderGradient = toCardBorderGradient(color), 34 | iconRes = toIconRes(type), 35 | iconColor = toIconColor(color), 36 | ) 37 | } 38 | 39 | private const val PINK_COLOR = "pink" 40 | private const val BLUE_COLOR = "blue" 41 | private const val RED_COLOR = "red" 42 | private const val YELLOW_COLOR = "yellow" 43 | private const val GREEN_COLOR = "green" 44 | 45 | private fun toIconColor(color: String): Color { 46 | return when (color) { 47 | PINK_COLOR -> Color(0xFF8D7CE7) 48 | BLUE_COLOR -> Color(0xFF6D94DA) 49 | RED_COLOR -> Color(0xEECF5942) 50 | YELLOW_COLOR -> Color(0xFFDB8A3F) 51 | GREEN_COLOR -> Color(0xFF539A83) 52 | else -> Color(0xFF539A83) 53 | } 54 | } 55 | 56 | private fun toIconRes(type: String): Int { 57 | return when (type) { 58 | "VISA" -> CardTypeIcon.VISA.icon 59 | "MASTER_CARD" -> CardTypeIcon.MASTERCARD.icon 60 | else -> CardTypeIcon.MASTERCARD.icon 61 | } 62 | } 63 | 64 | private fun toCardTextNumberColor(color: String): Color { 65 | return when (color) { 66 | PINK_COLOR -> CardTextNumberColor.PINK.color 67 | BLUE_COLOR -> CardTextNumberColor.BLUE.color 68 | RED_COLOR -> CardTextNumberColor.RED.color 69 | YELLOW_COLOR -> CardTextNumberColor.YELLOW.color 70 | GREEN_COLOR -> CardTextNumberColor.GREEN.color 71 | else -> CardTextNumberColor.PINK.color 72 | } 73 | } 74 | 75 | private fun toCardGradient(color: String): Brush { 76 | return when (color) { 77 | PINK_COLOR -> CardGradient.PINK.brush 78 | BLUE_COLOR -> CardGradient.BLUE.brush 79 | RED_COLOR -> CardGradient.RED.brush 80 | YELLOW_COLOR -> CardGradient.YELLOW.brush 81 | GREEN_COLOR -> CardGradient.GREEN.brush 82 | else -> CardGradient.YELLOW.brush 83 | } 84 | } 85 | 86 | private fun toCardBorderGradient(color: String): Brush { 87 | return when (color) { 88 | PINK_COLOR -> CardBorderGradient.PINK.brush 89 | BLUE_COLOR -> CardBorderGradient.BLUE.brush 90 | RED_COLOR -> CardBorderGradient.RED.brush 91 | YELLOW_COLOR -> CardBorderGradient.YELLOW.brush 92 | GREEN_COLOR -> CardBorderGradient.GREEN.brush 93 | else -> CardBorderGradient.YELLOW.brush 94 | } 95 | } 96 | 97 | private fun toCardColors(color: String): List { 98 | return when (color) { 99 | PINK_COLOR -> CardColors.PINK.colors 100 | BLUE_COLOR -> CardColors.BLUE.colors 101 | RED_COLOR -> CardColors.RED.colors 102 | YELLOW_COLOR -> CardColors.YELLOW.colors 103 | GREEN_COLOR -> CardColors.GREEN.colors 104 | else -> CardColors.PINK.colors 105 | } 106 | } 107 | 108 | private fun toCardBorderCardColors(color: String): List { 109 | return when (color) { 110 | PINK_COLOR -> CardBorderColors.PINK.colors 111 | BLUE_COLOR -> CardBorderColors.BLUE.colors 112 | RED_COLOR -> CardBorderColors.RED.colors 113 | YELLOW_COLOR -> CardBorderColors.YELLOW.colors 114 | GREEN_COLOR -> CardBorderColors.GREEN.colors 115 | else -> CardColors.PINK.colors 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /base/base_cards/src/main/kotlin/com/redmadrobot/base_cards/model/CardBorderColors.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.base_cards.model 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | enum class CardBorderColors(val colors: List) { 6 | BLUE( 7 | listOf( 8 | Color(0xFFB4F4EF), 9 | Color(0xFF5959BA), 10 | ) 11 | ), 12 | 13 | RED( 14 | listOf( 15 | Color(0xFFB88BDB), 16 | Color(0xFFCF5942) 17 | ) 18 | ), 19 | 20 | YELLOW( 21 | listOf( 22 | Color(0xFFFFF8B6), 23 | Color(0xFFB15F13), 24 | ) 25 | ), 26 | 27 | PINK( 28 | listOf( 29 | Color(0xFFCADFFF), 30 | Color(0xFF703AB5), 31 | ) 32 | ), 33 | 34 | GREEN( 35 | listOf( 36 | Color(0xFFB6FDDC), 37 | Color(0xFF396466), 38 | ) 39 | ) 40 | } -------------------------------------------------------------------------------- /base/base_cards/src/main/kotlin/com/redmadrobot/base_cards/model/CardBorderGradient.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.base_cards.model 2 | 3 | import androidx.compose.ui.geometry.Offset 4 | import androidx.compose.ui.graphics.Brush 5 | 6 | enum class CardBorderGradient(val brush: Brush) { 7 | BLUE( 8 | Brush.linearGradient( 9 | colors = CardColors.BLUE.colors, 10 | start = Offset(0f, Float.POSITIVE_INFINITY), 11 | end = Offset(Float.POSITIVE_INFINITY, 0f) 12 | ) 13 | ), 14 | 15 | RED( 16 | Brush.linearGradient( 17 | colors = CardColors.RED.colors, 18 | start = Offset(0f, 0f), 19 | ) 20 | ), 21 | 22 | YELLOW( 23 | Brush.linearGradient( 24 | colors = CardColors.YELLOW.colors, 25 | start = Offset(0f, Float.POSITIVE_INFINITY), 26 | end = Offset(Float.POSITIVE_INFINITY, 0f) 27 | ) 28 | ), 29 | 30 | PINK( 31 | Brush.linearGradient( 32 | colors = CardColors.PINK.colors, 33 | start = Offset(0f, Float.POSITIVE_INFINITY), 34 | end = Offset(Float.POSITIVE_INFINITY, 0f) 35 | ) 36 | ), 37 | 38 | GREEN( 39 | Brush.linearGradient( 40 | colors = CardColors.GREEN.colors, 41 | start = Offset(0f, Float.POSITIVE_INFINITY), 42 | end = Offset(Float.POSITIVE_INFINITY, 0f) 43 | ) 44 | ) 45 | } -------------------------------------------------------------------------------- /base/base_cards/src/main/kotlin/com/redmadrobot/base_cards/model/CardColors.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.base_cards.model 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | enum class CardColors(val colors: List) { 6 | BLUE( 7 | listOf( 8 | Color(0xFF91EAE4), 9 | Color(0xFF7F7FD5), 10 | ) 11 | ), 12 | 13 | RED( 14 | listOf( 15 | Color(0xFFA36BCF), 16 | Color(0xFFE77557) 17 | ) 18 | ), 19 | 20 | YELLOW( 21 | listOf( 22 | Color(0xFFF9ED80), 23 | Color(0xFFDF832E), 24 | ) 25 | ), 26 | 27 | PINK( 28 | listOf( 29 | Color(0xFFB0D0FF), 30 | Color(0xFF914CE8), 31 | ) 32 | ), 33 | 34 | GREEN( 35 | listOf( 36 | Color(0xFF99F2C8), 37 | Color(0xFF396063), 38 | ) 39 | ) 40 | 41 | } -------------------------------------------------------------------------------- /base/base_cards/src/main/kotlin/com/redmadrobot/base_cards/model/CardGradient.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.base_cards.model 2 | 3 | import androidx.compose.ui.geometry.Offset 4 | import androidx.compose.ui.graphics.Brush 5 | 6 | enum class CardGradient(val brush: Brush) { 7 | BLUE( 8 | Brush.linearGradient( 9 | colors = CardColors.BLUE.colors, 10 | start = Offset(0f, Float.POSITIVE_INFINITY), 11 | end = Offset(Float.POSITIVE_INFINITY, 0f) 12 | ) 13 | ), 14 | 15 | RED( 16 | Brush.linearGradient( 17 | colors = CardColors.RED.colors, 18 | start = Offset(0f, Float.POSITIVE_INFINITY), 19 | end = Offset(Float.POSITIVE_INFINITY, 0f) 20 | ) 21 | ), 22 | 23 | YELLOW( 24 | Brush.linearGradient( 25 | colors = CardColors.YELLOW.colors, 26 | start = Offset(0f, Float.POSITIVE_INFINITY), 27 | end = Offset(Float.POSITIVE_INFINITY, 0f) 28 | ) 29 | ), 30 | 31 | PINK( 32 | Brush.linearGradient( 33 | colors = CardColors.PINK.colors, 34 | start = Offset(0f, Float.POSITIVE_INFINITY), 35 | end = Offset(Float.POSITIVE_INFINITY, 0f) 36 | ) 37 | ), 38 | 39 | GREEN( 40 | Brush.linearGradient( 41 | colors = CardColors.GREEN.colors, 42 | start = Offset(0f, Float.POSITIVE_INFINITY), 43 | end = Offset(Float.POSITIVE_INFINITY, 0f) 44 | ) 45 | ) 46 | } -------------------------------------------------------------------------------- /base/base_cards/src/main/kotlin/com/redmadrobot/base_cards/model/CardTextNumberColor.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.base_cards.model 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | enum class CardTextNumberColor(val color: Color) { 6 | BLUE(Color(0xFF185E66)), 7 | PINK(Color(0xFF2A4786)), 8 | YELLOW(Color(0xFF5B4108)), 9 | RED(Color(0xFF491D67)), 10 | GREEN(Color(0xFF174E34)), 11 | } -------------------------------------------------------------------------------- /base/base_cards/src/main/kotlin/com/redmadrobot/base_cards/model/CardTypeIcon.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.base_cards.model 2 | 3 | import com.redmadrobot.base_cards.R 4 | 5 | enum class CardTypeIcon(val icon: Int) { 6 | MASTERCARD(R.drawable.ic_master), 7 | VISA(R.drawable.ic_visa) 8 | } -------------------------------------------------------------------------------- /base/base_cards/src/main/kotlin/com/redmadrobot/base_cards/model/CardViewState.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.base_cards.model 2 | 3 | import androidx.compose.ui.graphics.Brush 4 | import androidx.compose.ui.graphics.Color 5 | 6 | data class CardViewState( 7 | val id: String, 8 | val number: String, 9 | val cardColors: List, 10 | val cardBorderColors: List, 11 | val cardTextNumberColor: Color, 12 | val cardGradient: Brush, 13 | val borderGradient: Brush, 14 | val iconRes: Int, 15 | val iconColor: Color, 16 | ) -------------------------------------------------------------------------------- /base/base_cards/src/main/res/drawable/ic_circle_arrow.xml: -------------------------------------------------------------------------------- 1 | 6 | 13 | 20 | 27 | 28 | -------------------------------------------------------------------------------- /base/base_cards/src/main/res/drawable/ic_iron_man.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /base/base_cards/src/main/res/drawable/ic_master.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /base/base_cards/src/main/res/drawable/ic_subtract.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /base/base_cards/src/main/res/drawable/ic_visa.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | 2 | buildscript { 3 | repositories { 4 | google() 5 | mavenCentral() 6 | maven("https://dl.bintray.com/kotlin/kotlin-eap/") 7 | } 8 | dependencies { 9 | classpath(GradlePluginDependency.ANDROID_BUILD_TOOLS) 10 | classpath(GradlePluginDependency.KOTLIN) 11 | classpath(GradlePluginDependency.HILT) 12 | classpath(GradlePluginDependency.APOLLO) 13 | } 14 | } 15 | 16 | // all projects = root project + sub projects 17 | allprojects { 18 | repositories { 19 | google() 20 | mavenCentral() 21 | maven { url = uri("https://jitpack.io") } 22 | } 23 | } 24 | 25 | tasks.register("clean", Delete::class) { 26 | delete(rootProject.buildDir) 27 | } -------------------------------------------------------------------------------- /buildSrc/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /buildSrc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `kotlin-dsl` 3 | } 4 | 5 | repositories { 6 | mavenCentral() 7 | google() 8 | } 9 | 10 | dependencies { 11 | implementation("com.android.tools.build:gradle:7.4.1") 12 | implementation("com.android.tools.build:gradle-api:7.4.1") 13 | implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.10") 14 | implementation("org.jetbrains.kotlin:kotlin-gradle-plugin-api:1.8.10") 15 | implementation("com.squareup:javapoet:1.13.0") // https://github.com/google/dagger/issues/3068 16 | } 17 | 18 | gradlePlugin { 19 | plugins { 20 | register("common-android-config") { 21 | id = "common-android-config" 22 | implementationClass = "com.redmadrobot.mad.plugins.CommonAndroidConfigPlugin" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/AndroidConfig.kt: -------------------------------------------------------------------------------- 1 | object AndroidConfig { 2 | const val MIN_SDK_VERSION = 26 3 | const val COMPILE_SDK_VERSION = 33 4 | const val TARGET_SDK_VERSION = 33 5 | } 6 | 7 | interface BuildType { 8 | 9 | companion object { 10 | const val RELEASE = "release" 11 | const val DEBUG = "debug" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/CoreDependency.kt: -------------------------------------------------------------------------------- 1 | object CoreVersions { 2 | const val KOTLIN = "1.8.10" 3 | const val COROUTINES = "1.6.4" 4 | 5 | const val HILT = "2.45" 6 | 7 | const val APOLLO = "2.5.14" 8 | } 9 | 10 | object CoreDependency { 11 | const val KOTLIN = "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${CoreVersions.KOTLIN}" 12 | 13 | const val COROUTINES_CORE = 14 | "org.jetbrains.kotlinx:kotlinx-coroutines-core:${CoreVersions.COROUTINES}" 15 | const val COROUTINES_ANDROID = 16 | "org.jetbrains.kotlinx:kotlinx-coroutines-android:${CoreVersions.COROUTINES}" 17 | 18 | const val HILT = "com.google.dagger:hilt-android:${CoreVersions.HILT}" 19 | const val HILT_COMPILER = "com.google.dagger:hilt-compiler:${CoreVersions.HILT}" 20 | } 21 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/GradlePluginDependency.kt: -------------------------------------------------------------------------------- 1 | object GradlePluginVersions { 2 | const val ANDROID_BUILD_TOOLS = "7.4.1" 3 | const val KOTLIN = CoreVersions.KOTLIN 4 | const val HILT = CoreVersions.HILT 5 | const val APOLLO = CoreVersions.APOLLO 6 | } 7 | 8 | object GradlePluginId { 9 | const val ANDROID_COMMON_CONFIG = "common-android-config" 10 | const val ANDROID_APPLICATION = "com.android.application" 11 | const val ANDROID_LIBRARY = "com.android.library" 12 | const val KAPT = "kapt" 13 | const val HILT = "dagger.hilt.android.plugin" 14 | const val APOLLO = "com.apollographql.apollo" 15 | } 16 | 17 | object GradlePluginDependency { 18 | const val ANDROID_BUILD_TOOLS = "com.android.tools.build:gradle:${GradlePluginVersions.ANDROID_BUILD_TOOLS}" 19 | const val KOTLIN = "org.jetbrains.kotlin:kotlin-gradle-plugin:${GradlePluginVersions.KOTLIN}" 20 | const val HILT = "com.google.dagger:hilt-android-gradle-plugin:${GradlePluginVersions.HILT}" 21 | const val APOLLO = "com.apollographql.apollo:apollo-gradle-plugin:${GradlePluginVersions.APOLLO}" 22 | } -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/NetworkDependency.kt: -------------------------------------------------------------------------------- 1 | private object NetworkVersions { 2 | const val APOLLO = CoreVersions.APOLLO 3 | } 4 | 5 | object NetworkDependency { 6 | const val APOLLO_GRAPHQL = "com.apollographql.apollo:apollo-runtime:${NetworkVersions.APOLLO}" 7 | const val APOLLO_COROUTINES = "com.apollographql.apollo:apollo-coroutines-support:${NetworkVersions.APOLLO}" 8 | } 9 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/PresentationDependency.kt: -------------------------------------------------------------------------------- 1 | object PresentationVersions { 2 | const val APPCOMPAT = "1.6.1" 3 | 4 | const val COMPOSE = "1.3.3" 5 | 6 | const val COMPOSE_MATERIAL = "1.3.1" 7 | const val COMPOSE_FOUNDATION = "1.3.1" 8 | const val COMPOSE_COMPILER = "1.4.2" 9 | const val COMPOSE_ACTIVITY = "1.6.1" 10 | const val COMPOSE_NAVIGATION = "2.5.3" 11 | const val COMPOSE_HILT_NAVIGATION = "1.0.0" 12 | const val COMPOSE_LIFECYCLE = "2.6.0-beta01" 13 | } 14 | 15 | object PresentationDependency { 16 | const val APPCOMPAT = "androidx.appcompat:appcompat:${PresentationVersions.APPCOMPAT}" 17 | 18 | const val COMPOSE_UI = "androidx.compose.ui:ui:${PresentationVersions.COMPOSE}" 19 | const val COMPOSE_RUNTIME = "androidx.compose.runtime:runtime:${PresentationVersions.COMPOSE}" 20 | const val COMPOSE_TOOLING = "androidx.compose.ui:ui-tooling:${PresentationVersions.COMPOSE}" 21 | const val COMPOSE_TOOLING_PREVIEW = "androidx.compose.ui:ui-tooling-preview:${PresentationVersions.COMPOSE}" 22 | const val COMPOSE_ANIMATION = "androidx.compose.animation:animation:${PresentationVersions.COMPOSE}" 23 | 24 | const val COMPOSE_MATERIAL = "androidx.compose.material:material:${PresentationVersions.COMPOSE_MATERIAL}" 25 | const val COMPOSE_FOUNDATION = "androidx.compose.foundation:foundation:${PresentationVersions.COMPOSE_FOUNDATION}" 26 | const val COMPOSE_COMPILER = "androidx.compose.compiler:compiler:${PresentationVersions.COMPOSE_COMPILER}" 27 | const val COMPOSE_ACTIVITY = "androidx.activity:activity-compose:${PresentationVersions.COMPOSE_ACTIVITY}" 28 | const val COMPOSE_NAVIGATION = "androidx.navigation:navigation-compose:${PresentationVersions.COMPOSE_NAVIGATION}" 29 | const val COMPOSE_HILT_NAVIGATION = "androidx.hilt:hilt-navigation-compose:${PresentationVersions.COMPOSE_HILT_NAVIGATION}" 30 | const val COMPOSE_LIFECYCLE = "androidx.lifecycle:lifecycle-runtime-compose:${PresentationVersions.COMPOSE_LIFECYCLE}" 31 | } 32 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/com/redmadrobot/mad/plugins/CommonAndroidConfigPlugin.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.mad.plugins 2 | 3 | import org.gradle.api.JavaVersion 4 | import org.gradle.api.Plugin 5 | import org.gradle.api.Project 6 | import org.gradle.kotlin.dsl.withType 7 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 8 | 9 | class CommonAndroidConfigPlugin : Plugin { 10 | 11 | override fun apply(target: Project) { 12 | with(target) { 13 | applyPlugins() 14 | applyAndroidConfig() 15 | applyKotlinConfig() 16 | } 17 | } 18 | 19 | private fun Project.applyPlugins() { 20 | plugins.run { 21 | apply("kotlin-android") 22 | } 23 | } 24 | 25 | private fun Project.applyAndroidConfig() { 26 | android?.run { 27 | defaultConfig { 28 | minSdk = AndroidConfig.MIN_SDK_VERSION 29 | targetSdk = AndroidConfig.TARGET_SDK_VERSION 30 | compileSdkVersion(AndroidConfig.COMPILE_SDK_VERSION) 31 | } 32 | 33 | compileOptions { 34 | sourceCompatibility = JavaVersion.VERSION_1_8 35 | targetCompatibility = JavaVersion.VERSION_1_8 36 | } 37 | 38 | composeOptions { 39 | kotlinCompilerExtensionVersion = PresentationVersions.COMPOSE_COMPILER 40 | } 41 | 42 | sourceSets.forEach { it.java.srcDir("src/${it.name}/kotlin") } 43 | } 44 | app?.run { 45 | buildTypes { 46 | val proguardFiles = rootProject.fileTree("proguard").files + 47 | getDefaultProguardFile("proguard-android-optimize.txt") 48 | 49 | getByName(BuildType.DEBUG) { 50 | } 51 | 52 | getByName(BuildType.RELEASE) { 53 | isDebuggable = false 54 | isMinifyEnabled = true 55 | proguardFiles(*proguardFiles.toTypedArray()) 56 | } 57 | } 58 | 59 | buildFeatures { 60 | compose = true 61 | } 62 | } 63 | library?.run { 64 | buildTypes { 65 | val proguardFiles = rootProject.fileTree("proguard").files + 66 | getDefaultProguardFile("proguard-android-optimize.txt") 67 | 68 | getByName(BuildType.DEBUG) { 69 | } 70 | 71 | getByName(BuildType.RELEASE) { 72 | isMinifyEnabled = true 73 | proguardFiles(*proguardFiles.toTypedArray()) 74 | } 75 | } 76 | 77 | buildFeatures { 78 | compose = true 79 | } 80 | } 81 | } 82 | 83 | private fun Project.applyKotlinConfig() { 84 | this.tasks.withType { 85 | kotlinOptions { 86 | jvmTarget = JavaVersion.VERSION_1_8.toString() 87 | } 88 | } 89 | } 90 | } -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/com/redmadrobot/mad/plugins/Extensions.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.mad.plugins 2 | 3 | import com.android.build.gradle.BaseExtension 4 | import com.android.build.gradle.internal.dsl.BaseAppModuleExtension 5 | import com.android.build.gradle.LibraryExtension 6 | import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension 7 | import org.gradle.api.Project 8 | 9 | val Project.android: BaseExtension? 10 | get() = extensions.findByName("android") as? BaseExtension 11 | 12 | val Project.app: BaseAppModuleExtension? 13 | get() = extensions.findByName("android") as? BaseAppModuleExtension 14 | 15 | val Project.library: LibraryExtension? 16 | get() = extensions.findByName("android") as? LibraryExtension 17 | 18 | val Project.kotlin: KotlinAndroidProjectExtension? 19 | get() = extensions.findByName("android") as? KotlinAndroidProjectExtension -------------------------------------------------------------------------------- /core/core/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /core/core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id(GradlePluginId.ANDROID_LIBRARY) 3 | id(GradlePluginId.ANDROID_COMMON_CONFIG) 4 | } 5 | 6 | dependencies { 7 | api(CoreDependency.KOTLIN) 8 | api(CoreDependency.COROUTINES_CORE) 9 | api(CoreDependency.COROUTINES_ANDROID) 10 | 11 | api(project(":core:core_network")) 12 | api(project(":core:core_presentation")) 13 | api(project(":core:core_navigation")) 14 | } 15 | -------------------------------------------------------------------------------- /core/core/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /core/core/src/main/kotlin/com/redmadrobot/core/extensions/CoroutinesExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.core.extensions 2 | 3 | import kotlinx.coroutines.CancellationException 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.launch 6 | 7 | @Suppress("InstanceOfCheckForException") 8 | fun CoroutineScope.safeLaunch( 9 | block: suspend CoroutineScope.() -> Unit, 10 | onError: (Throwable) -> Unit, 11 | onComplete: () -> Unit = {} 12 | ) { 13 | launch { 14 | try { 15 | block.invoke(this) 16 | } catch (exception: Exception) { 17 | if (exception !is CancellationException) { 18 | onError.invoke(exception) 19 | } 20 | } finally { 21 | onComplete() 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /core/core_navigation/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /core/core_navigation/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id(GradlePluginId.ANDROID_LIBRARY) 3 | id(GradlePluginId.ANDROID_COMMON_CONFIG) 4 | } 5 | 6 | dependencies { 7 | // TODO: delete next line after update compose navigation and hilt navigation because these libs have target 1.0.0-rc01 compose runtime 8 | implementation(PresentationDependency.COMPOSE_RUNTIME) 9 | api(PresentationDependency.COMPOSE_NAVIGATION) 10 | api(PresentationDependency.COMPOSE_HILT_NAVIGATION) 11 | } 12 | -------------------------------------------------------------------------------- /core/core_navigation/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /core/core_navigation/src/main/kotlin/com/redmadrobot/core_navigation/navigation/Route.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.core_navigation.navigation 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.navigation.NamedNavArgument 5 | import androidx.navigation.NavBackStackEntry 6 | import androidx.navigation.NavGraphBuilder 7 | import androidx.navigation.compose.composable 8 | 9 | data class Route( 10 | val name: String, 11 | val arguments: List = emptyList(), 12 | ) 13 | 14 | fun NavGraphBuilder.composableScreen( 15 | route: Route, 16 | content: @Composable (NavBackStackEntry) -> Unit 17 | ): Unit = composable( 18 | route = route.name, 19 | arguments = route.arguments, 20 | content = content, 21 | ) -------------------------------------------------------------------------------- /core/core_navigation/src/main/kotlin/com/redmadrobot/core_navigation/navigation/Router.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.core_navigation.navigation 2 | 3 | interface Router { 4 | 5 | fun navigate(route: Route) 6 | } -------------------------------------------------------------------------------- /core/core_navigation/src/main/kotlin/com/redmadrobot/core_navigation/navigation/screens/Routes.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.core_navigation.navigation.screens 2 | 3 | import androidx.navigation.NavType 4 | import androidx.navigation.navArgument 5 | import com.redmadrobot.core_navigation.navigation.Route 6 | 7 | object Routes { 8 | private const val KEY = "id" 9 | val Auth = Route(name = "Auth") 10 | val Home = Route(name = "Home") 11 | val Details = Route(name = "Details/{$KEY}") 12 | 13 | fun toDetails(id: String? = null): Route { 14 | val arguments = listOf( 15 | navArgument(KEY) { type = NavType.StringType } 16 | ) 17 | return Route(name = "Details/$id", arguments = arguments) 18 | } 19 | } -------------------------------------------------------------------------------- /core/core_network/.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | .idea/ 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | local.properties 11 | -------------------------------------------------------------------------------- /core/core_network/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id(GradlePluginId.ANDROID_LIBRARY) 3 | id(GradlePluginId.ANDROID_COMMON_CONFIG) 4 | id(GradlePluginId.APOLLO) 5 | } 6 | 7 | android { 8 | buildFeatures { 9 | compose = false 10 | } 11 | } 12 | 13 | apollo { 14 | generateApolloMetadata.set(true) 15 | generateKotlinModels.set(true) 16 | } 17 | 18 | dependencies { 19 | api(NetworkDependency.APOLLO_GRAPHQL) 20 | api(NetworkDependency.APOLLO_COROUTINES) 21 | } 22 | -------------------------------------------------------------------------------- /core/core_network/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /core/core_network/src/main/graphql/com/redmadrobot/core_network/.graphqlconfig: -------------------------------------------------------------------------------- 1 | { 2 | "name": "GraphQL Schema", 3 | "schemaPath": "schema.json", 4 | "extensions": { 5 | "endpoints": { 6 | "Default GraphQL Endpoint": { 7 | "url": "https://mad-showcase-fake-server.herokuapp.com/graphql" 8 | } 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /core/core_network/src/main/graphql/com/redmadrobot/core_network/queries.graphql: -------------------------------------------------------------------------------- 1 | mutation Login($email: String!, $password: String!){ 2 | login(email: $email, password: $password) 3 | } 4 | 5 | query CardsList { 6 | cards { 7 | id, 8 | type, 9 | number, 10 | color 11 | } 12 | } 13 | 14 | query CardDetails($id:ID!) { 15 | card(id: $id) { 16 | id, 17 | type, 18 | number, 19 | color 20 | } 21 | } -------------------------------------------------------------------------------- /core/core_network/src/main/graphql/com/redmadrobot/core_network/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "__schema": { 3 | "queryType": { 4 | "name": "Query" 5 | }, 6 | "mutationType": { 7 | "name": "Mutation" 8 | }, 9 | "subscriptionType": null, 10 | "types": [ 11 | { 12 | "kind": "OBJECT", 13 | "name": "Query", 14 | "description": null, 15 | "fields": [ 16 | { 17 | "name": "account", 18 | "description": null, 19 | "args": [ 20 | { 21 | "name": "id", 22 | "description": null, 23 | "type": { 24 | "kind": "NON_NULL", 25 | "name": null, 26 | "ofType": { 27 | "kind": "SCALAR", 28 | "name": "ID", 29 | "ofType": null 30 | } 31 | }, 32 | "defaultValue": null 33 | } 34 | ], 35 | "type": { 36 | "kind": "OBJECT", 37 | "name": "Account", 38 | "ofType": null 39 | }, 40 | "isDeprecated": false, 41 | "deprecationReason": null 42 | }, 43 | { 44 | "name": "accounts", 45 | "description": null, 46 | "args": [], 47 | "type": { 48 | "kind": "LIST", 49 | "name": null, 50 | "ofType": { 51 | "kind": "OBJECT", 52 | "name": "Account", 53 | "ofType": null 54 | } 55 | }, 56 | "isDeprecated": false, 57 | "deprecationReason": null 58 | }, 59 | { 60 | "name": "viewer", 61 | "description": null, 62 | "args": [], 63 | "type": { 64 | "kind": "NON_NULL", 65 | "name": null, 66 | "ofType": { 67 | "kind": "OBJECT", 68 | "name": "Account", 69 | "ofType": null 70 | } 71 | }, 72 | "isDeprecated": false, 73 | "deprecationReason": null 74 | }, 75 | { 76 | "name": "card", 77 | "description": null, 78 | "args": [ 79 | { 80 | "name": "id", 81 | "description": null, 82 | "type": { 83 | "kind": "NON_NULL", 84 | "name": null, 85 | "ofType": { 86 | "kind": "SCALAR", 87 | "name": "ID", 88 | "ofType": null 89 | } 90 | }, 91 | "defaultValue": null 92 | } 93 | ], 94 | "type": { 95 | "kind": "NON_NULL", 96 | "name": null, 97 | "ofType": { 98 | "kind": "OBJECT", 99 | "name": "Card", 100 | "ofType": null 101 | } 102 | }, 103 | "isDeprecated": false, 104 | "deprecationReason": null 105 | }, 106 | { 107 | "name": "cards", 108 | "description": null, 109 | "args": [], 110 | "type": { 111 | "kind": "NON_NULL", 112 | "name": null, 113 | "ofType": { 114 | "kind": "LIST", 115 | "name": null, 116 | "ofType": { 117 | "kind": "NON_NULL", 118 | "name": null, 119 | "ofType": { 120 | "kind": "OBJECT", 121 | "name": "Card", 122 | "ofType": null 123 | } 124 | } 125 | } 126 | }, 127 | "isDeprecated": false, 128 | "deprecationReason": null 129 | } 130 | ], 131 | "inputFields": null, 132 | "interfaces": [], 133 | "enumValues": null, 134 | "possibleTypes": null 135 | }, 136 | { 137 | "kind": "SCALAR", 138 | "name": "ID", 139 | "description": "The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `\"4\"`) or integer (such as `4`) input value will be accepted as an ID.", 140 | "fields": null, 141 | "inputFields": null, 142 | "interfaces": null, 143 | "enumValues": null, 144 | "possibleTypes": null 145 | }, 146 | { 147 | "kind": "OBJECT", 148 | "name": "Account", 149 | "description": null, 150 | "fields": [ 151 | { 152 | "name": "id", 153 | "description": null, 154 | "args": [], 155 | "type": { 156 | "kind": "NON_NULL", 157 | "name": null, 158 | "ofType": { 159 | "kind": "SCALAR", 160 | "name": "ID", 161 | "ofType": null 162 | } 163 | }, 164 | "isDeprecated": false, 165 | "deprecationReason": null 166 | }, 167 | { 168 | "name": "name", 169 | "description": null, 170 | "args": [], 171 | "type": { 172 | "kind": "SCALAR", 173 | "name": "String", 174 | "ofType": null 175 | }, 176 | "isDeprecated": false, 177 | "deprecationReason": null 178 | } 179 | ], 180 | "inputFields": null, 181 | "interfaces": [], 182 | "enumValues": null, 183 | "possibleTypes": null 184 | }, 185 | { 186 | "kind": "SCALAR", 187 | "name": "String", 188 | "description": "The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", 189 | "fields": null, 190 | "inputFields": null, 191 | "interfaces": null, 192 | "enumValues": null, 193 | "possibleTypes": null 194 | }, 195 | { 196 | "kind": "OBJECT", 197 | "name": "Card", 198 | "description": null, 199 | "fields": [ 200 | { 201 | "name": "id", 202 | "description": null, 203 | "args": [], 204 | "type": { 205 | "kind": "NON_NULL", 206 | "name": null, 207 | "ofType": { 208 | "kind": "SCALAR", 209 | "name": "ID", 210 | "ofType": null 211 | } 212 | }, 213 | "isDeprecated": false, 214 | "deprecationReason": null 215 | }, 216 | { 217 | "name": "type", 218 | "description": null, 219 | "args": [], 220 | "type": { 221 | "kind": "NON_NULL", 222 | "name": null, 223 | "ofType": { 224 | "kind": "SCALAR", 225 | "name": "String", 226 | "ofType": null 227 | } 228 | }, 229 | "isDeprecated": false, 230 | "deprecationReason": null 231 | }, 232 | { 233 | "name": "number", 234 | "description": null, 235 | "args": [], 236 | "type": { 237 | "kind": "NON_NULL", 238 | "name": null, 239 | "ofType": { 240 | "kind": "SCALAR", 241 | "name": "String", 242 | "ofType": null 243 | } 244 | }, 245 | "isDeprecated": false, 246 | "deprecationReason": null 247 | }, 248 | { 249 | "name": "color", 250 | "description": null, 251 | "args": [], 252 | "type": { 253 | "kind": "NON_NULL", 254 | "name": null, 255 | "ofType": { 256 | "kind": "SCALAR", 257 | "name": "String", 258 | "ofType": null 259 | } 260 | }, 261 | "isDeprecated": false, 262 | "deprecationReason": null 263 | } 264 | ], 265 | "inputFields": null, 266 | "interfaces": [], 267 | "enumValues": null, 268 | "possibleTypes": null 269 | }, 270 | { 271 | "kind": "OBJECT", 272 | "name": "Mutation", 273 | "description": null, 274 | "fields": [ 275 | { 276 | "name": "login", 277 | "description": null, 278 | "args": [ 279 | { 280 | "name": "email", 281 | "description": null, 282 | "type": { 283 | "kind": "NON_NULL", 284 | "name": null, 285 | "ofType": { 286 | "kind": "SCALAR", 287 | "name": "String", 288 | "ofType": null 289 | } 290 | }, 291 | "defaultValue": null 292 | }, 293 | { 294 | "name": "password", 295 | "description": null, 296 | "type": { 297 | "kind": "NON_NULL", 298 | "name": null, 299 | "ofType": { 300 | "kind": "SCALAR", 301 | "name": "String", 302 | "ofType": null 303 | } 304 | }, 305 | "defaultValue": null 306 | } 307 | ], 308 | "type": { 309 | "kind": "SCALAR", 310 | "name": "String", 311 | "ofType": null 312 | }, 313 | "isDeprecated": false, 314 | "deprecationReason": null 315 | } 316 | ], 317 | "inputFields": null, 318 | "interfaces": [], 319 | "enumValues": null, 320 | "possibleTypes": null 321 | }, 322 | { 323 | "kind": "OBJECT", 324 | "name": "__Schema", 325 | "description": "A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation, and subscription operations.", 326 | "fields": [ 327 | { 328 | "name": "types", 329 | "description": "A list of all types supported by this server.", 330 | "args": [], 331 | "type": { 332 | "kind": "NON_NULL", 333 | "name": null, 334 | "ofType": { 335 | "kind": "LIST", 336 | "name": null, 337 | "ofType": { 338 | "kind": "NON_NULL", 339 | "name": null, 340 | "ofType": { 341 | "kind": "OBJECT", 342 | "name": "__Type", 343 | "ofType": null 344 | } 345 | } 346 | } 347 | }, 348 | "isDeprecated": false, 349 | "deprecationReason": null 350 | }, 351 | { 352 | "name": "queryType", 353 | "description": "The type that query operations will be rooted at.", 354 | "args": [], 355 | "type": { 356 | "kind": "NON_NULL", 357 | "name": null, 358 | "ofType": { 359 | "kind": "OBJECT", 360 | "name": "__Type", 361 | "ofType": null 362 | } 363 | }, 364 | "isDeprecated": false, 365 | "deprecationReason": null 366 | }, 367 | { 368 | "name": "mutationType", 369 | "description": "If this server supports mutation, the type that mutation operations will be rooted at.", 370 | "args": [], 371 | "type": { 372 | "kind": "OBJECT", 373 | "name": "__Type", 374 | "ofType": null 375 | }, 376 | "isDeprecated": false, 377 | "deprecationReason": null 378 | }, 379 | { 380 | "name": "subscriptionType", 381 | "description": "If this server support subscription, the type that subscription operations will be rooted at.", 382 | "args": [], 383 | "type": { 384 | "kind": "OBJECT", 385 | "name": "__Type", 386 | "ofType": null 387 | }, 388 | "isDeprecated": false, 389 | "deprecationReason": null 390 | }, 391 | { 392 | "name": "directives", 393 | "description": "A list of all directives supported by this server.", 394 | "args": [], 395 | "type": { 396 | "kind": "NON_NULL", 397 | "name": null, 398 | "ofType": { 399 | "kind": "LIST", 400 | "name": null, 401 | "ofType": { 402 | "kind": "NON_NULL", 403 | "name": null, 404 | "ofType": { 405 | "kind": "OBJECT", 406 | "name": "__Directive", 407 | "ofType": null 408 | } 409 | } 410 | } 411 | }, 412 | "isDeprecated": false, 413 | "deprecationReason": null 414 | } 415 | ], 416 | "inputFields": null, 417 | "interfaces": [], 418 | "enumValues": null, 419 | "possibleTypes": null 420 | }, 421 | { 422 | "kind": "OBJECT", 423 | "name": "__Type", 424 | "description": "The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the `__TypeKind` enum.\n\nDepending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name and description, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types.", 425 | "fields": [ 426 | { 427 | "name": "kind", 428 | "description": null, 429 | "args": [], 430 | "type": { 431 | "kind": "NON_NULL", 432 | "name": null, 433 | "ofType": { 434 | "kind": "ENUM", 435 | "name": "__TypeKind", 436 | "ofType": null 437 | } 438 | }, 439 | "isDeprecated": false, 440 | "deprecationReason": null 441 | }, 442 | { 443 | "name": "name", 444 | "description": null, 445 | "args": [], 446 | "type": { 447 | "kind": "SCALAR", 448 | "name": "String", 449 | "ofType": null 450 | }, 451 | "isDeprecated": false, 452 | "deprecationReason": null 453 | }, 454 | { 455 | "name": "description", 456 | "description": null, 457 | "args": [], 458 | "type": { 459 | "kind": "SCALAR", 460 | "name": "String", 461 | "ofType": null 462 | }, 463 | "isDeprecated": false, 464 | "deprecationReason": null 465 | }, 466 | { 467 | "name": "fields", 468 | "description": null, 469 | "args": [ 470 | { 471 | "name": "includeDeprecated", 472 | "description": null, 473 | "type": { 474 | "kind": "SCALAR", 475 | "name": "Boolean", 476 | "ofType": null 477 | }, 478 | "defaultValue": "false" 479 | } 480 | ], 481 | "type": { 482 | "kind": "LIST", 483 | "name": null, 484 | "ofType": { 485 | "kind": "NON_NULL", 486 | "name": null, 487 | "ofType": { 488 | "kind": "OBJECT", 489 | "name": "__Field", 490 | "ofType": null 491 | } 492 | } 493 | }, 494 | "isDeprecated": false, 495 | "deprecationReason": null 496 | }, 497 | { 498 | "name": "interfaces", 499 | "description": null, 500 | "args": [], 501 | "type": { 502 | "kind": "LIST", 503 | "name": null, 504 | "ofType": { 505 | "kind": "NON_NULL", 506 | "name": null, 507 | "ofType": { 508 | "kind": "OBJECT", 509 | "name": "__Type", 510 | "ofType": null 511 | } 512 | } 513 | }, 514 | "isDeprecated": false, 515 | "deprecationReason": null 516 | }, 517 | { 518 | "name": "possibleTypes", 519 | "description": null, 520 | "args": [], 521 | "type": { 522 | "kind": "LIST", 523 | "name": null, 524 | "ofType": { 525 | "kind": "NON_NULL", 526 | "name": null, 527 | "ofType": { 528 | "kind": "OBJECT", 529 | "name": "__Type", 530 | "ofType": null 531 | } 532 | } 533 | }, 534 | "isDeprecated": false, 535 | "deprecationReason": null 536 | }, 537 | { 538 | "name": "enumValues", 539 | "description": null, 540 | "args": [ 541 | { 542 | "name": "includeDeprecated", 543 | "description": null, 544 | "type": { 545 | "kind": "SCALAR", 546 | "name": "Boolean", 547 | "ofType": null 548 | }, 549 | "defaultValue": "false" 550 | } 551 | ], 552 | "type": { 553 | "kind": "LIST", 554 | "name": null, 555 | "ofType": { 556 | "kind": "NON_NULL", 557 | "name": null, 558 | "ofType": { 559 | "kind": "OBJECT", 560 | "name": "__EnumValue", 561 | "ofType": null 562 | } 563 | } 564 | }, 565 | "isDeprecated": false, 566 | "deprecationReason": null 567 | }, 568 | { 569 | "name": "inputFields", 570 | "description": null, 571 | "args": [], 572 | "type": { 573 | "kind": "LIST", 574 | "name": null, 575 | "ofType": { 576 | "kind": "NON_NULL", 577 | "name": null, 578 | "ofType": { 579 | "kind": "OBJECT", 580 | "name": "__InputValue", 581 | "ofType": null 582 | } 583 | } 584 | }, 585 | "isDeprecated": false, 586 | "deprecationReason": null 587 | }, 588 | { 589 | "name": "ofType", 590 | "description": null, 591 | "args": [], 592 | "type": { 593 | "kind": "OBJECT", 594 | "name": "__Type", 595 | "ofType": null 596 | }, 597 | "isDeprecated": false, 598 | "deprecationReason": null 599 | } 600 | ], 601 | "inputFields": null, 602 | "interfaces": [], 603 | "enumValues": null, 604 | "possibleTypes": null 605 | }, 606 | { 607 | "kind": "ENUM", 608 | "name": "__TypeKind", 609 | "description": "An enum describing what kind of type a given `__Type` is.", 610 | "fields": null, 611 | "inputFields": null, 612 | "interfaces": null, 613 | "enumValues": [ 614 | { 615 | "name": "SCALAR", 616 | "description": "Indicates this type is a scalar.", 617 | "isDeprecated": false, 618 | "deprecationReason": null 619 | }, 620 | { 621 | "name": "OBJECT", 622 | "description": "Indicates this type is an object. `fields` and `interfaces` are valid fields.", 623 | "isDeprecated": false, 624 | "deprecationReason": null 625 | }, 626 | { 627 | "name": "INTERFACE", 628 | "description": "Indicates this type is an interface. `fields` and `possibleTypes` are valid fields.", 629 | "isDeprecated": false, 630 | "deprecationReason": null 631 | }, 632 | { 633 | "name": "UNION", 634 | "description": "Indicates this type is a union. `possibleTypes` is a valid field.", 635 | "isDeprecated": false, 636 | "deprecationReason": null 637 | }, 638 | { 639 | "name": "ENUM", 640 | "description": "Indicates this type is an enum. `enumValues` is a valid field.", 641 | "isDeprecated": false, 642 | "deprecationReason": null 643 | }, 644 | { 645 | "name": "INPUT_OBJECT", 646 | "description": "Indicates this type is an input object. `inputFields` is a valid field.", 647 | "isDeprecated": false, 648 | "deprecationReason": null 649 | }, 650 | { 651 | "name": "LIST", 652 | "description": "Indicates this type is a list. `ofType` is a valid field.", 653 | "isDeprecated": false, 654 | "deprecationReason": null 655 | }, 656 | { 657 | "name": "NON_NULL", 658 | "description": "Indicates this type is a non-null. `ofType` is a valid field.", 659 | "isDeprecated": false, 660 | "deprecationReason": null 661 | } 662 | ], 663 | "possibleTypes": null 664 | }, 665 | { 666 | "kind": "SCALAR", 667 | "name": "Boolean", 668 | "description": "The `Boolean` scalar type represents `true` or `false`.", 669 | "fields": null, 670 | "inputFields": null, 671 | "interfaces": null, 672 | "enumValues": null, 673 | "possibleTypes": null 674 | }, 675 | { 676 | "kind": "OBJECT", 677 | "name": "__Field", 678 | "description": "Object and Interface types are described by a list of Fields, each of which has a name, potentially a list of arguments, and a return type.", 679 | "fields": [ 680 | { 681 | "name": "name", 682 | "description": null, 683 | "args": [], 684 | "type": { 685 | "kind": "NON_NULL", 686 | "name": null, 687 | "ofType": { 688 | "kind": "SCALAR", 689 | "name": "String", 690 | "ofType": null 691 | } 692 | }, 693 | "isDeprecated": false, 694 | "deprecationReason": null 695 | }, 696 | { 697 | "name": "description", 698 | "description": null, 699 | "args": [], 700 | "type": { 701 | "kind": "SCALAR", 702 | "name": "String", 703 | "ofType": null 704 | }, 705 | "isDeprecated": false, 706 | "deprecationReason": null 707 | }, 708 | { 709 | "name": "args", 710 | "description": null, 711 | "args": [], 712 | "type": { 713 | "kind": "NON_NULL", 714 | "name": null, 715 | "ofType": { 716 | "kind": "LIST", 717 | "name": null, 718 | "ofType": { 719 | "kind": "NON_NULL", 720 | "name": null, 721 | "ofType": { 722 | "kind": "OBJECT", 723 | "name": "__InputValue", 724 | "ofType": null 725 | } 726 | } 727 | } 728 | }, 729 | "isDeprecated": false, 730 | "deprecationReason": null 731 | }, 732 | { 733 | "name": "type", 734 | "description": null, 735 | "args": [], 736 | "type": { 737 | "kind": "NON_NULL", 738 | "name": null, 739 | "ofType": { 740 | "kind": "OBJECT", 741 | "name": "__Type", 742 | "ofType": null 743 | } 744 | }, 745 | "isDeprecated": false, 746 | "deprecationReason": null 747 | }, 748 | { 749 | "name": "isDeprecated", 750 | "description": null, 751 | "args": [], 752 | "type": { 753 | "kind": "NON_NULL", 754 | "name": null, 755 | "ofType": { 756 | "kind": "SCALAR", 757 | "name": "Boolean", 758 | "ofType": null 759 | } 760 | }, 761 | "isDeprecated": false, 762 | "deprecationReason": null 763 | }, 764 | { 765 | "name": "deprecationReason", 766 | "description": null, 767 | "args": [], 768 | "type": { 769 | "kind": "SCALAR", 770 | "name": "String", 771 | "ofType": null 772 | }, 773 | "isDeprecated": false, 774 | "deprecationReason": null 775 | } 776 | ], 777 | "inputFields": null, 778 | "interfaces": [], 779 | "enumValues": null, 780 | "possibleTypes": null 781 | }, 782 | { 783 | "kind": "OBJECT", 784 | "name": "__InputValue", 785 | "description": "Arguments provided to Fields or Directives and the input fields of an InputObject are represented as Input Values which describe their type and optionally a default value.", 786 | "fields": [ 787 | { 788 | "name": "name", 789 | "description": null, 790 | "args": [], 791 | "type": { 792 | "kind": "NON_NULL", 793 | "name": null, 794 | "ofType": { 795 | "kind": "SCALAR", 796 | "name": "String", 797 | "ofType": null 798 | } 799 | }, 800 | "isDeprecated": false, 801 | "deprecationReason": null 802 | }, 803 | { 804 | "name": "description", 805 | "description": null, 806 | "args": [], 807 | "type": { 808 | "kind": "SCALAR", 809 | "name": "String", 810 | "ofType": null 811 | }, 812 | "isDeprecated": false, 813 | "deprecationReason": null 814 | }, 815 | { 816 | "name": "type", 817 | "description": null, 818 | "args": [], 819 | "type": { 820 | "kind": "NON_NULL", 821 | "name": null, 822 | "ofType": { 823 | "kind": "OBJECT", 824 | "name": "__Type", 825 | "ofType": null 826 | } 827 | }, 828 | "isDeprecated": false, 829 | "deprecationReason": null 830 | }, 831 | { 832 | "name": "defaultValue", 833 | "description": "A GraphQL-formatted string representing the default value for this input value.", 834 | "args": [], 835 | "type": { 836 | "kind": "SCALAR", 837 | "name": "String", 838 | "ofType": null 839 | }, 840 | "isDeprecated": false, 841 | "deprecationReason": null 842 | } 843 | ], 844 | "inputFields": null, 845 | "interfaces": [], 846 | "enumValues": null, 847 | "possibleTypes": null 848 | }, 849 | { 850 | "kind": "OBJECT", 851 | "name": "__EnumValue", 852 | "description": "One possible value for a given Enum. Enum values are unique values, not a placeholder for a string or numeric value. However an Enum value is returned in a JSON response as a string.", 853 | "fields": [ 854 | { 855 | "name": "name", 856 | "description": null, 857 | "args": [], 858 | "type": { 859 | "kind": "NON_NULL", 860 | "name": null, 861 | "ofType": { 862 | "kind": "SCALAR", 863 | "name": "String", 864 | "ofType": null 865 | } 866 | }, 867 | "isDeprecated": false, 868 | "deprecationReason": null 869 | }, 870 | { 871 | "name": "description", 872 | "description": null, 873 | "args": [], 874 | "type": { 875 | "kind": "SCALAR", 876 | "name": "String", 877 | "ofType": null 878 | }, 879 | "isDeprecated": false, 880 | "deprecationReason": null 881 | }, 882 | { 883 | "name": "isDeprecated", 884 | "description": null, 885 | "args": [], 886 | "type": { 887 | "kind": "NON_NULL", 888 | "name": null, 889 | "ofType": { 890 | "kind": "SCALAR", 891 | "name": "Boolean", 892 | "ofType": null 893 | } 894 | }, 895 | "isDeprecated": false, 896 | "deprecationReason": null 897 | }, 898 | { 899 | "name": "deprecationReason", 900 | "description": null, 901 | "args": [], 902 | "type": { 903 | "kind": "SCALAR", 904 | "name": "String", 905 | "ofType": null 906 | }, 907 | "isDeprecated": false, 908 | "deprecationReason": null 909 | } 910 | ], 911 | "inputFields": null, 912 | "interfaces": [], 913 | "enumValues": null, 914 | "possibleTypes": null 915 | }, 916 | { 917 | "kind": "OBJECT", 918 | "name": "__Directive", 919 | "description": "A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document.\n\nIn some cases, you need to provide options to alter GraphQL's execution behavior in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor.", 920 | "fields": [ 921 | { 922 | "name": "name", 923 | "description": null, 924 | "args": [], 925 | "type": { 926 | "kind": "NON_NULL", 927 | "name": null, 928 | "ofType": { 929 | "kind": "SCALAR", 930 | "name": "String", 931 | "ofType": null 932 | } 933 | }, 934 | "isDeprecated": false, 935 | "deprecationReason": null 936 | }, 937 | { 938 | "name": "description", 939 | "description": null, 940 | "args": [], 941 | "type": { 942 | "kind": "SCALAR", 943 | "name": "String", 944 | "ofType": null 945 | }, 946 | "isDeprecated": false, 947 | "deprecationReason": null 948 | }, 949 | { 950 | "name": "locations", 951 | "description": null, 952 | "args": [], 953 | "type": { 954 | "kind": "NON_NULL", 955 | "name": null, 956 | "ofType": { 957 | "kind": "LIST", 958 | "name": null, 959 | "ofType": { 960 | "kind": "NON_NULL", 961 | "name": null, 962 | "ofType": { 963 | "kind": "ENUM", 964 | "name": "__DirectiveLocation", 965 | "ofType": null 966 | } 967 | } 968 | } 969 | }, 970 | "isDeprecated": false, 971 | "deprecationReason": null 972 | }, 973 | { 974 | "name": "args", 975 | "description": null, 976 | "args": [], 977 | "type": { 978 | "kind": "NON_NULL", 979 | "name": null, 980 | "ofType": { 981 | "kind": "LIST", 982 | "name": null, 983 | "ofType": { 984 | "kind": "NON_NULL", 985 | "name": null, 986 | "ofType": { 987 | "kind": "OBJECT", 988 | "name": "__InputValue", 989 | "ofType": null 990 | } 991 | } 992 | } 993 | }, 994 | "isDeprecated": false, 995 | "deprecationReason": null 996 | } 997 | ], 998 | "inputFields": null, 999 | "interfaces": [], 1000 | "enumValues": null, 1001 | "possibleTypes": null 1002 | }, 1003 | { 1004 | "kind": "ENUM", 1005 | "name": "__DirectiveLocation", 1006 | "description": "A Directive can be adjacent to many parts of the GraphQL language, a __DirectiveLocation describes one such possible adjacencies.", 1007 | "fields": null, 1008 | "inputFields": null, 1009 | "interfaces": null, 1010 | "enumValues": [ 1011 | { 1012 | "name": "QUERY", 1013 | "description": "Location adjacent to a query operation.", 1014 | "isDeprecated": false, 1015 | "deprecationReason": null 1016 | }, 1017 | { 1018 | "name": "MUTATION", 1019 | "description": "Location adjacent to a mutation operation.", 1020 | "isDeprecated": false, 1021 | "deprecationReason": null 1022 | }, 1023 | { 1024 | "name": "SUBSCRIPTION", 1025 | "description": "Location adjacent to a subscription operation.", 1026 | "isDeprecated": false, 1027 | "deprecationReason": null 1028 | }, 1029 | { 1030 | "name": "FIELD", 1031 | "description": "Location adjacent to a field.", 1032 | "isDeprecated": false, 1033 | "deprecationReason": null 1034 | }, 1035 | { 1036 | "name": "FRAGMENT_DEFINITION", 1037 | "description": "Location adjacent to a fragment definition.", 1038 | "isDeprecated": false, 1039 | "deprecationReason": null 1040 | }, 1041 | { 1042 | "name": "FRAGMENT_SPREAD", 1043 | "description": "Location adjacent to a fragment spread.", 1044 | "isDeprecated": false, 1045 | "deprecationReason": null 1046 | }, 1047 | { 1048 | "name": "INLINE_FRAGMENT", 1049 | "description": "Location adjacent to an inline fragment.", 1050 | "isDeprecated": false, 1051 | "deprecationReason": null 1052 | }, 1053 | { 1054 | "name": "VARIABLE_DEFINITION", 1055 | "description": "Location adjacent to a variable definition.", 1056 | "isDeprecated": false, 1057 | "deprecationReason": null 1058 | }, 1059 | { 1060 | "name": "SCHEMA", 1061 | "description": "Location adjacent to a schema definition.", 1062 | "isDeprecated": false, 1063 | "deprecationReason": null 1064 | }, 1065 | { 1066 | "name": "SCALAR", 1067 | "description": "Location adjacent to a scalar definition.", 1068 | "isDeprecated": false, 1069 | "deprecationReason": null 1070 | }, 1071 | { 1072 | "name": "OBJECT", 1073 | "description": "Location adjacent to an object type definition.", 1074 | "isDeprecated": false, 1075 | "deprecationReason": null 1076 | }, 1077 | { 1078 | "name": "FIELD_DEFINITION", 1079 | "description": "Location adjacent to a field definition.", 1080 | "isDeprecated": false, 1081 | "deprecationReason": null 1082 | }, 1083 | { 1084 | "name": "ARGUMENT_DEFINITION", 1085 | "description": "Location adjacent to an argument definition.", 1086 | "isDeprecated": false, 1087 | "deprecationReason": null 1088 | }, 1089 | { 1090 | "name": "INTERFACE", 1091 | "description": "Location adjacent to an interface definition.", 1092 | "isDeprecated": false, 1093 | "deprecationReason": null 1094 | }, 1095 | { 1096 | "name": "UNION", 1097 | "description": "Location adjacent to a union definition.", 1098 | "isDeprecated": false, 1099 | "deprecationReason": null 1100 | }, 1101 | { 1102 | "name": "ENUM", 1103 | "description": "Location adjacent to an enum definition.", 1104 | "isDeprecated": false, 1105 | "deprecationReason": null 1106 | }, 1107 | { 1108 | "name": "ENUM_VALUE", 1109 | "description": "Location adjacent to an enum value definition.", 1110 | "isDeprecated": false, 1111 | "deprecationReason": null 1112 | }, 1113 | { 1114 | "name": "INPUT_OBJECT", 1115 | "description": "Location adjacent to an input object type definition.", 1116 | "isDeprecated": false, 1117 | "deprecationReason": null 1118 | }, 1119 | { 1120 | "name": "INPUT_FIELD_DEFINITION", 1121 | "description": "Location adjacent to an input object field definition.", 1122 | "isDeprecated": false, 1123 | "deprecationReason": null 1124 | } 1125 | ], 1126 | "possibleTypes": null 1127 | } 1128 | ], 1129 | "directives": [ 1130 | { 1131 | "name": "include", 1132 | "description": "Directs the executor to include this field or fragment only when the `if` argument is true.", 1133 | "locations": [ 1134 | "FIELD", 1135 | "FRAGMENT_SPREAD", 1136 | "INLINE_FRAGMENT" 1137 | ], 1138 | "args": [ 1139 | { 1140 | "name": "if", 1141 | "description": "Included when true.", 1142 | "type": { 1143 | "kind": "NON_NULL", 1144 | "name": null, 1145 | "ofType": { 1146 | "kind": "SCALAR", 1147 | "name": "Boolean", 1148 | "ofType": null 1149 | } 1150 | }, 1151 | "defaultValue": null 1152 | } 1153 | ] 1154 | }, 1155 | { 1156 | "name": "skip", 1157 | "description": "Directs the executor to skip this field or fragment when the `if` argument is true.", 1158 | "locations": [ 1159 | "FIELD", 1160 | "FRAGMENT_SPREAD", 1161 | "INLINE_FRAGMENT" 1162 | ], 1163 | "args": [ 1164 | { 1165 | "name": "if", 1166 | "description": "Skipped when true.", 1167 | "type": { 1168 | "kind": "NON_NULL", 1169 | "name": null, 1170 | "ofType": { 1171 | "kind": "SCALAR", 1172 | "name": "Boolean", 1173 | "ofType": null 1174 | } 1175 | }, 1176 | "defaultValue": null 1177 | } 1178 | ] 1179 | }, 1180 | { 1181 | "name": "deprecated", 1182 | "description": "Marks an element of a GraphQL schema as no longer supported.", 1183 | "locations": [ 1184 | "FIELD_DEFINITION", 1185 | "ENUM_VALUE" 1186 | ], 1187 | "args": [ 1188 | { 1189 | "name": "reason", 1190 | "description": "Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted using the Markdown syntax (as specified by [CommonMark](https://commonmark.org/).", 1191 | "type": { 1192 | "kind": "SCALAR", 1193 | "name": "String", 1194 | "ofType": null 1195 | }, 1196 | "defaultValue": "\"No longer supported\"" 1197 | } 1198 | ] 1199 | } 1200 | ] 1201 | } 1202 | } 1203 | -------------------------------------------------------------------------------- /core/core_network/src/main/kotlin/com/redmadrobot/core_network/ApiFactory.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.core_network 2 | 3 | import com.apollographql.apollo.ApolloClient 4 | import com.redmadrobot.core_network.exception.NetworkExceptionHandler 5 | import okhttp3.OkHttpClient 6 | import java.util.concurrent.TimeUnit 7 | 8 | object ApiFactory { 9 | 10 | private const val DEFAULT_SERVER = "https://mad-showcase-fake-server.herokuapp.com/graphql" 11 | 12 | fun provideApi(networkExceptionHandler: NetworkExceptionHandler): ApolloApi { 13 | val okhttpClient = provideOkHttpClient() 14 | val apolloClient = provideApolloClient(okhttpClient) 15 | return ApolloApi(apolloClient, networkExceptionHandler) 16 | } 17 | 18 | private fun provideApolloClient( 19 | okHttpClient: OkHttpClient, 20 | ): ApolloClient { 21 | return ApolloClient.builder() 22 | .serverUrl(DEFAULT_SERVER) 23 | .okHttpClient(okHttpClient) 24 | .build() 25 | } 26 | 27 | private fun provideOkHttpClient(): OkHttpClient { 28 | return OkHttpClient.Builder() 29 | .connectTimeout(NetworkConstants.CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) 30 | .readTimeout(NetworkConstants.READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) 31 | .writeTimeout(NetworkConstants.WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS) 32 | .build() 33 | } 34 | } -------------------------------------------------------------------------------- /core/core_network/src/main/kotlin/com/redmadrobot/core_network/ApolloApi.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.core_network 2 | 3 | import com.apollographql.apollo.ApolloClient 4 | import com.apollographql.apollo.api.Mutation 5 | import com.apollographql.apollo.api.Operation 6 | import com.apollographql.apollo.api.Operation.Variables 7 | import com.apollographql.apollo.api.Query 8 | import com.apollographql.apollo.api.Response 9 | import com.apollographql.apollo.coroutines.await 10 | import com.redmadrobot.core_network.exception.NetworkExceptionHandler 11 | import com.redmadrobot.core_network.exception.ServerException 12 | import com.redmadrobot.core_network.exception.ServerExceptionFactory 13 | import kotlinx.coroutines.Dispatchers 14 | import kotlinx.coroutines.withContext 15 | 16 | open class ApolloApi( 17 | private val apolloClient: ApolloClient, 18 | private val networkErrorHandler: NetworkExceptionHandler 19 | ) { 20 | 21 | suspend fun query(query: Query): T { 22 | val response = queryForRawResponse(query) 23 | return response.data ?: throw getServerException(response) 24 | } 25 | 26 | suspend fun mutate(mutation: Mutation): T { 27 | val response = mutateForRawResponse(mutation) 28 | return response.data ?: throw getServerException(response) 29 | } 30 | 31 | suspend fun mutateForRawResponse(mutation: Mutation): Response { 32 | return withContext(Dispatchers.IO) { 33 | val response: Response? 34 | try { 35 | response = apolloClient.mutate(mutation).await() 36 | } catch (exception: Throwable) { 37 | throw networkErrorHandler.handle(exception) 38 | } 39 | response 40 | } 41 | } 42 | 43 | suspend fun queryForRawResponse(query: Query): Response { 44 | return withContext(Dispatchers.IO) { 45 | val response: Response? 46 | try { 47 | response = apolloClient.query(query).await() 48 | } catch (exception: Throwable) { 49 | throw networkErrorHandler.handle(exception) 50 | } 51 | response 52 | } 53 | } 54 | 55 | private fun getServerException(response: Response): ServerException { 56 | return ServerExceptionFactory.createException(checkNotNull(response.errors)) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /core/core_network/src/main/kotlin/com/redmadrobot/core_network/NetworkConstants.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.core_network 2 | 3 | internal object NetworkConstants { 4 | const val CONNECT_TIMEOUT_SECONDS = 5L 5 | const val READ_TIMEOUT_SECONDS = 30L 6 | const val WRITE_TIMEOUT_SECONDS = 10L 7 | } -------------------------------------------------------------------------------- /core/core_network/src/main/kotlin/com/redmadrobot/core_network/exception/NetworkException.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.core_network.exception 2 | 3 | open class NetworkException(message: String) : Exception(message) 4 | -------------------------------------------------------------------------------- /core/core_network/src/main/kotlin/com/redmadrobot/core_network/exception/NetworkExceptionHandler.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.core_network.exception 2 | 3 | import android.content.Context 4 | import com.apollographql.apollo.exception.ApolloNetworkException 5 | import com.redmadrobot.core_network.R 6 | import java.net.* 7 | 8 | class NetworkExceptionHandler constructor(private val context: Context) { 9 | 10 | fun handle(exception: Throwable): NetworkException { 11 | val message: String = if (exception is ApolloNetworkException && 12 | exception.cause.isNetworkException() 13 | ) { 14 | context.getString(R.string.error_no_internet) 15 | } else context.getString(R.string.error_network_unknown) 16 | 17 | return NetworkException(message) 18 | } 19 | 20 | private fun Throwable?.isNetworkException(): Boolean { 21 | return when (this) { 22 | is ConnectException, 23 | is SocketException, 24 | is SocketTimeoutException, 25 | is UnknownHostException, 26 | is ProtocolException -> true 27 | else -> false 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /core/core_network/src/main/kotlin/com/redmadrobot/core_network/exception/ServerException.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.core_network.exception 2 | 3 | open class ServerException(message: String) : Exception(message) 4 | -------------------------------------------------------------------------------- /core/core_network/src/main/kotlin/com/redmadrobot/core_network/exception/ServerExceptionFactory.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.core_network.exception 2 | 3 | import com.apollographql.apollo.api.Error 4 | 5 | object ServerExceptionFactory { 6 | 7 | private const val EXTENSIONS = "extensions" 8 | private const val TYPE = "type" 9 | 10 | fun createException(errors: List): ServerException { 11 | val error = errors.first() 12 | val message = error.message 13 | val extensions = error.customAttributes[EXTENSIONS] as? HashMap<*, *> 14 | val errorType = extensions?.let { it[TYPE] as? String }.orEmpty() 15 | return getException(message, errorType) 16 | } 17 | 18 | @Suppress("UseIfInsteadOfWhen") 19 | private fun getException(message: String, errorType: String): ServerException { 20 | return when (errorType) { 21 | "" -> UnknownTypeServerException(message) 22 | else -> ServerException(message) 23 | } 24 | } 25 | } 26 | 27 | class UnknownTypeServerException(message: String) : ServerException(message) 28 | -------------------------------------------------------------------------------- /core/core_network/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Something went wrong 4 | No internet connection.\nTry again 5 | -------------------------------------------------------------------------------- /core/core_presentation/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /core/core_presentation/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id(GradlePluginId.ANDROID_LIBRARY) 3 | id(GradlePluginId.ANDROID_COMMON_CONFIG) 4 | } 5 | 6 | dependencies { 7 | api(PresentationDependency.APPCOMPAT) 8 | api(PresentationDependency.COMPOSE_UI) 9 | api(PresentationDependency.COMPOSE_ANIMATION) 10 | api(PresentationDependency.COMPOSE_COMPILER) 11 | api(PresentationDependency.COMPOSE_TOOLING) 12 | api(PresentationDependency.COMPOSE_TOOLING_PREVIEW) 13 | api(PresentationDependency.COMPOSE_FOUNDATION) 14 | api(PresentationDependency.COMPOSE_MATERIAL) 15 | api(PresentationDependency.COMPOSE_ACTIVITY) 16 | api(PresentationDependency.COMPOSE_LIFECYCLE) 17 | } 18 | -------------------------------------------------------------------------------- /core/core_presentation/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /core/core_presentation/src/main/kotlin/com/redmadrobot/core_presentation/extensions/StateFlowExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.core_presentation.extensions 2 | 3 | import kotlinx.coroutines.flow.MutableStateFlow 4 | 5 | fun MutableStateFlow.requireValue( 6 | messageIfNull: String = "required value was null and not set" 7 | ): T { 8 | return this.value ?: error(messageIfNull) 9 | } 10 | 11 | inline fun MutableStateFlow.update(action: T.() -> T) { 12 | value = action.invoke(requireValue()) 13 | } 14 | -------------------------------------------------------------------------------- /core/core_presentation/src/main/kotlin/com/redmadrobot/core_presentation/model/State.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.core_presentation.model 2 | 3 | sealed class State 4 | 5 | data class Content(val content: T) : State() 6 | class Loading : State() 7 | class Stub(val error: Throwable) : State() 8 | -------------------------------------------------------------------------------- /fake_server/.gitignore: -------------------------------------------------------------------------------- 1 | # editor 2 | 3 | .vscode 4 | 5 | # packages 6 | 7 | node_modules 8 | 9 | # misc 10 | 11 | .DS_Store 12 | .DS_Store? 13 | ._* 14 | .Spotlight-V100 15 | .Trashes 16 | ehthumbs.db 17 | *[Tt]humbs.db 18 | *.Trashes 19 | -------------------------------------------------------------------------------- /fake_server/README.md: -------------------------------------------------------------------------------- 1 | # Fake server with GgaphQL 2 | 3 | **Installation:** 4 | 5 | ```bash 6 | npm i && npm run start 7 | ``` 8 | 9 | **Tutorial:** 10 | 11 | Inspired by https://www.apollographql.com/blog/setting-up-authentication-and-authorization-with-apollo-federation 👩‍💻 12 | -------------------------------------------------------------------------------- /fake_server/accounts/index.js: -------------------------------------------------------------------------------- 1 | const { ApolloServer, gql } = require("apollo-server"); 2 | const { applyMiddleware } = require("graphql-middleware"); 3 | const { buildFederatedSchema } = require("@apollo/federation"); 4 | const jwt = require("jsonwebtoken"); 5 | 6 | const { accounts } = require("../data"); 7 | const { permissions } = require("./permissions"); 8 | 9 | const { accounts_port } = require("../config") 10 | 11 | const typeDefs = gql` 12 | type Account @key(fields: "id") { 13 | id: ID! 14 | name: String 15 | } 16 | 17 | extend type Query { 18 | account(id: ID!): Account 19 | accounts: [Account] 20 | viewer: Account! 21 | } 22 | 23 | extend type Mutation { 24 | login(email: String!, password: String!): String 25 | } 26 | `; 27 | 28 | const resolvers = { 29 | Account: { 30 | __resolveReference(object) { 31 | return accounts.find(account => account.id === object.id); 32 | } 33 | }, 34 | Query: { 35 | account(parent, { id }) { 36 | return accounts.find(account => account.id === id); 37 | }, 38 | accounts() { 39 | return accounts; 40 | }, 41 | viewer(parent, args, { user }) { 42 | return accounts.find(account => account.id === user.sub); 43 | } 44 | }, 45 | Mutation: { 46 | login(parent, { email, password }) { 47 | const { id, permissions, roles } = accounts.find( 48 | account => account.email === email && account.password === password 49 | ); 50 | return jwt.sign( 51 | { "https://awesomeapi.com/graphql": { roles, permissions } }, 52 | "f1BtnWgD3VKY", 53 | { algorithm: "HS256", subject: id, expiresIn: "1d" } 54 | ); 55 | } 56 | } 57 | }; 58 | 59 | const server = new ApolloServer({ 60 | schema: applyMiddleware( 61 | buildFederatedSchema([{ typeDefs, resolvers }]), 62 | permissions 63 | ), 64 | context: ({ req }) => { 65 | const user = req.headers.user ? JSON.parse(req.headers.user) : null; 66 | return { user }; 67 | } 68 | }); 69 | 70 | server.listen({ port: accounts_port }).then(({ url }) => { 71 | console.log(`Accounts service ready at ${url}`); 72 | }); 73 | -------------------------------------------------------------------------------- /fake_server/accounts/permissions.js: -------------------------------------------------------------------------------- 1 | const { and, or, rule, shield } = require("graphql-shield"); 2 | 3 | function getPermissions(user) { 4 | if (user && user["https://awesomeapi.com/graphql"]) { 5 | return user["https://awesomeapi.com/graphql"].permissions; 6 | } 7 | return []; 8 | } 9 | 10 | const isAuthenticated = rule()((parent, args, { user }) => { 11 | return user !== null; 12 | }); 13 | 14 | const canReadAnyAccount = rule()((parent, args, { user }) => { 15 | const userPermissions = getPermissions(user); 16 | return userPermissions.includes("read:any_account"); 17 | }); 18 | 19 | const canReadOwnAccount = rule()((parent, args, { user }) => { 20 | const userPermissions = getPermissions(user); 21 | return userPermissions.includes("read:own_account"); 22 | }); 23 | 24 | const isReadingOwnAccount = rule()((parent, { id }, { user }) => { 25 | return user && user.sub === id; 26 | }); 27 | 28 | const permissions = shield({ 29 | Query: { 30 | account: or(and(canReadOwnAccount, isReadingOwnAccount), canReadAnyAccount), 31 | accounts: canReadAnyAccount, 32 | viewer: isAuthenticated 33 | } 34 | }); 35 | 36 | module.exports = { permissions }; 37 | -------------------------------------------------------------------------------- /fake_server/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | port: process.env.PORT || 6000, 3 | accounts_port: process.env.ACCOUNTS_PORT || 6001 4 | } 5 | -------------------------------------------------------------------------------- /fake_server/data.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | accounts: [ 3 | { 4 | id: "12345", 5 | name: "QA", 6 | email: "robot@redmadrobot.com", 7 | password: "Qq!11111", 8 | roles: ["admin"], 9 | permissions: ["read:any_account", "read:own_account"] 10 | }, 11 | { 12 | id: "67890", 13 | name: "qa", 14 | email: "qa@redmadrobot.com", 15 | password: "Qq!11111", 16 | roles: ["subscriber"], 17 | permissions: ["read:own_account"] 18 | } 19 | ] 20 | }; 21 | -------------------------------------------------------------------------------- /fake_server/index.js: -------------------------------------------------------------------------------- 1 | const { ApolloGateway, RemoteGraphQLDataSource } = require("@apollo/gateway"); 2 | const { ApolloServer } = require("apollo-server-express"); 3 | const { port, accounts_port } = require("./config") 4 | 5 | const express = require("express"); 6 | const expressJwt = require("express-jwt"); 7 | 8 | const app = express(); 9 | 10 | app.use( 11 | expressJwt({ 12 | secret: "f1BtnWgD3VKY", 13 | algorithms: ["HS256"], 14 | credentialsRequired: false 15 | }) 16 | ); 17 | 18 | const gateway = new ApolloGateway({ 19 | serviceList: [{ name: "accounts", url: `http://0.0.0.0:${accounts_port}`}], 20 | buildService({ name, url }) { 21 | return new RemoteGraphQLDataSource({ 22 | url, 23 | willSendRequest({ request, context }) { 24 | request.http.headers.set( 25 | "user", 26 | context.user ? JSON.stringify(context.user) : null 27 | ); 28 | } 29 | }); 30 | } 31 | }); 32 | 33 | const server = new ApolloServer({ 34 | gateway, 35 | subscriptions: false, 36 | context: ({ req }) => { 37 | const user = req.user || null; 38 | return { user }; 39 | }, 40 | introspection: true, 41 | }); 42 | 43 | server.applyMiddleware({ app }); 44 | 45 | app.listen({ port }, () => 46 | console.log(`Listening on ${port}${server.graphqlPath}`) 47 | ); 48 | -------------------------------------------------------------------------------- /fake_server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apollo-federation-auth-demo", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "concurrently -k npm:start:*", 8 | "start:accounts": "nodemon ./accounts/index.js", 9 | "start:gateway": "wait-on tcp:5001 && nodemon ./index.js" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "@apollo/federation": "^0.13.2", 16 | "@apollo/gateway": "^0.13.2", 17 | "apollo-server": "^2.11.0", 18 | "apollo-server-express": "^2.11.0", 19 | "concurrently": "^5.1.0", 20 | "express": "^4.17.1", 21 | "express-jwt": "^5.3.3", 22 | "graphql-middleware": "^4.0.2", 23 | "graphql-shield": "^7.2.3", 24 | "jsonwebtoken": "^8.5.1", 25 | "nodemon": "^2.0.3", 26 | "wait-on": "^4.0.2" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /features/auth/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /features/auth/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id(GradlePluginId.ANDROID_LIBRARY) 3 | id(GradlePluginId.ANDROID_COMMON_CONFIG) 4 | kotlin(GradlePluginId.KAPT) 5 | id(GradlePluginId.HILT) 6 | } 7 | 8 | dependencies { 9 | implementation(project(":core:core")) 10 | 11 | implementation(CoreDependency.HILT) 12 | kapt(CoreDependency.HILT_COMPILER) 13 | } 14 | -------------------------------------------------------------------------------- /features/auth/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /features/auth/src/main/kotlin/com/redmadrobot/auth/presentation/AuthScreen.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.auth.presentation 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material.Button 8 | import androidx.compose.material.CircularProgressIndicator 9 | import androidx.compose.material.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.graphics.Color 14 | import androidx.compose.ui.res.stringResource 15 | import androidx.compose.ui.tooling.preview.Preview 16 | import androidx.compose.ui.unit.dp 17 | import androidx.hilt.navigation.compose.hiltViewModel 18 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 19 | import com.redmadrobot.auth.R 20 | 21 | @Composable 22 | fun AuthRoute(viewModel: AuthViewModel = hiltViewModel()) { 23 | val isLoading = viewModel.viewState.collectAsStateWithLifecycle().value.isLoading 24 | AuthScreen( 25 | isLoading, 26 | viewModel::onLoginClicked 27 | ) 28 | } 29 | 30 | @Composable 31 | fun AuthScreen( 32 | isLoading: Boolean, 33 | onLoginClicked: () -> Unit, 34 | ) { 35 | Column( 36 | modifier = Modifier.fillMaxSize(), 37 | verticalArrangement = Arrangement.Center, 38 | horizontalAlignment = Alignment.CenterHorizontally 39 | ) { 40 | Text( 41 | text = stringResource(id = R.string.login_title), 42 | color = Color.White, 43 | modifier = Modifier.padding(bottom = 4.dp), 44 | ) 45 | if (isLoading) { 46 | CircularProgressIndicator() 47 | } else { 48 | Button(onClick = onLoginClicked) { 49 | Text(stringResource(id = R.string.login_button_title)) 50 | } 51 | } 52 | } 53 | } 54 | 55 | @Preview( 56 | showBackground = true, 57 | backgroundColor = 0x434A74 58 | ) 59 | @Composable 60 | fun PreviewAuthScreen() { 61 | return AuthScreen(isLoading = false) {} 62 | } -------------------------------------------------------------------------------- /features/auth/src/main/kotlin/com/redmadrobot/auth/presentation/AuthState.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.auth.presentation 2 | 3 | data class AuthState( 4 | val isLoading: Boolean 5 | ) -------------------------------------------------------------------------------- /features/auth/src/main/kotlin/com/redmadrobot/auth/presentation/AuthViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.auth.presentation 2 | 3 | import android.app.Application 4 | import android.widget.Toast 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import com.redmadrobot.core.extensions.safeLaunch 8 | import com.redmadrobot.core_navigation.navigation.Router 9 | import com.redmadrobot.core_navigation.navigation.screens.Routes 10 | import com.redmadrobot.core_network.ApolloApi 11 | import com.redmadrobot.core_network.LoginMutation 12 | import com.redmadrobot.core_presentation.extensions.update 13 | import dagger.hilt.android.lifecycle.HiltViewModel 14 | import kotlinx.coroutines.flow.MutableStateFlow 15 | import kotlinx.coroutines.flow.StateFlow 16 | import javax.inject.Inject 17 | 18 | @HiltViewModel 19 | class AuthViewModel @Inject constructor( 20 | private val api: ApolloApi, 21 | private val router: Router, 22 | private val context: Application, 23 | ) : ViewModel() { 24 | 25 | private val _viewState = MutableStateFlow(AuthState(isLoading = false)) 26 | val viewState: StateFlow = _viewState 27 | 28 | fun onLoginClicked() { 29 | _viewState.update { copy(isLoading = true) } 30 | viewModelScope.safeLaunch( 31 | { 32 | api.mutate(LoginMutation("robot@redmadrobot.com", "Qq!11111")) 33 | router.navigate(Routes.Home) 34 | }, 35 | onError = { throwable -> 36 | Toast.makeText(context, throwable.localizedMessage, Toast.LENGTH_LONG).show() 37 | } 38 | ) 39 | } 40 | } -------------------------------------------------------------------------------- /features/auth/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Please Login 3 | Login 4 | -------------------------------------------------------------------------------- /features/details/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id(GradlePluginId.ANDROID_LIBRARY) 3 | id(GradlePluginId.ANDROID_COMMON_CONFIG) 4 | kotlin(GradlePluginId.KAPT) 5 | id(GradlePluginId.HILT) 6 | } 7 | 8 | dependencies { 9 | implementation(project(":core:core")) 10 | implementation(project(":base:base_cards")) 11 | 12 | implementation(CoreDependency.HILT) 13 | kapt(CoreDependency.HILT_COMPILER) 14 | } -------------------------------------------------------------------------------- /features/details/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /features/details/src/main/kotlin/com/redmadrobot/details/presentation/DetailsScreen.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.details.presentation 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material.Button 5 | import androidx.compose.material.CircularProgressIndicator 6 | import androidx.compose.material.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.ExperimentalComposeUiApi 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.graphics.Color 12 | import androidx.compose.ui.tooling.preview.Preview 13 | import androidx.compose.ui.unit.dp 14 | import androidx.hilt.navigation.compose.hiltViewModel 15 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 16 | import com.redmadrobot.base_cards.toCardViewState 17 | import com.redmadrobot.core_network.CardDetailsQuery.Card 18 | import com.redmadrobot.core_presentation.model.Content 19 | import com.redmadrobot.core_presentation.model.Loading 20 | import com.redmadrobot.core_presentation.model.Stub 21 | import com.redmadrobot.details.presentation.view.CreateCustomCard 22 | 23 | @ExperimentalComposeUiApi 24 | @Composable 25 | fun DetailsRoute(viewModel: DetailsViewModel = hiltViewModel()) { 26 | val state = viewModel.viewState.collectAsStateWithLifecycle().value 27 | DetailsScreen( 28 | state, viewModel::onRetryClicked 29 | ) 30 | } 31 | 32 | @ExperimentalComposeUiApi 33 | @Composable 34 | private fun DetailsScreen( 35 | state: DetailsViewState, 36 | onRetryClicked: () -> Unit, 37 | ) { 38 | when (state) { 39 | is Loading -> { 40 | Box(Modifier.fillMaxSize()) { CircularProgressIndicator(Modifier.align(Alignment.Center)) } 41 | } 42 | is Content -> { 43 | CreateCustomCard(state.content) 44 | } 45 | is Stub -> { 46 | Column( 47 | modifier = Modifier.fillMaxSize(), 48 | horizontalAlignment = Alignment.CenterHorizontally, 49 | verticalArrangement = Arrangement.Center 50 | ) { 51 | Text( 52 | text = "error: ${state.error}", 53 | color = Color.White, 54 | modifier = Modifier.padding(bottom = 14.dp) 55 | ) 56 | Button(onClick = onRetryClicked) { 57 | Text("Try again", color = Color.White) 58 | } 59 | } 60 | } 61 | } 62 | } 63 | 64 | @Preview( 65 | showBackground = true, backgroundColor = 0x434A74 66 | ) 67 | @ExperimentalComposeUiApi 68 | @Composable 69 | fun PreviewDetailsScreenWithContent() { 70 | return DetailsScreen( 71 | Content( 72 | Card( 73 | id = "33", number = "3123", type = "MASTER_CARD", color = "green" 74 | ).toCardViewState() 75 | ) 76 | ) {} 77 | } 78 | 79 | @Preview( 80 | showBackground = true, backgroundColor = 0x434A74 81 | ) 82 | @ExperimentalComposeUiApi 83 | @Composable 84 | fun PreviewDetailsScreenWithError() { 85 | return DetailsScreen( 86 | Stub(RuntimeException()) 87 | ) {} 88 | } 89 | 90 | 91 | -------------------------------------------------------------------------------- /features/details/src/main/kotlin/com/redmadrobot/details/presentation/DetailsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.details.presentation 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import com.redmadrobot.base_cards.toCardViewState 7 | import com.redmadrobot.core.extensions.safeLaunch 8 | import com.redmadrobot.core_network.ApolloApi 9 | import com.redmadrobot.core_network.CardDetailsQuery 10 | import com.redmadrobot.core_presentation.extensions.update 11 | import com.redmadrobot.core_presentation.model.Content 12 | import com.redmadrobot.core_presentation.model.Loading 13 | import com.redmadrobot.core_presentation.model.Stub 14 | import dagger.hilt.android.lifecycle.HiltViewModel 15 | import kotlinx.coroutines.flow.MutableStateFlow 16 | import kotlinx.coroutines.flow.StateFlow 17 | import javax.inject.Inject 18 | 19 | @HiltViewModel 20 | class DetailsViewModel @Inject constructor( 21 | private val api: ApolloApi, 22 | savedStateHandle: SavedStateHandle 23 | ) : ViewModel() { 24 | 25 | private val _viewState = MutableStateFlow(DetailsViewState(card = Loading())) 26 | val viewState: StateFlow = _viewState 27 | private val id = savedStateHandle.get("id")!! 28 | 29 | init { 30 | loadDetails() 31 | } 32 | 33 | fun onRetryClicked() = loadDetails() 34 | 35 | private fun loadDetails() { 36 | _viewState.update { copy(card = Loading()) } 37 | viewModelScope.safeLaunch( 38 | { 39 | val card = api.query(CardDetailsQuery(id = id)).card 40 | _viewState.update { copy(card = Content(card.toCardViewState())) } 41 | }, 42 | onError = { throwable -> 43 | _viewState.update { copy(card = Stub(throwable)) } 44 | } 45 | ) 46 | } 47 | } -------------------------------------------------------------------------------- /features/details/src/main/kotlin/com/redmadrobot/details/presentation/DetailsViewState.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.details.presentation 2 | 3 | import com.redmadrobot.base_cards.model.CardViewState 4 | import com.redmadrobot.core_presentation.model.State 5 | 6 | typealias DetailsViewState = State -------------------------------------------------------------------------------- /features/details/src/main/kotlin/com/redmadrobot/details/presentation/model/CardDetailsParams.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.details.presentation.model 2 | 3 | import android.graphics.PointF 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.ui.geometry.Offset 6 | import androidx.compose.ui.graphics.Path 7 | 8 | var cardPoints = emptyList() 9 | var additionalPoints = emptyList() 10 | var scaleListPoints = emptyList() 11 | var downY = mutableStateOf(0f) 12 | val cardPath = Path() 13 | var axisYStep = 0f 14 | var circleCoordinateX = 0f 15 | var scaleVerticalMargin = 0f 16 | var scaleBottomMargin = 0f 17 | var scaleRightMargin = 0f 18 | var radius = 0f 19 | const val countParts = 10 20 | var height = mutableStateOf(0f) 21 | var width = 0f 22 | var scaleHeight = 0f 23 | var border = 0f 24 | const val maxSum = 600 25 | const val minSum = 0 26 | const val max = maxSum - (maxSum - minSum) / countParts 27 | 28 | var position = 0 29 | fun currentSum(position: Int) = maxSum - (maxSum - minSum) / countParts * (position + 1) -------------------------------------------------------------------------------- /features/details/src/main/kotlin/com/redmadrobot/details/presentation/view/CardDetailsView.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.details.presentation.view 2 | 3 | import android.annotation.SuppressLint 4 | import android.graphics.* 5 | import android.text.TextPaint 6 | import android.view.MotionEvent 7 | import androidx.compose.foundation.Canvas 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.height 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.MutableState 12 | import androidx.compose.runtime.mutableStateOf 13 | import androidx.compose.runtime.remember 14 | import androidx.compose.ui.ExperimentalComposeUiApi 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.geometry.Offset 17 | import androidx.compose.ui.graphics.* 18 | import androidx.compose.ui.graphics.Canvas 19 | import androidx.compose.ui.graphics.drawscope.drawIntoCanvas 20 | import androidx.compose.ui.input.pointer.pointerInteropFilter 21 | import androidx.compose.ui.layout.onSizeChanged 22 | import androidx.compose.ui.platform.LocalContext 23 | import androidx.compose.ui.platform.LocalDensity 24 | import androidx.compose.ui.unit.dp 25 | import androidx.compose.ui.unit.sp 26 | import androidx.core.graphics.drawable.toBitmap 27 | import com.redmadrobot.base_cards.model.CardViewState 28 | import com.redmadrobot.details.R 29 | import com.redmadrobot.details.presentation.model.* 30 | import androidx.compose.ui.graphics.Color 31 | import androidx.compose.ui.graphics.Paint 32 | import androidx.compose.ui.graphics.Path 33 | import androidx.compose.ui.graphics.PathEffect 34 | 35 | @ExperimentalComposeUiApi 36 | @SuppressLint("UseCompatLoadingForDrawables") 37 | @Composable 38 | fun CreateCustomCard( 39 | card: CardViewState 40 | ) { 41 | val imageSize = with(LocalDensity.current) { 150.dp.toPx() }.toInt() 42 | val cardIconWidth = with(LocalDensity.current) { 70.dp.toPx() }.toInt() 43 | val cardIconHeight = with(LocalDensity.current) { 40.dp.toPx() }.toInt() 44 | val circleIconWidth = with(LocalDensity.current) { 10.dp.toPx() }.toInt() 45 | val circleIconHeight = with(LocalDensity.current) { 24.dp.toPx() }.toInt() 46 | val innerMaskImageHeight = with(LocalDensity.current) { 90.dp.toPx() }.toInt() 47 | val innerMaskImageWidth = with(LocalDensity.current) { 80.dp.toPx() }.toInt() 48 | val leftMargin = with(LocalDensity.current) { 20.dp.toPx() } 49 | val imageRadius = with(LocalDensity.current) { 50.dp.toPx() } 50 | val imageCxCoordinate = with(LocalDensity.current) { 70.dp.toPx() } 51 | val imageCyCoordinate = with(LocalDensity.current) { 90.dp.toPx() } 52 | val textTopMargin = with(LocalDensity.current) { 200.dp.toPx() } 53 | val iconTopMargin = with(LocalDensity.current) { 460.dp.toPx() } 54 | val numberTextSize = with(LocalDensity.current) { 50.sp.toPx() } 55 | val scaleTextSize = with(LocalDensity.current) { 12.sp.toPx() } 56 | 57 | circleCoordinateX = with(LocalDensity.current) { 288.dp.toPx() } 58 | scaleVerticalMargin = with(LocalDensity.current) { 124.dp.toPx() } 59 | scaleBottomMargin = with(LocalDensity.current) { 62.dp.toPx() } 60 | scaleRightMargin = with(LocalDensity.current) { 10.dp.toPx() } 61 | radius = with(LocalDensity.current) { 30.dp.toPx() } 62 | border = with(LocalDensity.current) { 2.dp.toPx() } 63 | 64 | val circlePoint: MutableState = remember { 65 | mutableStateOf(Offset(0f, 0f)) 66 | } 67 | 68 | val choosePart: MutableState = remember { 69 | mutableStateOf(4) 70 | } 71 | 72 | val scalePaint = Paint().apply { 73 | color = Color.Gray 74 | strokeWidth = 3f 75 | } 76 | val cardShader = LinearGradientShader( 77 | from = Offset(20f, 0f), 78 | to = Offset(20f, height.value), 79 | colors = card.cardBorderColors, 80 | ) 81 | 82 | val innerCardShader = LinearGradientShader( 83 | from = Offset(20f, border), 84 | to = Offset(circleCoordinateX, height.value), 85 | colors = card.cardColors, 86 | ) 87 | 88 | val innerCircleShader = LinearGradientShader( 89 | from = Offset(circlePoint.value.x - radius + border, circlePoint.value.y - radius + border), 90 | to = Offset(circlePoint.value.x + radius - border, circlePoint.value.y + radius - border), 91 | colors = card.cardColors, 92 | ) 93 | 94 | val circleShader = LinearGradientShader( 95 | from = Offset(circlePoint.value.x - radius, circlePoint.value.y - radius), 96 | to = Offset(circlePoint.value.x + radius, circlePoint.value.y + radius), 97 | colors = card.cardBorderColors, 98 | ) 99 | 100 | val cardPaint = Paint().apply { 101 | style = PaintingStyle.Stroke 102 | strokeWidth = border 103 | pathEffect = PathEffect.cornerPathEffect(20f) 104 | } 105 | val innerCardPaint = Paint().apply { 106 | style = PaintingStyle.Fill 107 | pathEffect = PathEffect.cornerPathEffect(20f) 108 | } 109 | 110 | val circlePaint = Paint().apply { 111 | style = PaintingStyle.Stroke 112 | strokeWidth = border 113 | } 114 | val innerCirclePaint = Paint().apply { 115 | style = PaintingStyle.Fill 116 | } 117 | 118 | val maskPaint = Paint().asFrameworkPaint().apply { 119 | isAntiAlias = true 120 | style = android.graphics.Paint.Style.FILL 121 | color = android.graphics.Color.WHITE 122 | } 123 | 124 | val typeIconPaint = Paint().asFrameworkPaint().apply { 125 | isAntiAlias = true 126 | style = android.graphics.Paint.Style.FILL 127 | } 128 | 129 | val textPaint = TextPaint().apply { 130 | isAntiAlias = true 131 | textSize = numberTextSize 132 | color = android.graphics.Color.WHITE 133 | } 134 | 135 | val textScalePaint = TextPaint().apply { 136 | isAntiAlias = true 137 | textSize = scaleTextSize 138 | textAlign = android.graphics.Paint.Align.RIGHT 139 | color = android.graphics.Color.GRAY 140 | } 141 | 142 | val sourceBitmap = LocalContext.current.getDrawable(R.drawable.ic_iron_man) 143 | ?.toBitmap(innerMaskImageWidth, innerMaskImageHeight, Bitmap.Config.ARGB_8888) 144 | val maskBitmap = Bitmap.createBitmap(imageSize, imageSize, Bitmap.Config.ARGB_8888) 145 | val resultBitmap = maskBitmap.copy(Bitmap.Config.ARGB_8888, true) 146 | val maskCanvas = android.graphics.Canvas(maskBitmap) 147 | maskCanvas.drawCircle(imageCxCoordinate, imageCyCoordinate, imageRadius, maskPaint) 148 | maskPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN) 149 | val resultCanvas = android.graphics.Canvas(resultBitmap) 150 | resultCanvas.drawBitmap(maskBitmap, 0f, 0f, null) 151 | sourceBitmap?.let { resultCanvas.drawBitmap(it, 90f, 130f, null) } 152 | 153 | val iconSourceBitmap = LocalContext.current.getDrawable(R.drawable.ic_master) 154 | ?.toBitmap(cardIconWidth, cardIconHeight, Bitmap.Config.ARGB_8888) 155 | typeIconPaint.colorFilter = 156 | PorterDuffColorFilter(card.iconColor.toArgb(), PorterDuff.Mode.SRC_IN) 157 | 158 | val circleIconBitmap = LocalContext.current.getDrawable(R.drawable.ic_circle_arrow) 159 | ?.toBitmap(circleIconWidth, circleIconHeight, Bitmap.Config.ARGB_8888) 160 | 161 | Canvas(modifier = Modifier 162 | .fillMaxWidth() 163 | .height(524.dp) 164 | .onSizeChanged { 165 | height.value = it.height.toFloat() 166 | width = it.width.toFloat() 167 | scaleHeight = height.value - scaleVerticalMargin 168 | axisYStep = scaleHeight / (countParts - 1) 169 | circlePoint.value = calculateCircleCoordinates(choosePart.value) 170 | cardPoints = calculateCardListPoints(circlePoint) 171 | additionalPoints = additionalCirclePoints() 172 | scaleListPoints = calculateScalePoints() 173 | buildCardPath( 174 | path = cardPath, 175 | ) 176 | } 177 | .pointerInteropFilter { event -> 178 | when (event.action) { 179 | MotionEvent.ACTION_DOWN -> { 180 | downY.value = event.y 181 | } 182 | MotionEvent.ACTION_MOVE -> { 183 | val x = event.x 184 | val y = event.y 185 | val deltaY = downY.value - event.y 186 | if (x in (circlePoint.value.x - radius..circlePoint.value.x + radius)) { 187 | if (deltaY >= 0 && y < (circlePoint.value.y - radius) && choosePart.value >= 1) { 188 | downY.value = circlePoint.value.y 189 | val pos = choosePart.value - 1 190 | updateCoordinates(circlePoint, choosePart, pos) 191 | } else if (deltaY < 0 && y > (circlePoint.value.y + radius) && choosePart.value <= 8) { 192 | downY.value = circlePoint.value.y 193 | val pos = choosePart.value + 1 194 | updateCoordinates(circlePoint, choosePart, pos) 195 | } 196 | } 197 | } 198 | MotionEvent.ACTION_UP -> { 199 | } 200 | } 201 | true 202 | } 203 | ) { 204 | 205 | cardPaint.shader = cardShader 206 | innerCardPaint.shader = innerCardShader 207 | circlePaint.shader = circleShader 208 | innerCirclePaint.shader = innerCircleShader 209 | 210 | drawIntoCanvas { canvas -> 211 | drawCircle( 212 | canvas = canvas, 213 | outerPaint = circlePaint, 214 | innerPaint = innerCirclePaint, 215 | center = circlePoint.value, 216 | bitmap = circleIconBitmap, 217 | bitmapCenter = Offset(circleIconWidth.toFloat(), circleIconHeight.toFloat()) 218 | ) 219 | drawScale( 220 | canvas = canvas, 221 | scalePoints = scaleListPoints, 222 | paint = scalePaint, 223 | textPaint = textScalePaint, 224 | ) 225 | drawCard( 226 | canvas = canvas, 227 | innerPaint = innerCardPaint, 228 | outerPaint = cardPaint, 229 | path = cardPath 230 | ) 231 | drawImage( 232 | canvas = canvas.nativeCanvas, 233 | bitmap = resultBitmap 234 | ) 235 | drawBudget( 236 | canvas = canvas.nativeCanvas, 237 | number = "$${currentSum(position)}", 238 | coordinates = Offset(leftMargin, textTopMargin), 239 | paint = textPaint 240 | ) 241 | drawCardType( 242 | canvas = canvas.nativeCanvas, 243 | bitmap = iconSourceBitmap, 244 | coordinates = Offset(leftMargin, iconTopMargin), 245 | paint = typeIconPaint 246 | ) 247 | } 248 | } 249 | } 250 | 251 | private fun updateCoordinates( 252 | circlePoint: MutableState, 253 | choosePart: MutableState, 254 | pos: Int 255 | ) { 256 | position = pos 257 | choosePart.value = pos 258 | circlePoint.value = calculateCircleCoordinates(pos) 259 | cardPoints = calculateCardListPoints(circlePoint) 260 | additionalPoints = additionalCirclePoints() 261 | scaleListPoints = calculateScalePoints() 262 | buildCardPath(cardPath) 263 | } 264 | 265 | private fun drawImage( 266 | canvas: android.graphics.Canvas, 267 | bitmap: Bitmap 268 | ) { 269 | canvas.drawBitmap(bitmap, 0f, 0f, null) 270 | } 271 | 272 | private fun drawCardType( 273 | canvas: android.graphics.Canvas, 274 | bitmap: Bitmap?, 275 | coordinates: Offset, 276 | paint: android.graphics.Paint 277 | ) { 278 | bitmap?.let { canvas.drawBitmap(it, coordinates.x, coordinates.y, paint) } 279 | } 280 | 281 | private fun drawBudget( 282 | canvas: android.graphics.Canvas, 283 | number: String, 284 | coordinates: Offset, 285 | paint: TextPaint 286 | ) { 287 | canvas.drawText(number, coordinates.x, coordinates.y, paint) 288 | } 289 | 290 | private fun drawCircle( 291 | canvas: Canvas, 292 | innerPaint: Paint, 293 | outerPaint: Paint, 294 | center: Offset, 295 | bitmap: Bitmap?, 296 | bitmapCenter: Offset 297 | ) { 298 | canvas.drawCircle( 299 | center = center, 300 | radius = radius - border, 301 | paint = innerPaint 302 | ) 303 | canvas.drawCircle( 304 | center = center, 305 | radius = radius, 306 | paint = outerPaint 307 | ) 308 | bitmap?.let { 309 | canvas.nativeCanvas.drawBitmap( 310 | it, 311 | center.x - bitmapCenter.x / 2, 312 | center.y - bitmapCenter.y / 2, 313 | null 314 | ) 315 | } 316 | } 317 | 318 | private fun drawScale(canvas: Canvas, scalePoints: List, paint: Paint, textPaint: TextPaint) { 319 | val lastIndex = scalePoints.lastIndex 320 | scalePoints.forEachIndexed { index, point -> 321 | when (index) { 322 | 0 -> canvas.nativeCanvas.drawText("$"+ minSum, point.x, point.y, textPaint) 323 | lastIndex -> canvas.nativeCanvas.drawText("$"+ max.toString(), point.x, point.y, textPaint) 324 | else -> canvas.drawLine(Offset(point.x, point.y), Offset(point.x - 20f, point.y), paint) 325 | } 326 | } 327 | } 328 | 329 | private fun drawCard(canvas: Canvas, innerPaint: Paint, outerPaint: Paint, path: Path) { 330 | canvas.drawPath(path, innerPaint) 331 | canvas.drawPath(path, outerPaint) 332 | } 333 | 334 | private fun buildCardPath(path: Path) { 335 | path.reset() 336 | cardPoints.forEachIndexed { index, point -> 337 | when (index) { 338 | 0 -> path.moveTo(point.x, point.y) 339 | 3 -> { 340 | path.cubicTo( 341 | additionalPoints[0].x, additionalPoints[0].y, 342 | additionalPoints[1].x, additionalPoints[1].y, 343 | point.x, point.y, 344 | ) 345 | } 346 | 4 -> { 347 | path.cubicTo( 348 | additionalPoints[2].x, additionalPoints[2].y, 349 | additionalPoints[3].x, additionalPoints[3].y, 350 | point.x, point.y + radius / 3 351 | ) 352 | } 353 | else -> path.lineTo(point.x, point.y) 354 | } 355 | } 356 | path.close() 357 | } 358 | 359 | private fun calculateScalePoints(): List { 360 | val points = mutableListOf() 361 | val scaleHeight = height.value - scaleBottomMargin 362 | val coordinateX = width - scaleRightMargin 363 | for (i in 0 until countParts) { 364 | val step = scaleHeight - axisYStep * i 365 | points.add(PointF(coordinateX, step)) 366 | } 367 | return points 368 | } 369 | 370 | private fun additionalCirclePoints(): List { 371 | val topCirclePoint = cardPoints[2] 372 | val middleCirclePoint = cardPoints[3] 373 | val bottomCirclePoint = cardPoints[4] 374 | 375 | val additionFirstTopCirclePoint = PointF(topCirclePoint.x, topCirclePoint.y + radius / 3) 376 | val additionSecondTopCirclePoint = 377 | PointF(middleCirclePoint.x, middleCirclePoint.y - 4 * radius / 3) 378 | val additionFirstBottomCirclePoint = 379 | PointF(middleCirclePoint.x, middleCirclePoint.y + 4 * radius / 3) 380 | val additionSecondBottomCirclePoint = 381 | PointF(bottomCirclePoint.x, bottomCirclePoint.y - radius / 3) 382 | 383 | return listOf( 384 | additionFirstTopCirclePoint, 385 | additionSecondTopCirclePoint, 386 | additionFirstBottomCirclePoint, 387 | additionSecondBottomCirclePoint 388 | ) 389 | } 390 | 391 | private fun calculateCardListPoints( 392 | circlePoint: MutableState 393 | ): List { 394 | val topCirclePoint = Offset(circlePoint.value.x, circlePoint.value.y - axisYStep) 395 | val bottomCirclePoint = Offset(circlePoint.value.x, circlePoint.value.y + axisYStep) 396 | val middleCirclePoint = Offset(circlePoint.value.x - 1.3f * radius, circlePoint.value.y) 397 | 398 | return listOf( 399 | Offset(-20f, border), 400 | Offset(circlePoint.value.x, border), 401 | topCirclePoint, 402 | middleCirclePoint, 403 | bottomCirclePoint, 404 | Offset(circlePoint.value.x, height.value - border), 405 | Offset(-20f, height.value - border), 406 | ) 407 | } 408 | 409 | 410 | private fun calculateCircleCoordinates( 411 | choosePart: Int, 412 | ): Offset { 413 | val y = choosePart * axisYStep + scaleBottomMargin 414 | return Offset(circleCoordinateX, y) 415 | } 416 | -------------------------------------------------------------------------------- /features/details/src/main/res/drawable/ic_circle_arrow.xml: -------------------------------------------------------------------------------- 1 | 6 | 13 | 20 | 27 | 28 | -------------------------------------------------------------------------------- /features/details/src/main/res/drawable/ic_iron_man.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /features/home/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /features/home/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id(GradlePluginId.ANDROID_LIBRARY) 3 | id(GradlePluginId.ANDROID_COMMON_CONFIG) 4 | kotlin(GradlePluginId.KAPT) 5 | id(GradlePluginId.HILT) 6 | } 7 | 8 | dependencies { 9 | implementation(project(":core:core")) 10 | implementation(project(":base:base_cards")) 11 | 12 | implementation(CoreDependency.HILT) 13 | kapt(CoreDependency.HILT_COMPILER) 14 | } 15 | -------------------------------------------------------------------------------- /features/home/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /features/home/src/main/kotlin/com/redmadrobot/home/presentation/HomeScreen.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.home.presentation 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.lazy.LazyColumn 6 | import androidx.compose.foundation.lazy.rememberLazyListState 7 | import androidx.compose.foundation.shape.RoundedCornerShape 8 | import androidx.compose.material.Button 9 | import androidx.compose.material.CircularProgressIndicator 10 | import androidx.compose.material.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.LaunchedEffect 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.draw.clip 16 | import androidx.compose.ui.graphics.Color 17 | import androidx.compose.ui.unit.dp 18 | import androidx.hilt.navigation.compose.hiltViewModel 19 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 20 | import com.redmadrobot.base_cards.model.CardViewState 21 | import com.redmadrobot.core_presentation.model.Content 22 | import com.redmadrobot.core_presentation.model.Loading 23 | import com.redmadrobot.core_presentation.model.Stub 24 | import com.redmadrobot.home.presentation.view.CardView 25 | 26 | @Composable 27 | fun HomeRoute() { 28 | val viewModel: HomeViewModel = hiltViewModel() 29 | HomeScreen(viewModel) 30 | } 31 | 32 | @Composable 33 | fun HomeScreen(viewModel: HomeViewModel) { 34 | when (val state = viewModel.viewState.collectAsStateWithLifecycle().value) { 35 | is Loading -> { 36 | Box(Modifier.fillMaxSize()) { CircularProgressIndicator(Modifier.align(Alignment.Center)) } 37 | } 38 | is Content -> { 39 | val cards: List = state.content 40 | val listState = rememberLazyListState() 41 | LazyColumn( 42 | modifier = Modifier 43 | .fillMaxSize() 44 | .padding(horizontal = 14.dp) 45 | .background(Color(0xFF434A74)) 46 | .clip(RoundedCornerShape(10.dp)), 47 | reverseLayout = true, 48 | verticalArrangement = Arrangement.spacedBy((-90).dp), 49 | state = listState, 50 | ) { 51 | items(count = cards.size) { cardIndex -> 52 | CardView( 53 | cardViewState = cards[cardIndex], onCardClicked = viewModel::onCardClicked 54 | ) 55 | } 56 | } 57 | LaunchedEffect(viewModel.scrollToLastPosition) { 58 | listState.scrollToItem(cards.size - 1) 59 | viewModel.onLastPositionScrolled() 60 | } 61 | } 62 | is Stub -> { 63 | Column { 64 | Text(text = "error: ${state.error}", color = Color.White) 65 | Button(onClick = viewModel::onRetryClicked) { 66 | Text("Try again", color = Color.White) 67 | } 68 | } 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /features/home/src/main/kotlin/com/redmadrobot/home/presentation/HomeViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.home.presentation 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import com.redmadrobot.base_cards.toCardViewState 7 | import com.redmadrobot.core.extensions.safeLaunch 8 | import com.redmadrobot.core_navigation.navigation.Router 9 | import com.redmadrobot.core_navigation.navigation.screens.Routes 10 | import com.redmadrobot.core_network.ApolloApi 11 | import com.redmadrobot.core_network.CardsListQuery 12 | import com.redmadrobot.core_presentation.extensions.update 13 | import com.redmadrobot.core_presentation.model.Content 14 | import com.redmadrobot.core_presentation.model.Loading 15 | import com.redmadrobot.core_presentation.model.Stub 16 | import com.redmadrobot.home.presentation.state.HomeViewState 17 | import dagger.hilt.android.lifecycle.HiltViewModel 18 | import kotlinx.coroutines.flow.MutableStateFlow 19 | import kotlinx.coroutines.flow.StateFlow 20 | import javax.inject.Inject 21 | 22 | @HiltViewModel 23 | class HomeViewModel @Inject constructor( 24 | private val api: ApolloApi, 25 | private val router: Router, 26 | private val savedStateHandle: SavedStateHandle 27 | ) : ViewModel() { 28 | 29 | companion object { 30 | private const val SCROLL_TO_LAST_POSITION_KEY = "SCROLL_TO_LAST_POSITION_KEY" 31 | } 32 | 33 | private val _viewState = MutableStateFlow(Loading()) 34 | val viewState: StateFlow = _viewState 35 | 36 | // Fix negative arrangement: in initial state the list have a wrong position 37 | var scrollToLastPosition = savedStateHandle.get(SCROLL_TO_LAST_POSITION_KEY) ?: true 38 | private set 39 | 40 | init { 41 | loadCards() 42 | } 43 | 44 | fun onRetryClicked() = loadCards() 45 | 46 | fun onLastPositionScrolled() { 47 | savedStateHandle.set(SCROLL_TO_LAST_POSITION_KEY, false) 48 | scrollToLastPosition = false 49 | } 50 | 51 | private fun loadCards() { 52 | _viewState.update { Loading() } 53 | viewModelScope.safeLaunch( 54 | { 55 | val cards = api.query(CardsListQuery()).cards 56 | _viewState.update { Content(cards.map(CardsListQuery.Card::toCardViewState)) } 57 | }, 58 | onError = { throwable -> 59 | _viewState.update { Stub(throwable) } 60 | } 61 | ) 62 | } 63 | 64 | fun onCardClicked(id: String) { 65 | router.navigate(Routes.toDetails(id)) 66 | } 67 | } -------------------------------------------------------------------------------- /features/home/src/main/kotlin/com/redmadrobot/home/presentation/state/HomeViewState.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.home.presentation.state 2 | 3 | import com.redmadrobot.core_presentation.model.State 4 | import com.redmadrobot.base_cards.model.CardViewState 5 | 6 | typealias HomeViewState = State> -------------------------------------------------------------------------------- /features/home/src/main/kotlin/com/redmadrobot/home/presentation/view/CardView.kt: -------------------------------------------------------------------------------- 1 | package com.redmadrobot.home.presentation.view 2 | 3 | import android.annotation.SuppressLint 4 | import androidx.compose.foundation.BorderStroke 5 | import androidx.compose.foundation.background 6 | import androidx.compose.foundation.border 7 | import androidx.compose.foundation.clickable 8 | import androidx.compose.foundation.layout.* 9 | import androidx.compose.foundation.shape.RoundedCornerShape 10 | import androidx.compose.material.Icon 11 | import androidx.compose.material.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.draw.clip 16 | import androidx.compose.ui.draw.drawBehind 17 | import androidx.compose.ui.graphics.Color 18 | import androidx.compose.ui.graphics.Paint 19 | import androidx.compose.ui.graphics.drawscope.drawIntoCanvas 20 | import androidx.compose.ui.graphics.nativeCanvas 21 | import androidx.compose.ui.res.painterResource 22 | import androidx.compose.ui.unit.Dp 23 | import androidx.compose.ui.unit.dp 24 | import com.redmadrobot.base_cards.model.CardViewState 25 | import com.redmadrobot.home.R 26 | 27 | @SuppressLint("UnrememberedMutableState") 28 | @Composable 29 | fun CardView(cardViewState: CardViewState, onCardClicked: (String) -> Unit) { 30 | val height = 200.dp 31 | Column( 32 | Modifier 33 | .padding(horizontal = 10.dp, vertical = 10.dp) 34 | .drawColoredShadow( 35 | Color.White, 36 | darkColor = Color.Black, 37 | borderRadius = 10.dp, 38 | offsetY = 0.dp, 39 | offsetX = 0.dp, 40 | offsetDarkY = 10.dp 41 | ) 42 | .clickable { 43 | onCardClicked(cardViewState.id) 44 | } 45 | .fillMaxWidth() 46 | .height(height) 47 | .clip(RoundedCornerShape(10.dp)) 48 | .background(cardViewState.cardGradient) 49 | .border(BorderStroke(1.dp, cardViewState.borderGradient), RoundedCornerShape(10.dp)) 50 | ) { 51 | Icon( 52 | modifier = Modifier 53 | .padding(30.dp), 54 | painter = painterResource(id = R.drawable.ic_subtract), 55 | contentDescription = "substract", 56 | tint = cardViewState.iconColor, 57 | ) 58 | Spacer(modifier = Modifier.height(40.dp)) 59 | Row( 60 | Modifier 61 | .padding(horizontal = 30.dp) 62 | .fillMaxWidth(), 63 | horizontalArrangement = Arrangement.SpaceBetween 64 | ) { 65 | Text( 66 | modifier = Modifier 67 | .align(Alignment.Bottom) 68 | .drawTextColoredShadow( 69 | shadowColor = Color.White, 70 | textColor = cardViewState.iconColor, 71 | text = cardViewState.number 72 | ), color = Color.Black, text = "" 73 | ) 74 | Icon( 75 | modifier = Modifier.align(Alignment.Bottom), 76 | painter = painterResource(id = cardViewState.iconRes), 77 | contentDescription = "type", 78 | tint = cardViewState.iconColor 79 | ) 80 | } 81 | } 82 | } 83 | 84 | private fun Modifier.drawTextColoredShadow( 85 | shadowColor: Color, 86 | textColor: Color, 87 | alpha: Float = 0.8f, 88 | shadowRadius: Float = 5f, 89 | offsetY: Float = 3f, 90 | offsetX: Float = 3f, 91 | text: String 92 | ) = this.drawBehind { 93 | val shadowColorArgb = 94 | android.graphics.Color.toArgb(shadowColor.copy(alpha = alpha).value.toLong()) 95 | val textColorArgb = android.graphics.Color.toArgb(textColor.value.toLong()) 96 | 97 | drawIntoCanvas { 98 | val paint = Paint() 99 | val frameworkPaint = paint.asFrameworkPaint() 100 | frameworkPaint.isAntiAlias = true 101 | frameworkPaint.color = textColorArgb 102 | frameworkPaint.textSize = 50f 103 | frameworkPaint.setShadowLayer( 104 | shadowRadius, 105 | offsetX, 106 | offsetY, 107 | shadowColorArgb 108 | ) 109 | it.nativeCanvas.drawText(text, 0f, this.size.height, frameworkPaint) 110 | } 111 | } 112 | 113 | private fun Modifier.drawColoredShadow( 114 | color: Color, 115 | darkColor: Color, 116 | alpha: Float = 0.2f, 117 | borderRadius: Dp = 20.dp, 118 | shadowRadius: Dp = 10.dp, 119 | offsetY: Dp = 0.dp, 120 | offsetX: Dp = 0.dp, 121 | offsetDarkY: Dp = 0.dp, 122 | offsetDarkX: Dp = 0.dp 123 | ) = this.drawBehind { 124 | val transparentColor = android.graphics.Color.toArgb(color.copy(alpha = 0.0f).value.toLong()) 125 | 126 | val shadowColor = android.graphics.Color.toArgb(color.copy(alpha = alpha).value.toLong()) 127 | val shadowDarkColor = 128 | android.graphics.Color.toArgb(darkColor.copy(alpha = alpha).value.toLong()) 129 | this.drawIntoCanvas { 130 | val paint = Paint() 131 | val frameworkPaint = paint.asFrameworkPaint() 132 | frameworkPaint.color = transparentColor 133 | frameworkPaint.setShadowLayer( 134 | shadowRadius.toPx(), 135 | offsetX.toPx(), 136 | offsetY.toPx(), 137 | shadowColor 138 | ) 139 | it.drawRoundRect( 140 | 0f, 141 | 0f, 142 | this.size.width - 2 * shadowRadius.value, 143 | this.size.height - 2 * shadowRadius.value, 144 | borderRadius.toPx(), 145 | borderRadius.toPx(), 146 | paint 147 | ) 148 | val darkPaint = Paint() 149 | val frameworkDarkPaint = darkPaint.asFrameworkPaint() 150 | frameworkDarkPaint.setShadowLayer( 151 | shadowRadius.toPx(), 152 | offsetDarkX.toPx(), 153 | offsetDarkY.toPx(), 154 | shadowDarkColor 155 | ) 156 | it.drawRoundRect( 157 | 2 * shadowRadius.value, 158 | 0f, 159 | this.size.width - 2 * shadowRadius.value, 160 | this.size.height, 161 | borderRadius.toPx(), 162 | borderRadius.toPx(), 163 | darkPaint 164 | ) 165 | } 166 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # -------Gradle-------- 2 | org.gradle.jvmargs=-Xmx6g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g 3 | org.gradle.configureondemand=true 4 | org.gradle.daemon=true 5 | org.gradle.parallel=true 6 | org.gradle.caching=true 7 | 8 | # -------Android------- 9 | android.useAndroidX=true 10 | 11 | # -------Kotlin-------- 12 | kotlin.code.style=official 13 | 14 | # -------Kapt---------- 15 | kapt.incremental.apt=true -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedMadRobot/android-mad-showcase/9e90bf0432eb9170ce7e73e0fa779ea4e7f8cd6b/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Wed Aug 04 11:21:41 SAMT 2021 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = ("android-mad-showcase") 2 | 3 | include(":app") 4 | 5 | include( 6 | ":core:core", 7 | ":core:core_navigation", 8 | ":core:core_network", 9 | ":core:core_presentation" 10 | ) 11 | 12 | include( 13 | "base:base_cards" 14 | ) 15 | 16 | include( 17 | ":features:auth", 18 | ":features:home", 19 | ":features:details" 20 | ) 21 | --------------------------------------------------------------------------------