diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml new file mode 100644 index 0000000..780f8f8 --- /dev/null +++ b/.github/workflows/deployment.yml @@ -0,0 +1,49 @@ +name: Test Workflow +on: + push: + branches: + - main + - develop + + workflow_dispatch: + pull_request: + types: [opened, closed] + +jobs: +# Detekt: +# runs-on: ubuntu-latest +# steps: +# - name: Checkout Code +# uses: actions/checkout@v3 +# - name: Run Detekt +# uses: natiginfo/action-detekt-all@1.22.0 + + Lint: +# needs: Detekt + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v3 + - name: Run Lint + run: ./gradlew lint + + Test: + needs: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v3 + - name: Run tests + run: ./gradlew test + + Build: + needs: Test # it will be run after test job, we can use a list of jobs here. + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v3 + - name: Build App + uses: sparkfabrik/android-build-action@v1.0.0 + with: + project-path: ./ + output-path: my-app.apk \ No newline at end of file diff --git a/.github/workflows/output.yml b/.github/workflows/output.yml new file mode 100644 index 0000000..e793b8d --- /dev/null +++ b/.github/workflows/output.yml @@ -0,0 +1,8 @@ +name: Output Information +on: workflow_dispatch +jobs: + Info: + runs-on: ubuntu-latest + steps: + - name: Output github context + run: echo "${{ toJSON(github) }}" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml new file mode 100644 index 0000000..c22e5cd --- /dev/null +++ b/.idea/androidTestResultsUserPreferences.xml @@ -0,0 +1,204 @@ + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..7643783 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..fb7f4a8 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..2d1349b --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,33 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..ed76bea --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,37 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..bdd9278 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index a850934..8b4ff9c 100644 --- a/README.md +++ b/README.md @@ -1 +1,19 @@ -# ComposeApp \ No newline at end of file +# Compose App + +This is a sample modularized android application with clean architecture and compose + +## Technologies + +- Kotlin +- MVVM +- Compose +- Coroutines +- Clean architecture +- Compose navigation +- Reactive programing + +## To Do +- Writing unit tests +- Pagination +- Search history +- Show increase or decrease of rates \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..0615c4f --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,77 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + kotlin("kapt") + id("com.google.dagger.hilt.android") + +} + +android { + namespace = "com.example.compose" + compileSdk = AppConfig.compileSdk + + defaultConfig { + applicationId = "com.example.compose" + minSdk = AppConfig.minSdk + targetSdk = AppConfig.targetSdk + versionCode = AppConfig.versionCode + versionName = AppConfig.versionName + + testInstrumentationRunner = AppConfig.androidTestInstrumentation + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = Version.KOTLIN_COMPILER_EXTENSION_VERSION + } + packagingOptions { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1,LICENSE}" + pickFirsts += "/META-INF/{AL2.0,LGPL2.1,LICENSE*}" + } + } +} + +dependencies { + androidxCore() + lifecycle() + compose() + composeMaterial() + composeNavigation() + hilt() + junit4() + espresso() + composeTest() + retrofit() + + moduleDependency(":data:datastore") + moduleDependency(":data:common") + moduleDependency(":ui:home") + moduleDependency(":ui:main") + moduleDependency(":ui:detail") + moduleDependency(":ui:search") + moduleDependency(":ui:common") + moduleDependency(":ui:setting") + moduleDependency(":ui:favorite") +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..ff59496 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/example/compose/MainActivityTest.kt b/app/src/androidTest/java/com/example/compose/MainActivityTest.kt new file mode 100644 index 0000000..c477546 --- /dev/null +++ b/app/src/androidTest/java/com/example/compose/MainActivityTest.kt @@ -0,0 +1,134 @@ +package com.example.compose + +import android.content.Context.MODE_PRIVATE +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.text.input.ImeAction +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.example.data.common.database.DbConfig.DATABASE_NAME +import com.example.data.common.database.DbConfig.FAVORITE_TABLE_NAME +import com.example.main.TestTag +import com.example.ui.common.R +import com.example.ui.common.ThemeType +import com.example.ui.common.test.getString +import com.example.ui.common.test.scrollToEnd +import com.example.ui.common.test.wait +import com.example.ui.common.test.waitUntilDisplayed +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import com.example.ui.common.R.string as CommonString +import com.example.ui.common.test.TestTag as UiCommonTestTag +import com.example.ui.detail.R.string as DetailString +import com.example.ui.home.R.string as HomeString +import com.example.ui.main.R.string as MainString + + +@RunWith(AndroidJUnit4::class) +class MainActivityTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Before + fun setup() { + val db = InstrumentationRegistry.getInstrumentation() + .targetContext.openOrCreateDatabase( + DATABASE_NAME, + MODE_PRIVATE, + null + ) + db.delete(FAVORITE_TABLE_NAME, null, null) + } + + @Test + fun test_homeScreen_scrolling() { + with(composeTestRule) { + onNodeWithTag(TestTag.BOTTOM_BAR).assertIsDisplayed() + waitUntilDisplayed(hasScrollToIndexAction()) + scrollToEnd(hasScrollToIndexAction(), step = 30) + onNode(hasScrollToIndexAction()).performScrollToIndex(0) + } + } + + + @Test + fun test_navigationTo_detailScreen_and_return() { + with(composeTestRule) { + waitUntilDisplayed(hasScrollToIndexAction()) + onNodeWithTag(TestTag.BOTTOM_BAR).assertIsDisplayed() + + onAllNodes(hasScrollToIndexAction()).onFirst().performClick() + + onNodeWithTag(TestTag.BOTTOM_BAR).assertDoesNotExist() + + onNodeWithContentDescription(getString(DetailString.favoriteIcon)).performClick() + + onNodeWithContentDescription(getString(R.string.backIconContentDescription)).performClick() + onNodeWithTag(TestTag.BOTTOM_BAR).assertIsDisplayed() + } + } + + + @Test + fun test_navigateTo_favoriteScreen_and_checkFavorites() { + with(composeTestRule) { + onNodeWithText(getString(MainString.favorite)).performClick() + + onNodeWithTag(UiCommonTestTag.EMPTY_VIEW).assertIsDisplayed() + + onNodeWithText(getString(MainString.home)).performClick() + waitUntilDisplayed(hasScrollToIndexAction()) + + onAllNodesWithContentDescription(getString(CommonString.favoriteIconDescription)) + .onFirst() + .performClick() + + onNodeWithText(getString(MainString.favorite)).performClick() + + waitUntilDisplayed(hasScrollToIndexAction()) + onNodeWithTag(UiCommonTestTag.EMPTY_VIEW).assertDoesNotExist() + onAllNodesWithContentDescription(getString(CommonString.favoriteIconDescription)) + .assertCountEquals(1) + + onAllNodesWithContentDescription(getString(CommonString.favoriteIconDescription)) + .onFirst() + .performClick() + onNodeWithTag(UiCommonTestTag.EMPTY_VIEW).assertIsDisplayed() + + onAllNodesWithContentDescription(getString(CommonString.favoriteIconDescription)) + .assertCountEquals(0) + } + } + + @Test + fun test_navigateTo_searchScreen_and_return() { + with(composeTestRule) { + onNodeWithContentDescription(getString(HomeString.searchIconContentDescription)).performClick() + + val query = "b" + onNode(hasImeAction(ImeAction.Search)).performTextInput(query) + waitUntilDisplayed(hasScrollToIndexAction()) + + onNode(hasScrollToIndexAction()).onChildren().onFirst().performClick() + onNodeWithContentDescription(getString(R.string.backIconContentDescription)).performClick() + + onNodeWithText(getString(R.string.cancel)).performClick() + } + } + + @Test + fun test_setting_change_app_theme() { + with(composeTestRule) { + onNodeWithText(getString(MainString.setting)).performClick() + onNodeWithText(ThemeType.LIGHT.name).performClick() + wait(200) + onNodeWithText(ThemeType.DARK.name).performClick() + wait(200) + onNodeWithText(ThemeType.SYSTEM.name).performClick() + onNodeWithText(getString(MainString.home)).performClick() + } + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4fc516a --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/compose/AppLauncher.kt b/app/src/main/java/com/example/compose/AppLauncher.kt new file mode 100644 index 0000000..55b966d --- /dev/null +++ b/app/src/main/java/com/example/compose/AppLauncher.kt @@ -0,0 +1,12 @@ +package com.example.compose + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +/** + * @author yaya (@yahyalmh) + * @since 28th October 2022 + */ + +@HiltAndroidApp +class AppLauncher : Application() \ No newline at end of file diff --git a/app/src/main/java/com/example/compose/MainActivity.kt b/app/src/main/java/com/example/compose/MainActivity.kt new file mode 100644 index 0000000..7724e19 --- /dev/null +++ b/app/src/main/java/com/example/compose/MainActivity.kt @@ -0,0 +1,20 @@ +package com.example.compose + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.navigation.compose.rememberNavController +import com.example.main.MainScreen +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + val navController = rememberNavController() + MainScreen(navController = navController) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..eca70cf --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..eca70cf --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..513d78d --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,9 @@ + + Compose + No Internet connection + online + offline + Favorite + Home + Setting + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..95302db --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..fa0f996 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9ee9997 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/com/example/compose/ExampleUnitTest.kt b/app/src/test/java/com/example/compose/ExampleUnitTest.kt new file mode 100644 index 0000000..a83bd2d --- /dev/null +++ b/app/src/test/java/com/example/compose/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package com.example.compose + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..222342e --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,9 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + id("com.android.application") version "7.3.1" apply false + id("com.android.library") version "7.3.1" apply false + id("org.jetbrains.kotlin.android") version "1.6.10" apply false + id("org.jetbrains.kotlin.jvm") version "1.7.20" apply false + id("com.google.dagger.hilt.android") version Version.HILT apply false + id("de.mannodermaus.android-junit5") version "1.8.2.1" apply false +} \ No newline at end of file diff --git a/buildSrc/.gitignore b/buildSrc/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/buildSrc/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..9cb33dd --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() + google() +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/AppConfig.kt b/buildSrc/src/main/kotlin/AppConfig.kt new file mode 100644 index 0000000..53cb432 --- /dev/null +++ b/buildSrc/src/main/kotlin/AppConfig.kt @@ -0,0 +1,16 @@ +/** + * @author yaya (@yahyalmh) + * @since 28th October 2022 + */ + +object AppConfig { + + const val compileSdk = 33 + const val minSdk = 21 + const val targetSdk = 33 + const val versionCode = 1 + const val versionName = "1.0.0" + + const val androidTestInstrumentation = "androidx.test.runner.AndroidJUnitRunner" + const val appCustomTestRunner = "com.example.common.test.AppTestRunner" +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt new file mode 100644 index 0000000..9e322f8 --- /dev/null +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -0,0 +1,234 @@ +import Dependencies.JUNIT_KTX +import Dependencies.ROOM_COMPILER +import Dependencies.ROOM_KTX +import Dependencies.ROOM_RUNTIME +import Dependencies.ROOM_TEST +import org.gradle.api.artifacts.Dependency +import org.gradle.api.artifacts.dsl.DependencyHandler +import org.gradle.kotlin.dsl.project + +/** + * @author yaya (@yahyalmh) + * @since 28th October 2022 + */ + +object Dependencies { + const val ANDROIDX_CORE_KTX = "androidx.core:core-ktx:${Version.Androidx.CORE_KTX}" + const val ANDROID_LIFECYCLE_RUNTIME = + "androidx.lifecycle:lifecycle-runtime-ktx:${Version.Androidx.LIFECYCLE}" + + const val COMPOSE_BOM = "androidx.compose:compose-bom:${Version.Compose.BOM}" + const val ANDROID_ACTIVITY_COMPOSE = + "androidx.activity:activity-compose:${Version.Compose.ACTIVITY_COMPOSE}" + const val ANDROIDX_COMPOSE_PREVIEW = "androidx.compose.ui:ui-tooling-preview" + const val ANDROIDX_COMPOSE_UI_TOOLING = "androidx.compose.ui:ui-tooling" + const val ANDROIDX_COMPOSE_LIFECYCLE = + "androidx.lifecycle:lifecycle-runtime-compose:${Version.Androidx.LIFECYCLE}" + const val COMPOSE_UI = "androidx.compose.ui:ui" + const val COMPOSE_MATERIAL = "androidx.compose.material:material" + const val COMPOSE_MATERIAL3 = "androidx.compose.material3:material3" + const val COMPOSE_MATERIAL3_WINDOW_SIZE = + "androidx.compose.material3:material3-window-size-class" + const val COMPOSE_NAVIGATION = + "androidx.navigation:navigation-compose:${Version.Compose.NAVIGATION}" + const val COMPOSE_VIEW_MODEL = "androidx.lifecycle:lifecycle-viewmodel-compose" + const val COMPOSE_HILT_NAVIGATION = + "androidx.hilt:hilt-navigation-compose:${Version.Compose.HILT_NAVIGATION}" + const val COMPOSE_UI_TEST = "androidx.compose.ui:ui-test-junit4" + const val COMPOSE_UI_TEST_MANIFEST = "androidx.compose.ui:ui-test-manifest" + const val COMPOSE_CONSTRAINT_LAYOUT = + "androidx.constraintlayout:constraintlayout-compose:${Version.Compose.CONSTRAINTLAYOUT}" + + + const val HILT_ANDROID = "com.google.dagger:hilt-android:${Version.HILT}" + const val HILT_COMPILER = "com.google.dagger:hilt-compiler:${Version.HILT}" + const val HILT_TESTING = "com.google.dagger:hilt-android-testing:${Version.HILT}" + const val HILT_ANDROID_COMPILER = "com.google.dagger:hilt-android-compiler:${Version.HILT}" + + const val ANDROIDX_JUNIT = "androidx.test.ext:junit:${Version.Androidx.ANDROIDX_JUNIT}" + const val ANDROIDX_TEST_CORE = "androidx.test:core:${Version.Androidx.ANDROIDX_TEST}" + const val ANDROIDX_TEST_RUNNER = "androidx.test:runner:${Version.Androidx.ANDROIDX_TEST}" + + const val JUNIT = "junit:junit:${Version.Junit.JUNIT}" + const val JUNIT5_API = "org.junit.jupiter:junit-jupiter-api:${Version.Junit.JUNIT5}" + const val JUNIT5_ENGINE = "org.junit.jupiter:junit-jupiter-engine:${Version.Junit.JUNIT5}" + const val JUNIT5_PARAMS = "org.junit.jupiter:junit-jupiter-params:${Version.Junit.JUNIT5}" + const val JUNIT5_VINTAGE = "org.junit.vintage:junit-vintage-engine:${Version.Junit.VINTAGE}" + const val JUNIT_KTX = "androidx.test.ext:junit-ktx:${Version.Junit.KTX}" + + const val MOCKITO_CORE = "org.mockito:mockito-core:${Version.Mockito.CORE}" + const val MOCKITO_INLINE = "org.mockito:mockito-inline:${Version.Mockito.CORE}" + const val MOCKITO_JUNIT = "org.mockito:mockito-junit-jupiter:${Version.Mockito.CORE}" + const val MOCKITO_KOTLIN = "org.mockito.kotlin:mockito-kotlin:${Version.Mockito.KOTLIN}" + + const val ANDROIDX_ESPRESSO_CORE = + "androidx.test.espresso:espresso-core:${Version.ESPRESSO_CORE}" + + const val RETROFIT = "com.squareup.retrofit2:retrofit:${Version.Retrofit.RETROFIT}" + const val RETROFIT_GSON_CONVERTER = + "com.squareup.retrofit2:converter-gson:${Version.Retrofit.RETROFIT}" + const val GSON = "com.google.code.gson:gson:${Version.Retrofit.GSON}" + const val RETROFIT_COROUTINES_ADAPTER = + "com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:${Version.Retrofit.COROUTINES_ADAPTER}" + const val OKHTTP_LOGGING = + "com.squareup.okhttp3:logging-interceptor:${Version.Retrofit.OKHTTP_LOGGING}" + + const val COROUTINES_CORE = + "org.jetbrains.kotlinx:kotlinx-coroutines-core:${Version.COROUTINES}" + const val COROUTINES_ANDROID = + "org.jetbrains.kotlinx:kotlinx-coroutines-android:${Version.COROUTINES}" + const val COROUTINES_TEST = + "org.jetbrains.kotlinx:kotlinx-coroutines-test:${Version.COROUTINES}" + + const val KOTLIN_REFLECTION = "org.jetbrains.kotlin:kotlin-reflect:${Version.KOTLIN_REFLECTION}" + + const val DATA_STORE = "androidx.datastore:datastore-preferences:${Version.DATA_STORE}" + + const val ROOM_RUNTIME = "androidx.room:room-runtime:${Version.ROOM_VERSION}" + const val ROOM_COMPILER = "androidx.room:room-compiler:${Version.ROOM_VERSION}" + const val ROOM_TEST = "androidx.room:room-testing:${Version.ROOM_VERSION}" + const val ROOM_COROUTINE = "androidx.room:room-coroutines:${Version.ROOM_VERSION}" + const val ROOM_COMMON = "androidx.room:room-common:${Version.ROOM_VERSION}" + const val ROOM_KTX = "androidx.room:room-ktx:${Version.ROOM_VERSION}" + +} + +fun DependencyHandler.androidxCore() = implementation(Dependencies.ANDROIDX_CORE_KTX) +fun DependencyHandler.kotlinReflection() = runtimeOnly(Dependencies.KOTLIN_REFLECTION) + +fun DependencyHandler.compose() { + implementation(platform(Dependencies.COMPOSE_BOM)) + implementation(Dependencies.COMPOSE_HILT_NAVIGATION) + implementation(Dependencies.ANDROID_ACTIVITY_COMPOSE) + implementation(Dependencies.ANDROIDX_COMPOSE_PREVIEW) + implementation(Dependencies.ANDROIDX_COMPOSE_UI_TOOLING) + implementation(Dependencies.ANDROIDX_COMPOSE_LIFECYCLE) + implementation(Dependencies.COMPOSE_UI) +} + +fun DependencyHandler.room() { + implementation(ROOM_RUNTIME) +// implementation(ROOM_COROUTINE) + annotationProcessor(ROOM_COMPILER) + kapt(ROOM_COMPILER) + testImplementation(ROOM_TEST) + implementation(ROOM_KTX) +} + +fun DependencyHandler.composeMaterial() { + implementation(Dependencies.COMPOSE_MATERIAL) + implementation(Dependencies.COMPOSE_MATERIAL3) + implementation(Dependencies.COMPOSE_MATERIAL3_WINDOW_SIZE) +} + +fun DependencyHandler.composeTest() { + androidTestImplementation(platform(Dependencies.COMPOSE_BOM)) + androidTestImplementation(Dependencies.COMPOSE_UI_TEST) + implementation(Dependencies.COMPOSE_UI_TEST) + debugImplementation(Dependencies.COMPOSE_UI_TEST_MANIFEST) +} + +fun DependencyHandler.composeNavigation() { + implementation(Dependencies.COMPOSE_NAVIGATION) +} + +fun DependencyHandler.composeViewModel() { + implementation(Dependencies.COMPOSE_VIEW_MODEL) +} + +fun DependencyHandler.composeConstraintLayout() { + implementation(Dependencies.COMPOSE_CONSTRAINT_LAYOUT) +} + +fun DependencyHandler.coroutines() { + implementation(Dependencies.COROUTINES_CORE) + implementation(Dependencies.COROUTINES_ANDROID) + implementation(Dependencies.COROUTINES_TEST) +} + +fun DependencyHandler.retrofit() { + implementation(Dependencies.RETROFIT) + implementation(Dependencies.RETROFIT_GSON_CONVERTER) + implementation(Dependencies.GSON) + implementation(Dependencies.OKHTTP_LOGGING) + implementation(Dependencies.RETROFIT_COROUTINES_ADAPTER) +} + +fun DependencyHandler.hilt() { + implementation(Dependencies.HILT_ANDROID) + kapt(Dependencies.HILT_COMPILER) +} + +fun DependencyHandler.hiltTest() { + testImplementation(Dependencies.HILT_TESTING) + implementation(Dependencies.HILT_TESTING) + kaptTest(Dependencies.HILT_ANDROID_COMPILER) + androidTestImplementation(Dependencies.HILT_TESTING) + kaptAndroidTest(Dependencies.HILT_ANDROID_COMPILER) +} + +fun DependencyHandler.datastore() { + implementation(Dependencies.DATA_STORE) +} + +fun DependencyHandler.androidXTest() { + androidTestImplementation(Dependencies.ANDROIDX_JUNIT) + androidTestImplementation(Dependencies.ANDROIDX_TEST_CORE) + androidTestImplementation(Dependencies.ANDROIDX_TEST_RUNNER) + implementation(Dependencies.ANDROIDX_TEST_RUNNER) +} + +fun DependencyHandler.junit4() { + implementation(JUNIT_KTX) + testImplementation(Dependencies.JUNIT) +} + +fun DependencyHandler.junit5() { + testImplementation(Dependencies.JUNIT5_API) + implementation(Dependencies.JUNIT5_API) + testImplementation(Dependencies.JUNIT5_PARAMS) + testImplementation(Dependencies.JUNIT5_VINTAGE) + testRuntimeOnly(Dependencies.JUNIT5_ENGINE) +} + +fun DependencyHandler.mockito() { + testImplementation(Dependencies.MOCKITO_CORE) + implementation(Dependencies.MOCKITO_CORE) + testImplementation(Dependencies.MOCKITO_JUNIT) + testImplementation(Dependencies.MOCKITO_INLINE) + testImplementation(Dependencies.MOCKITO_KOTLIN) +} + +fun DependencyHandler.moduleDependency(path: String) { + implementation(project(path)) +} + +fun DependencyHandler.espresso() = androidTestImplementation(Dependencies.ANDROIDX_ESPRESSO_CORE) + +fun DependencyHandler.lifecycle() = implementation(Dependencies.ANDROID_LIFECYCLE_RUNTIME) + +private fun DependencyHandler.kapt(depName: String) = add("kapt", depName) +private fun DependencyHandler.kaptTest(depName: String) = add("kaptTest", depName) +private fun DependencyHandler.kaptAndroidTest(depName: String) = add("kaptAndroidTest", depName) + +fun DependencyHandler.implementation(depName: String) = add("implementation", depName) +fun DependencyHandler.implementation(dependency: Dependency) = add("implementation", dependency) + +fun DependencyHandler.androidTestImplementation(depName: String) = + add("androidTestImplementation", depName) + +fun DependencyHandler.androidTestImplementation(dependency: Dependency) = + add("androidTestImplementation", dependency) + +fun DependencyHandler.testImplementation(depName: String) = add("testImplementation", depName) +fun DependencyHandler.testRuntimeOnly(depName: String) = add("testRuntimeOnly", depName) + +fun DependencyHandler.debugImplementation(depName: String) = add("debugImplementation", depName) + +private fun DependencyHandler.compileOnly(depName: String) = add("compileOnly", depName) +private fun DependencyHandler.runtimeOnly(depName: String) = add("runtimeOnly", depName) + +private fun DependencyHandler.api(depName: String) = add("api", depName) + +private fun DependencyHandler.annotationProcessor(depName: String) = + add("annotationProcessor", depName) diff --git a/buildSrc/src/main/kotlin/Version.kt b/buildSrc/src/main/kotlin/Version.kt new file mode 100644 index 0000000..9a4d3df --- /dev/null +++ b/buildSrc/src/main/kotlin/Version.kt @@ -0,0 +1,58 @@ +/** + * @author yaya (@yahyalmh) + * @since 28th October 2022 + */ + +object Version { + object Mockito { + const val CORE = "4.10.0" + const val KOTLIN = "3.2.0" + } + + const val ROOM_VERSION = "2.4.3" + + const val ESPRESSO_CORE = "3.3.0" + + const val KOTLIN_COMPILER_EXTENSION_VERSION = "1.3.2" + + const val DATA_STORE = "1.0.0" + + const val HILT = "2.44" + + object Retrofit { + const val RETROFIT = "2.9.0" + const val OKHTTP_LOGGING = "5.0.0-alpha.8" + const val GSON = "2.10" + const val COROUTINES_ADAPTER = "0.9.2" + } + + const val KOTLIN_REFLECTION = "1.7.20" + + const val COROUTINES = "1.6.4" + + object Androidx { + const val CORE_KTX = "1.9.0" + const val LIFECYCLE = "2.6.0-alpha03" + const val ANDROIDX_TEST = "1.5.0" + const val ANDROIDX_JUNIT = "1.1.3" + } + + object Compose { + const val CONSTRAINTLAYOUT = "1.0.1" + const val BOM = "2022.10.00" + const val ACTIVITY_COMPOSE = "1.6.1" + const val UI_VERSION = "1.3.0" + const val MATERIAL = "1.3.0" + const val MATERIAL3 = "1.1.0-alpha01" + const val NAVIGATION = "2.6.0-alpha03" + const val HILT_NAVIGATION = "1.0.0" + const val VIEWMODEL = "2.6.0-alpha03" + } + + object Junit { + const val KTX= "1.1.4" + const val VINTAGE = "5.9.1" + const val JUNIT5 = "5.9.1" + const val JUNIT = "4.13.2" + } +} \ No newline at end of file diff --git a/data/common/.gitignore b/data/common/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/data/common/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/common/build.gradle.kts b/data/common/build.gradle.kts new file mode 100644 index 0000000..3112ea0 --- /dev/null +++ b/data/common/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + kotlin("kapt") + id("com.google.dagger.hilt.android") +} + +android { + namespace = "com.example.data.common" + compileSdk = AppConfig.compileSdk + + defaultConfig { + minSdk = AppConfig.minSdk + targetSdk = AppConfig.targetSdk + + testInstrumentationRunner = AppConfig.androidTestInstrumentation + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + retrofit() + hilt() + junit4() + room() +} \ No newline at end of file diff --git a/data/common/consumer-rules.pro b/data/common/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/data/common/proguard-rules.pro b/data/common/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/data/common/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/data/common/src/androidTest/java/com/example/common/ExampleInstrumentedTest.kt b/data/common/src/androidTest/java/com/example/common/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..45d3ec2 --- /dev/null +++ b/data/common/src/androidTest/java/com/example/common/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package com.example.common + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.data.common.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/data/common/src/main/AndroidManifest.xml b/data/common/src/main/AndroidManifest.xml new file mode 100644 index 0000000..44008a4 --- /dev/null +++ b/data/common/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/data/common/src/main/java/com/example/data/common/api/CoinCapRetrofit.kt b/data/common/src/main/java/com/example/data/common/api/CoinCapRetrofit.kt new file mode 100644 index 0000000..f696009 --- /dev/null +++ b/data/common/src/main/java/com/example/data/common/api/CoinCapRetrofit.kt @@ -0,0 +1,17 @@ +package com.example.data.common.api + +import retrofit2.Retrofit +import javax.inject.Inject + +/** + * @author yaya (@yahyalmh) + * @since 25th November 2022 + */ + +class CoinCapRetrofit @Inject constructor(builder: Retrofit.Builder) : RetrofitBuilder { + companion object { + private const val COINCAP_BASE_URL = "https://api.coincap.io/" + } + + override val retrofit: Retrofit = builder.baseUrl(COINCAP_BASE_URL).build() +} \ No newline at end of file diff --git a/data/common/src/main/java/com/example/data/common/api/RetrofitBuilder.kt b/data/common/src/main/java/com/example/data/common/api/RetrofitBuilder.kt new file mode 100644 index 0000000..d351abd --- /dev/null +++ b/data/common/src/main/java/com/example/data/common/api/RetrofitBuilder.kt @@ -0,0 +1,15 @@ +package com.example.data.common.api + +import retrofit2.Retrofit + +/** + * @author yaya (@yahyalmh) + * @since 25th November 2022 + */ + +interface RetrofitBuilder { + + val retrofit: Retrofit + + fun create(service: Class): T = retrofit.create(service) +} \ No newline at end of file diff --git a/data/common/src/main/java/com/example/data/common/api/di/NetworkModule.kt b/data/common/src/main/java/com/example/data/common/api/di/NetworkModule.kt new file mode 100644 index 0000000..2d18028 --- /dev/null +++ b/data/common/src/main/java/com/example/data/common/api/di/NetworkModule.kt @@ -0,0 +1,52 @@ +package com.example.data.common.api.di + +import com.example.data.common.BuildConfig +import com.example.data.common.api.CoinCapRetrofit +import com.example.data.common.api.RetrofitBuilder +import com.jakewharton.retrofit2.adapter.kotlin.coroutines.CoroutineCallAdapterFactory +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +/** + * @author yaya (@yahyalmh) + * @since 04th November 2022 + */ + +@Module +@InstallIn(SingletonComponent::class) +interface NetworkModule { + @Binds + fun bindCoincapRetrofit(coincapRetrofit: CoinCapRetrofit): RetrofitBuilder + + companion object { + @Provides + fun provideHttpClient(): OkHttpClient = if (BuildConfig.DEBUG) { + val logging = HttpLoggingInterceptor() + logging.setLevel(HttpLoggingInterceptor.Level.BODY) + OkHttpClient.Builder() + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .addInterceptor(logging) + .build() + } else OkHttpClient.Builder().build() + + @Provides + @Singleton + fun provideBaseRetrofit( + httpClient: OkHttpClient, + ): Retrofit.Builder = Retrofit + .Builder() + .client(httpClient) + .addConverterFactory(GsonConverterFactory.create()) + .addCallAdapterFactory(CoroutineCallAdapterFactory()) + } +} \ No newline at end of file diff --git a/data/common/src/main/java/com/example/data/common/database/AppDatabase.kt b/data/common/src/main/java/com/example/data/common/database/AppDatabase.kt new file mode 100644 index 0000000..baf3818 --- /dev/null +++ b/data/common/src/main/java/com/example/data/common/database/AppDatabase.kt @@ -0,0 +1,10 @@ +package com.example.data.common.database + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database(version = 1, entities = [ExchangeRateEntity::class]) +abstract class AppDatabase: RoomDatabase() { + + abstract fun exchangeRateDao(): ExchangeRateDao +} \ No newline at end of file diff --git a/data/common/src/main/java/com/example/data/common/database/DbConfig.kt b/data/common/src/main/java/com/example/data/common/database/DbConfig.kt new file mode 100644 index 0000000..91ce944 --- /dev/null +++ b/data/common/src/main/java/com/example/data/common/database/DbConfig.kt @@ -0,0 +1,6 @@ +package com.example.data.common.database + +object DbConfig { + const val DATABASE_NAME = "app_database" + const val FAVORITE_TABLE_NAME = "favorite_rates" +} \ No newline at end of file diff --git a/data/common/src/main/java/com/example/data/common/database/ExchangeRateDao.kt b/data/common/src/main/java/com/example/data/common/database/ExchangeRateDao.kt new file mode 100644 index 0000000..dc9e1a1 --- /dev/null +++ b/data/common/src/main/java/com/example/data/common/database/ExchangeRateDao.kt @@ -0,0 +1,24 @@ +package com.example.data.common.database + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy.REPLACE +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface ExchangeRateDao { + + @Query("SELECT * FROM favorite_rates order by symbol ASC") + fun getAll(): Flow> + + @Insert(onConflict = REPLACE) + suspend fun insertAll(rates: List) + + @Insert(onConflict = REPLACE) + suspend fun insert(rate: ExchangeRateEntity) + + @Delete + suspend fun delete(rate: ExchangeRateEntity) +} \ No newline at end of file diff --git a/data/common/src/main/java/com/example/data/common/database/ExchangeRateEntity.kt b/data/common/src/main/java/com/example/data/common/database/ExchangeRateEntity.kt new file mode 100644 index 0000000..9b36bb4 --- /dev/null +++ b/data/common/src/main/java/com/example/data/common/database/ExchangeRateEntity.kt @@ -0,0 +1,11 @@ +package com.example.data.common.database + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.example.data.common.database.DbConfig.FAVORITE_TABLE_NAME + +@Entity(tableName = FAVORITE_TABLE_NAME) +data class ExchangeRateEntity( + @PrimaryKey val id: String, + val symbol: String, +) \ No newline at end of file diff --git a/data/common/src/main/java/com/example/data/common/database/di/DatabaseModule.kt b/data/common/src/main/java/com/example/data/common/database/di/DatabaseModule.kt new file mode 100644 index 0000000..d8cffe9 --- /dev/null +++ b/data/common/src/main/java/com/example/data/common/database/di/DatabaseModule.kt @@ -0,0 +1,30 @@ +package com.example.data.common.database.di + +import android.content.Context +import androidx.room.Room +import com.example.data.common.database.AppDatabase +import com.example.data.common.database.DbConfig.DATABASE_NAME +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class DatabaseModule { + + @Provides + @Singleton + fun provideExchangeRateDao(database: AppDatabase) = database.exchangeRateDao() + + @Provides + @Singleton + fun provideAppDatabase(@ApplicationContext context: Context) = Room.databaseBuilder( + context, + AppDatabase::class.java, + DATABASE_NAME + ).build() + +} diff --git a/data/common/src/main/java/com/example/data/common/ext/StringExt.kt b/data/common/src/main/java/com/example/data/common/ext/StringExt.kt new file mode 100644 index 0000000..d79425a --- /dev/null +++ b/data/common/src/main/java/com/example/data/common/ext/StringExt.kt @@ -0,0 +1,13 @@ +package com.example.data.common.ext + +object RandomString { + fun next(length: Int = 10, withNumbers: Boolean = true): String { + val charsSet = (('A'..'Z') + ('a'..'z')).toMutableList() + if (withNumbers) { + charsSet += ('0'..'9') + } + return (1..length) + .map { charsSet.random() } + .joinToString("") + } +} diff --git a/data/common/src/main/java/com/example/data/common/model/ExchangeDetailRate.kt b/data/common/src/main/java/com/example/data/common/model/ExchangeDetailRate.kt new file mode 100644 index 0000000..0ddce9a --- /dev/null +++ b/data/common/src/main/java/com/example/data/common/model/ExchangeDetailRate.kt @@ -0,0 +1,20 @@ +package com.example.data.common.model + +import java.math.BigDecimal + +data class ExchangeDetailRate( + val id: String, + val symbol: String, + val currencySymbol: String?, + val type: String, + val rateUsd: BigDecimal, + val timestamp: Long +) + +fun ExchangeDetailRate.toExchangeRate() = ExchangeRate( + id = id, + symbol = symbol, + currencySymbol = currencySymbol, + type = type, + rateUsd = rateUsd +) \ No newline at end of file diff --git a/data/common/src/main/java/com/example/data/common/model/ExchangeRate.kt b/data/common/src/main/java/com/example/data/common/model/ExchangeRate.kt new file mode 100644 index 0000000..b695b44 --- /dev/null +++ b/data/common/src/main/java/com/example/data/common/model/ExchangeRate.kt @@ -0,0 +1,22 @@ +package com.example.data.common.model + +import com.example.data.common.database.ExchangeRateEntity +import java.math.BigDecimal + +/** + * @author yaya (@yahyalmh) + * @since 02th November 2022 + */ + +data class ExchangeRate( + val id: String, + val symbol: String, + val currencySymbol: String?, + val type: String, + val rateUsd: BigDecimal +) + +fun ExchangeRate.toEntity() = ExchangeRateEntity( + id = id, + symbol = symbol +) diff --git a/data/common/src/test/java/com/example/common/ExampleUnitTest.kt b/data/common/src/test/java/com/example/common/ExampleUnitTest.kt new file mode 100644 index 0000000..96cdc7b --- /dev/null +++ b/data/common/src/test/java/com/example/common/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package com.example.common + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/data/datastore/.gitignore b/data/datastore/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/data/datastore/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/datastore/build.gradle.kts b/data/datastore/build.gradle.kts new file mode 100644 index 0000000..1a36aa5 --- /dev/null +++ b/data/datastore/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + kotlin("kapt") + id("com.google.dagger.hilt.android") +} +android { + namespace = "com.example.datastore" + compileSdk = AppConfig.compileSdk + + defaultConfig { + minSdk = AppConfig.minSdk + targetSdk = AppConfig.targetSdk + + testInstrumentationRunner = AppConfig.androidTestInstrumentation + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + coroutines() + hilt() + junit4() + datastore() +} \ No newline at end of file diff --git a/data/datastore/consumer-rules.pro b/data/datastore/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/data/datastore/proguard-rules.pro b/data/datastore/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/data/datastore/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/data/datastore/src/androidTest/java/com/example/datastore/ExampleInstrumentedTest.kt b/data/datastore/src/androidTest/java/com/example/datastore/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..202cb87 --- /dev/null +++ b/data/datastore/src/androidTest/java/com/example/datastore/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.datastore + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.datastore.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/data/datastore/src/main/AndroidManifest.xml b/data/datastore/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/data/datastore/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/data/datastore/src/main/java/com/example/datastore/DatastoreInteractor.kt b/data/datastore/src/main/java/com/example/datastore/DatastoreInteractor.kt new file mode 100644 index 0000000..efbb5f7 --- /dev/null +++ b/data/datastore/src/main/java/com/example/datastore/DatastoreInteractor.kt @@ -0,0 +1,22 @@ +package com.example.datastore + +import androidx.datastore.preferences.core.Preferences +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +interface DatastoreInteractor { + fun getThemeType(): Flow + suspend fun setThemeType(themeType: String): Preferences +} + +class DatastoreInteractorImpl @Inject constructor( + private val repository: DatastoreRepository +) : + DatastoreInteractor { + + override fun getThemeType() = repository.getValue(PreferencesKeys.THEME_TYPE_KEY) + + override suspend fun setThemeType(themeType: String) = + repository.setValue(PreferencesKeys.THEME_TYPE_KEY, themeType) + +} \ No newline at end of file diff --git a/data/datastore/src/main/java/com/example/datastore/DatastoreRepository.kt b/data/datastore/src/main/java/com/example/datastore/DatastoreRepository.kt new file mode 100644 index 0000000..2ddbe7e --- /dev/null +++ b/data/datastore/src/main/java/com/example/datastore/DatastoreRepository.kt @@ -0,0 +1,24 @@ +package com.example.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +interface DatastoreRepository { + fun getValue(key: Preferences.Key): Flow + suspend fun setValue(key: Preferences.Key, value: T): Preferences +} + +class DatastoreRepositoryImpl @Inject constructor( + private val dataStore: DataStore +) : DatastoreRepository { + + override fun getValue(key: Preferences.Key): Flow = dataStore.data + .map { preferences -> preferences[key] } + + override suspend fun setValue(key: Preferences.Key, value: T) = + dataStore.edit { preferences -> preferences[key] = value } +} \ No newline at end of file diff --git a/data/datastore/src/main/java/com/example/datastore/PreferencesKeys.kt b/data/datastore/src/main/java/com/example/datastore/PreferencesKeys.kt new file mode 100644 index 0000000..6513a33 --- /dev/null +++ b/data/datastore/src/main/java/com/example/datastore/PreferencesKeys.kt @@ -0,0 +1,10 @@ +package com.example.datastore + +import androidx.datastore.preferences.core.stringPreferencesKey + +object PreferencesKeys { + + const val PREFERENCES_FILE_NAME = "app_preferences_file" + val THEME_TYPE_KEY = stringPreferencesKey("theme_type") + +} \ No newline at end of file diff --git a/data/datastore/src/main/java/com/example/datastore/di/DataStoreModule.kt b/data/datastore/src/main/java/com/example/datastore/di/DataStoreModule.kt new file mode 100644 index 0000000..e9612b6 --- /dev/null +++ b/data/datastore/src/main/java/com/example/datastore/di/DataStoreModule.kt @@ -0,0 +1,51 @@ +package com.example.datastore.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.preferences.SharedPreferencesMigration +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.preferencesDataStoreFile +import com.example.datastore.* +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface DataStoreModule { + + companion object { + @Provides + @Singleton + fun provideDatastore( + @ApplicationContext appContext: Context, + ): DataStore { + val fileName: String = PreferencesKeys.PREFERENCES_FILE_NAME + + return PreferenceDataStoreFactory.create( + corruptionHandler = ReplaceFileCorruptionHandler( + produceNewData = { emptyPreferences() } + ), + migrations = listOf(SharedPreferencesMigration(appContext, fileName)), + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + produceFile = { appContext.preferencesDataStoreFile(fileName) } + ) + } + } + + @Binds + fun bindDatastoreRepository(repository: DatastoreRepositoryImpl): DatastoreRepository + + @Binds + fun bindDatastoreInteractor(interactor: DatastoreInteractorImpl): DatastoreInteractor +} \ No newline at end of file diff --git a/data/datastore/src/test/java/com/example/datastore/ExampleUnitTest.kt b/data/datastore/src/test/java/com/example/datastore/ExampleUnitTest.kt new file mode 100644 index 0000000..0de8cb7 --- /dev/null +++ b/data/datastore/src/test/java/com/example/datastore/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.example.datastore + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/data/exchangerate/.gitignore b/data/exchangerate/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/data/exchangerate/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/exchangerate/build.gradle.kts b/data/exchangerate/build.gradle.kts new file mode 100644 index 0000000..089d34a --- /dev/null +++ b/data/exchangerate/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + kotlin("kapt") + id("com.google.dagger.hilt.android") + id("de.mannodermaus.android-junit5") +} + +android { + namespace = "com.example.data.rate" + compileSdk = AppConfig.compileSdk + + defaultConfig { + minSdk = AppConfig.minSdk + targetSdk = AppConfig.targetSdk + + testInstrumentationRunner = AppConfig.androidTestInstrumentation + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + retrofit() + coroutines() + hilt() + junit5() + androidXTest() + espresso() + mockito() + moduleDependency(":ui:common") + moduleDependency(":data:common") +} \ No newline at end of file diff --git a/data/exchangerate/consumer-rules.pro b/data/exchangerate/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/data/exchangerate/proguard-rules.pro b/data/exchangerate/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/data/exchangerate/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/data/exchangerate/src/androidTest/java/com/example/rate/ExampleInstrumentedTest.kt b/data/exchangerate/src/androidTest/java/com/example/rate/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..22678b6 --- /dev/null +++ b/data/exchangerate/src/androidTest/java/com/example/rate/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package com.example.rate + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.data.rate.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/data/exchangerate/src/main/AndroidManifest.xml b/data/exchangerate/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/data/exchangerate/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/data/exchangerate/src/main/java/com/example/rate/ExchangeRateInteractor.kt b/data/exchangerate/src/main/java/com/example/rate/ExchangeRateInteractor.kt new file mode 100644 index 0000000..a587dc0 --- /dev/null +++ b/data/exchangerate/src/main/java/com/example/rate/ExchangeRateInteractor.kt @@ -0,0 +1,40 @@ +package com.example.rate + +import com.example.ui.common.ext.repeatFlow +import com.example.data.common.model.ExchangeDetailRate +import com.example.data.common.model.ExchangeRate +import com.example.rate.model.toExternalModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import javax.inject.Inject + +/** + * @author yaya (@yahyalmh) + * @since 04th November 2022 + */ + +interface ExchangeRateInteractor { + fun getRates(): Flow> + fun getLiveRates(interval: Long): Flow> + fun getLiveRate(id: String, interval: Long = 3000L): Flow +} + +class ExchangeRateInteractorImpl @Inject constructor(private val exchangeRateRepository: ExchangeRateRepository) : + ExchangeRateInteractor { + + override fun getRates() = flow { emit(exchangeRates()) }.flowOn(Dispatchers.IO) + + override fun getLiveRates(interval: Long) = + repeatFlow(interval) { exchangeRates() }.flowOn(Dispatchers.IO) + + override fun getLiveRate(id: String, interval: Long): Flow = + repeatFlow(interval) { + exchangeRateRepository.getExchangeRate(id).toExternalModel() + }.flowOn(Dispatchers.IO) + + private suspend fun exchangeRates() = exchangeRateRepository.getExchangeRates() + .toExternalModel() + .sortedByDescending { it.symbol } +} \ No newline at end of file diff --git a/data/exchangerate/src/main/java/com/example/rate/ExchangeRateRepository.kt b/data/exchangerate/src/main/java/com/example/rate/ExchangeRateRepository.kt new file mode 100644 index 0000000..c15e72c --- /dev/null +++ b/data/exchangerate/src/main/java/com/example/rate/ExchangeRateRepository.kt @@ -0,0 +1,27 @@ +package com.example.rate + +import com.example.rate.api.ExchangeRateApi +import com.example.rate.model.ExchangeRateDetailModel +import com.example.rate.model.ExchangeRatesModel +import javax.inject.Inject + +/** + * @author yaya (@yahyalmh) + * @since 04th November 2022 + */ + +interface ExchangeRateRepository { + suspend fun getExchangeRates(): ExchangeRatesModel + suspend fun getExchangeRate(id: String): ExchangeRateDetailModel +} + +class ExchangeRateRepositoryImpl @Inject constructor(private val exchangeRateApi: ExchangeRateApi) : + ExchangeRateRepository { + + override suspend fun getExchangeRates(): ExchangeRatesModel = + exchangeRateApi.getExchangeRates() + + override suspend fun getExchangeRate(id: String): ExchangeRateDetailModel { + return exchangeRateApi.getExchangeRate(id) + } +} \ No newline at end of file diff --git a/data/exchangerate/src/main/java/com/example/rate/api/ExchangeRateApi.kt b/data/exchangerate/src/main/java/com/example/rate/api/ExchangeRateApi.kt new file mode 100644 index 0000000..6ff321c --- /dev/null +++ b/data/exchangerate/src/main/java/com/example/rate/api/ExchangeRateApi.kt @@ -0,0 +1,20 @@ +package com.example.rate.api + +import com.example.rate.model.ExchangeRateDetailModel +import com.example.rate.model.ExchangeRatesModel +import retrofit2.http.GET +import retrofit2.http.Path + +/** + * @author yaya (@yahyalmh) + * @since 02th November 2022 + */ + +interface ExchangeRateApi { + + @GET("v2/rates") + suspend fun getExchangeRates(): ExchangeRatesModel + + @GET("v2/rates/{id}") + suspend fun getExchangeRate(@Path("id") id: String): ExchangeRateDetailModel +} \ No newline at end of file diff --git a/data/exchangerate/src/main/java/com/example/rate/di/ExchangeRateModule.kt b/data/exchangerate/src/main/java/com/example/rate/di/ExchangeRateModule.kt new file mode 100644 index 0000000..850351b --- /dev/null +++ b/data/exchangerate/src/main/java/com/example/rate/di/ExchangeRateModule.kt @@ -0,0 +1,37 @@ +package com.example.rate.di + +import com.example.data.common.api.CoinCapRetrofit +import com.example.rate.api.ExchangeRateApi +import com.example.rate.ExchangeRateInteractor +import com.example.rate.ExchangeRateInteractorImpl +import com.example.rate.ExchangeRateRepository +import com.example.rate.ExchangeRateRepositoryImpl +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * @author yaya (@yahyalmh) + * @since 02th November 2022 + */ +@Module +@InstallIn(SingletonComponent::class) +interface ExchangeRateModule { + + companion object { + @Provides + @Singleton + fun provideExchangeRateApi(coinCapRetrofit: CoinCapRetrofit): ExchangeRateApi = + coinCapRetrofit.create(ExchangeRateApi::class.java) + } + + @Binds + @Singleton + fun bindExchangeRateRepository(repository: ExchangeRateRepositoryImpl): ExchangeRateRepository + + @Binds + fun bindExchangeInteractor(exchangeRateInteractor: ExchangeRateInteractorImpl): ExchangeRateInteractor +} \ No newline at end of file diff --git a/data/exchangerate/src/main/java/com/example/rate/model/ExchangeRateDetailModel.kt b/data/exchangerate/src/main/java/com/example/rate/model/ExchangeRateDetailModel.kt new file mode 100644 index 0000000..d3db937 --- /dev/null +++ b/data/exchangerate/src/main/java/com/example/rate/model/ExchangeRateDetailModel.kt @@ -0,0 +1,20 @@ +package com.example.rate.model + +import com.example.data.common.model.ExchangeDetailRate +import com.google.gson.annotations.SerializedName +import java.math.BigDecimal + +data class ExchangeRateDetailModel( + @SerializedName("data") + val rateDetail: ExchangeRateModel, + val timestamp: String +) + +fun ExchangeRateDetailModel.toExternalModel() = ExchangeDetailRate( + id = rateDetail.id, + symbol = rateDetail.symbol, + currencySymbol = rateDetail.currencySymbol, + type = rateDetail.type, + rateUsd = BigDecimal(rateDetail.rateUsd), + timestamp = timestamp.toLong() +) \ No newline at end of file diff --git a/data/exchangerate/src/main/java/com/example/rate/model/ExchangeRateModel.kt b/data/exchangerate/src/main/java/com/example/rate/model/ExchangeRateModel.kt new file mode 100644 index 0000000..6fab8f1 --- /dev/null +++ b/data/exchangerate/src/main/java/com/example/rate/model/ExchangeRateModel.kt @@ -0,0 +1,25 @@ +package com.example.rate.model + +import com.example.data.common.model.ExchangeRate +import java.math.BigDecimal + +/** + * @author yaya (@yahyalmh) + * @since 02th November 2022 + */ + +data class ExchangeRateModel( + val id: String, + val symbol: String, + val currencySymbol: String?, + val type: String, + val rateUsd: String +) + +fun ExchangeRateModel.toExternalModel() = ExchangeRate( + id = id, + symbol = symbol, + currencySymbol = currencySymbol, + type = type, + rateUsd = BigDecimal(rateUsd) +) \ No newline at end of file diff --git a/data/exchangerate/src/main/java/com/example/rate/model/ExchangeRatesModel.kt b/data/exchangerate/src/main/java/com/example/rate/model/ExchangeRatesModel.kt new file mode 100644 index 0000000..8033f34 --- /dev/null +++ b/data/exchangerate/src/main/java/com/example/rate/model/ExchangeRatesModel.kt @@ -0,0 +1,12 @@ +package com.example.rate.model + +import com.example.data.common.model.ExchangeRate +import com.google.gson.annotations.SerializedName + +data class ExchangeRatesModel( + @SerializedName("data") + val rates: List +) + +fun ExchangeRatesModel.toExternalModel(): List = + this.rates.map { it.toExternalModel() } diff --git a/data/exchangerate/src/test/java/com/example/rate/ExchangeRateInteractorTest.kt b/data/exchangerate/src/test/java/com/example/rate/ExchangeRateInteractorTest.kt new file mode 100644 index 0000000..ffaf011 --- /dev/null +++ b/data/exchangerate/src/test/java/com/example/rate/ExchangeRateInteractorTest.kt @@ -0,0 +1,66 @@ +package com.example.rate + +import com.example.rate.model.toExternalModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.whenever + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(MockitoExtension::class) +internal class ExchangeRateInteractorTest { + + @Mock + lateinit var rateRepository: ExchangeRateRepositoryImpl + private lateinit var exchangeRateInteractor: ExchangeRateInteractorImpl + + @BeforeEach + fun setUp() { + exchangeRateInteractor = ExchangeRateInteractorImpl(rateRepository) + } + + @Test + fun `WHEN fetch rates from interactor THEN return rates`() = runTest { + val exchangeRatesModel = exchangeRatesModelStub() + whenever(rateRepository.getExchangeRates()).thenReturn(exchangeRatesModel) + + val actual = exchangeRateInteractor.getRates().first() + val expected = exchangeRatesModel.toExternalModel().sortedByDescending { it.symbol } + + Assertions.assertEquals(expected, actual) + } + + + @Test + fun `WHEN fetch live rates THEN return rates periodically`() = runTest { + val exchangeRatesModel = exchangeRatesModelStub() + whenever(rateRepository.getExchangeRates()).thenReturn(exchangeRatesModel) + + val actual = exchangeRateInteractor.getLiveRates(3000).take(3).toList() + val expected = exchangeRatesModel.toExternalModel().sortedByDescending { it.symbol } + + actual.forEach { Assertions.assertEquals(expected, it) } + } + + @Test + fun `WHEN fetch a live rate with id THEN return data based on interval`() = runTest { + val exchangeRateDetailModel = exchangeRateDetailModelStub() + val exchangeRateId = exchangeRateDetailModel.rateDetail.id + whenever(rateRepository.getExchangeRate(exchangeRateId)).thenReturn( + exchangeRateDetailModel + ) + + val actual = exchangeRateInteractor.getLiveRate(exchangeRateId).take(3).toList() + val expected = exchangeRateDetailModel.toExternalModel() + + actual.forEach { Assertions.assertEquals(expected, it) } + } +} \ No newline at end of file diff --git a/data/exchangerate/src/test/java/com/example/rate/ExchangeRateRepositoryTest.kt b/data/exchangerate/src/test/java/com/example/rate/ExchangeRateRepositoryTest.kt new file mode 100644 index 0000000..764e31e --- /dev/null +++ b/data/exchangerate/src/test/java/com/example/rate/ExchangeRateRepositoryTest.kt @@ -0,0 +1,61 @@ +package com.example.rate + +import com.example.rate.api.ExchangeRateApi +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.whenever + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(MockitoExtension::class) +class ExchangeRateRepositoryTest { + + @Mock + lateinit var exchangeRateApi: ExchangeRateApi + lateinit var exchangeRateRepository: ExchangeRateRepositoryImpl + + @BeforeEach + fun setUp() { + MockitoAnnotations.openMocks(this) + exchangeRateRepository = ExchangeRateRepositoryImpl(exchangeRateApi) + } + + @Test + fun `WHEN exchange rates fetched from repository THEN work well`() = runTest { + // given + val exchangeRatesModelStub = exchangeRatesModelStub() + whenever(exchangeRateApi.getExchangeRates()).thenReturn(exchangeRatesModelStub) + + // when + val result = exchangeRateRepository.getExchangeRates() + + // then + Assertions.assertEquals(exchangeRatesModelStub, result) + } + + @Test + fun `WHEN an exchange rate fetched from repository THEN work well`() = runTest { + // given + val exchangeRateDetailModel = exchangeRateDetailModelStub() + val exchangeRateId = exchangeRateDetailModel.rateDetail.id + whenever( + exchangeRateApi + .getExchangeRate(exchangeRateId) + ).thenReturn( + exchangeRateDetailModel + ) + + // when + val result = exchangeRateRepository.getExchangeRate(exchangeRateId) + + // then + Assertions.assertEquals(exchangeRateDetailModel, result) + } + +} \ No newline at end of file diff --git a/data/exchangerate/src/test/java/com/example/rate/Stubs.kt b/data/exchangerate/src/test/java/com/example/rate/Stubs.kt new file mode 100644 index 0000000..2df799d --- /dev/null +++ b/data/exchangerate/src/test/java/com/example/rate/Stubs.kt @@ -0,0 +1,30 @@ +package com.example.rate + +import com.example.data.common.ext.RandomString +import com.example.rate.model.ExchangeRateDetailModel +import com.example.rate.model.ExchangeRateModel +import com.example.rate.model.ExchangeRatesModel +import kotlin.random.Random + +fun exchangeRatesModelStub() = ExchangeRatesModel(exchangeRatesStub()) + +fun exchangeRatesStub(count: Int = 10): List { + val result = mutableListOf() + repeat(count) { + result.add(exchangeRateModelStub()) + } + return result +} + +fun exchangeRateModelStub() = ExchangeRateModel( + id = Random.nextInt().toString(), + symbol = RandomString.next(), + currencySymbol = RandomString.next(), + type = RandomString.next(), + rateUsd = Random.nextFloat().toString() +) + +fun exchangeRateDetailModelStub() = ExchangeRateDetailModel( + rateDetail = exchangeRateModelStub(), + timestamp = Random.nextLong().toString() +) \ No newline at end of file diff --git a/data/favorite/.gitignore b/data/favorite/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/data/favorite/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/favorite/build.gradle.kts b/data/favorite/build.gradle.kts new file mode 100644 index 0000000..42dbae5 --- /dev/null +++ b/data/favorite/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + kotlin("kapt") + id("com.google.dagger.hilt.android") +} + +android { + namespace = "com.example.data.favorite" + compileSdk = AppConfig.compileSdk + + defaultConfig { + minSdk = AppConfig.minSdk + targetSdk = AppConfig.targetSdk + + testInstrumentationRunner = AppConfig.androidTestInstrumentation + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + coroutines() + hilt() + junit4() + moduleDependency(":data:exchangerate") + moduleDependency(":data:common") +} \ No newline at end of file diff --git a/data/favorite/consumer-rules.pro b/data/favorite/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/data/favorite/proguard-rules.pro b/data/favorite/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/data/favorite/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/data/favorite/src/androidTest/java/com/example/favorite/ExampleInstrumentedTest.kt b/data/favorite/src/androidTest/java/com/example/favorite/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..0ccd38b --- /dev/null +++ b/data/favorite/src/androidTest/java/com/example/favorite/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.favorite + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.favorite.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/data/favorite/src/main/AndroidManifest.xml b/data/favorite/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/data/favorite/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/data/favorite/src/main/java/com/example/favorite/FavoriteRatesInteractor.kt b/data/favorite/src/main/java/com/example/favorite/FavoriteRatesInteractor.kt new file mode 100644 index 0000000..31ad180 --- /dev/null +++ b/data/favorite/src/main/java/com/example/favorite/FavoriteRatesInteractor.kt @@ -0,0 +1,53 @@ +package com.example.favorite + +import com.example.data.common.database.ExchangeRateEntity +import com.example.data.common.model.ExchangeRate +import com.example.rate.ExchangeRateInteractor +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import javax.inject.Inject + +interface FavoriteRatesInteractor { + fun getLiveFavoriteRates(interval: Long = 4000L): Flow> + fun getFavoriteRates(): Flow> + fun getFavoriteRatesEntities(): Flow> + suspend fun addFavorite(rate: ExchangeRate) + suspend fun removeFavorite(rate: ExchangeRate) +} + +class FavoriteRatesInteractorImpl @Inject constructor( + private val favoriteRepository: FavoriteRepository, + private val rateInteractor: ExchangeRateInteractor, +) : FavoriteRatesInteractor { + + override fun getLiveFavoriteRates(interval: Long): Flow> { + return combine( + rateInteractor.getLiveRates(interval), + favoriteRepository.getFavoriteRates() + ) { rates, favoriteRates -> + rates.filter { rate -> + favoriteRates.any { it.id == rate.id && it.symbol == rate.symbol } + } + } + } + + + override fun getFavoriteRates(): Flow> { + return combine( + rateInteractor.getRates(), + favoriteRepository.getFavoriteRates() + ) { rates, favoriteRates -> + rates.filter { rate -> + favoriteRates.any { it.id == rate.id && it.symbol == rate.symbol } + } + } + } + + override fun getFavoriteRatesEntities(): Flow> = + favoriteRepository.getFavoriteRates() + + override suspend fun addFavorite(rate: ExchangeRate) = favoriteRepository.addFavorite(rate) + + override suspend fun removeFavorite(rate: ExchangeRate) = + favoriteRepository.removeFavorite(rate) +} \ No newline at end of file diff --git a/data/favorite/src/main/java/com/example/favorite/FavoriteRepository.kt b/data/favorite/src/main/java/com/example/favorite/FavoriteRepository.kt new file mode 100644 index 0000000..96c07dd --- /dev/null +++ b/data/favorite/src/main/java/com/example/favorite/FavoriteRepository.kt @@ -0,0 +1,29 @@ +package com.example.favorite; + +import com.example.data.common.database.ExchangeRateDao +import com.example.data.common.database.ExchangeRateEntity +import com.example.data.common.model.ExchangeRate +import com.example.data.common.model.toEntity +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +interface FavoriteRepository { + suspend fun addFavorite(rate: ExchangeRate) + suspend fun removeFavorite(rate: ExchangeRate) + fun getFavoriteRates(): Flow> +} + +class FavoriteRepositoryImpl @Inject constructor( + private val exchangeRateDao: ExchangeRateDao +) : FavoriteRepository { + + override fun getFavoriteRates(): Flow> { + return exchangeRateDao.getAll() + } + + override suspend fun addFavorite(rate: ExchangeRate) = exchangeRateDao.insert(rate.toEntity()) + + override suspend fun removeFavorite(rate: ExchangeRate) = + exchangeRateDao.delete(rate.toEntity()) + +} diff --git a/data/favorite/src/main/java/com/example/favorite/di/FavoriteModule.kt b/data/favorite/src/main/java/com/example/favorite/di/FavoriteModule.kt new file mode 100644 index 0000000..6840b6e --- /dev/null +++ b/data/favorite/src/main/java/com/example/favorite/di/FavoriteModule.kt @@ -0,0 +1,21 @@ +package com.example.favorite.di + +import com.example.favorite.FavoriteRatesInteractor +import com.example.favorite.FavoriteRatesInteractorImpl +import com.example.favorite.FavoriteRepository +import com.example.favorite.FavoriteRepositoryImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface FavoriteModule { + + @Binds + fun bindFavoriteRatesRepository(repository: FavoriteRepositoryImpl): FavoriteRepository + + @Binds + fun bindFavoriteRatesInteractor(favoriteRatesInteractor: FavoriteRatesInteractorImpl): FavoriteRatesInteractor +} \ No newline at end of file diff --git a/data/favorite/src/test/java/com/example/favorite/ExampleUnitTest.kt b/data/favorite/src/test/java/com/example/favorite/ExampleUnitTest.kt new file mode 100644 index 0000000..e5f6c3a --- /dev/null +++ b/data/favorite/src/test/java/com/example/favorite/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.example.favorite + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..3c5031e --- /dev/null +++ b/gradle.properties @@ -0,0 +1,23 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..7c2367c --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Oct 28 17:11:42 CEST 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..ac1b06f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..86d0459 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,27 @@ +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "ComposeApp" +include(":app") +include(":data:exchangerate") +include(":ui:home") +include(":ui:detail") +include(":data:common") +include(":ui:search") +include(":ui:common") +include(":data:datastore") +include(":ui:setting") +include(":ui:favorite") +include(":data:favorite") +include(":ui:main") diff --git a/ui/common/.gitignore b/ui/common/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/ui/common/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/ui/common/build.gradle.kts b/ui/common/build.gradle.kts new file mode 100644 index 0000000..a8052da --- /dev/null +++ b/ui/common/build.gradle.kts @@ -0,0 +1,55 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + kotlin("kapt") + id("de.mannodermaus.android-junit5") + id("com.google.dagger.hilt.android") +} +android { + namespace = "com.example.ui.common" + compileSdk = AppConfig.compileSdk + + defaultConfig { + minSdk = AppConfig.minSdk + targetSdk = AppConfig.targetSdk + + testInstrumentationRunner = AppConfig.androidTestInstrumentation + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = Version.KOTLIN_COMPILER_EXTENSION_VERSION + } +} + +dependencies { + junit4() + junit5() + mockito() + compose() + hiltTest() + hilt() + coroutines() + composeTest() + androidXTest() + composeMaterial() + moduleDependency(":data:common") +} \ No newline at end of file diff --git a/ui/common/consumer-rules.pro b/ui/common/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/ui/common/proguard-rules.pro b/ui/common/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/ui/common/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/ui/common/src/androidTest/java/com/example/common/ExampleInstrumentedTest.kt b/ui/common/src/androidTest/java/com/example/common/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..0886bf6 --- /dev/null +++ b/ui/common/src/androidTest/java/com/example/common/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.common + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.common.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/ui/common/src/main/AndroidManifest.xml b/ui/common/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8c4c982 --- /dev/null +++ b/ui/common/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/ui/common/src/main/java/com/example/ui/common/Annotation.kt b/ui/common/src/main/java/com/example/ui/common/Annotation.kt new file mode 100644 index 0000000..f93e697 --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/Annotation.kt @@ -0,0 +1,16 @@ +package com.example.ui.common + +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview + +/** + * @author yaya (@yahyalmh) + * @since 12th November 2022 + */ + +@Preview(showSystemUi = true, name = "phone", device = Devices.PHONE) +@Preview(showSystemUi = true, name = "foldable", device = Devices.FOLDABLE) +@Preview(showSystemUi = true,name = "custom", device = "spec:width=1280dp, height=800dp,dpi=480") +@Preview(showSystemUi = true,name = "tablet", device = Devices.TABLET) +@Preview(showSystemUi = true,name = "desktop", device = "id:desktop_medium") +annotation class ReferenceDevices \ No newline at end of file diff --git a/ui/common/src/main/java/com/example/ui/common/BaseViewModel.kt b/ui/common/src/main/java/com/example/ui/common/BaseViewModel.kt new file mode 100644 index 0000000..a693aef --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/BaseViewModel.kt @@ -0,0 +1,26 @@ +package com.example.ui.common + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel + +/** + * @author yaya (@yahyalmh) + * @since 29th September 2022 + */ + +abstract class BaseViewModel(initialState: T) : ViewModel() { + private var internalSate: MutableState = mutableStateOf(initialState) + var state: State = internalSate + + abstract fun onEvent(event: E) + + protected fun setState(state: T) { + internalSate.value = state + } +} + +interface UIState + +interface UIEvent diff --git a/ui/common/src/main/java/com/example/ui/common/SharedState.kt b/ui/common/src/main/java/com/example/ui/common/SharedState.kt new file mode 100644 index 0000000..ea302e1 --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/SharedState.kt @@ -0,0 +1,8 @@ +package com.example.ui.common + +import kotlinx.coroutines.flow.MutableSharedFlow + +object SharedState { + val bottomBarVisible = MutableSharedFlow() + +} \ No newline at end of file diff --git a/ui/common/src/main/java/com/example/ui/common/ThemeType.kt b/ui/common/src/main/java/com/example/ui/common/ThemeType.kt new file mode 100644 index 0000000..cf53fc6 --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/ThemeType.kt @@ -0,0 +1,12 @@ +package com.example.ui.common + +enum class ThemeType { + LIGHT, DARK, SYSTEM +} + +fun String.toThemeType(): ThemeType { + val themeType = ThemeType.values().firstOrNull { this == it.name } + return themeType ?: throw UnknownThemeType("$this is unknown theme type") +} + +class UnknownThemeType(override val message: String): Exception() \ No newline at end of file diff --git a/ui/common/src/main/java/com/example/ui/common/component/BaseColumn.kt b/ui/common/src/main/java/com/example/ui/common/component/BaseColumn.kt new file mode 100644 index 0000000..15a1009 --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/component/BaseColumn.kt @@ -0,0 +1,67 @@ +package com.example.ui.common.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun BaseLazyColumn( + modifier: Modifier = Modifier, + isVisible: Boolean = false, + lazyListState: LazyListState = rememberLazyListState(), + models: List<@Composable () -> Unit>, + stickyHeader: @Composable (() -> Unit)? = null, +) { + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(), + exit = fadeOut() + ) { + LazyColumn( + modifier = modifier, + state = lazyListState, + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally + ) { + stickyHeader?.let { stickyHeader { it() } } + items(models) { it() } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun BaseLazyColumn( + modifier: Modifier = Modifier, + isVisible: Boolean = false, + lazyListState: LazyListState = rememberLazyListState(), + models: Map<@Composable () -> Unit, List<@Composable () -> Unit>>, +) { + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(), + exit = fadeOut() + ) { + LazyColumn( + modifier = modifier, + state = lazyListState, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + models.forEach { (initial, ratesForInitial) -> + stickyHeader { initial() } + items(ratesForInitial) { it() } + } + } + } +} \ No newline at end of file diff --git a/ui/common/src/main/java/com/example/ui/common/component/bar/BottomAppBar.kt b/ui/common/src/main/java/com/example/ui/common/component/bar/BottomAppBar.kt new file mode 100644 index 0000000..9c62def --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/component/bar/BottomAppBar.kt @@ -0,0 +1,108 @@ +package com.example.ui.common.component.bar + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.navigation.NavDestination +import androidx.navigation.NavDestination.Companion.hierarchy + +@Composable +fun BottomAppBar( + modifier: Modifier = Modifier, + tabs: List, + currentDestination: NavDestination? = null, + onNavigateToDestination: (BottomBarTab) -> Unit +) { + NavigationBar( + modifier = modifier + .zIndex(1F) + .shadow( + elevation = 5.dp, + spotColor = MaterialTheme.colorScheme.onSurface + ), + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + tonalElevation = 2.dp + ) { + tabs.forEach { tab -> + val selected = currentDestination.isInHierarchy(tab) + NavigationBarItem( + enabled = true, + selected = selected, + onClick = { onNavigateToDestination(tab) }, + icon = { + Icon( + imageVector = if (selected) { + tab.selectedIcon + } else { + tab.unselectedIcon + }, + contentDescription = tab.contentDescription + ) + }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + selectedTextColor = MaterialTheme.colorScheme.onPrimaryContainer, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + indicatorColor = MaterialTheme.colorScheme.primaryContainer + ), + label = { Text(tab.title) } + ) + } + } +} + + +private fun NavDestination?.isInHierarchy(destination: BottomBarTab) = + this?.hierarchy?.any { + it.route?.contains(destination.route, true) ?: false + } ?: false + + +class BottomBarTab( + val title: String, + val route: String, + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector, + val contentDescription: String? = null +) + +@Preview +@Composable +fun BottomBarPreview() { + BottomAppBar(tabs = bottomBarTabs()) {} +} + +@Composable +fun bottomBarTabs() = listOf( + BottomBarTab( + title = "Home", + route = "route", + selectedIcon = Icons.Default.Home, + unselectedIcon = Icons.Default.Home, + ), + + BottomBarTab( + title = "Favorite", + route = "route", + selectedIcon = Icons.Default.Favorite, + unselectedIcon = Icons.Default.FavoriteBorder, + ), + + BottomBarTab( + title = "Setting ", + route = "route", + selectedIcon = Icons.Default.Settings, + unselectedIcon = Icons.Default.Settings, + ) +) \ No newline at end of file diff --git a/ui/common/src/main/java/com/example/ui/common/component/bar/ConnectivityStatusView.kt b/ui/common/src/main/java/com/example/ui/common/component/bar/ConnectivityStatusView.kt new file mode 100644 index 0000000..be282a7 --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/component/bar/ConnectivityStatusView.kt @@ -0,0 +1,107 @@ +package com.example.ui.common.component.bar + +import androidx.compose.animation.* +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.example.ui.common.test.TestTag +import com.example.ui.common.R + +/** + * @author yaya (@yahyalmh) + * @since 02th November 2022 + */ + +@Composable +fun ConnectivityStatusView( + modifier: Modifier = Modifier, + isOnlineViewVisible: Boolean, + isOfflineViewVisible: Boolean, +) { + OfflineView(modifier = modifier, isVisible = isOfflineViewVisible) + OnlineView(modifier = modifier, isVisible = isOnlineViewVisible) +} + +@Composable +internal fun OfflineView( + modifier: Modifier = Modifier, + isVisible: Boolean +) { + AnimatedVisibility( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .background(Color.Red) + .testTag(TestTag.ONLINE_STATUS_VIEW), + visible = isVisible, + enter = fadeIn(animationSpec = tween(500)) + + expandVertically(animationSpec = tween(500)), + exit = fadeOut(animationSpec = tween(500)) + + shrinkVertically(animationSpec = tween(500)) + ) { + Row( + modifier = modifier, + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.waitForNetwork), + style = MaterialTheme.typography.bodySmall + ) + } + } +} + +@Composable +internal fun OnlineView( + modifier: Modifier = Modifier, + isVisible: Boolean +) { + AnimatedVisibility( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .background(Color.Green) + .testTag(TestTag.OFFLINE_STATUS_VIEW), + visible = isVisible, + enter = fadeIn(animationSpec = tween(500)) + + expandVertically(animationSpec = tween(500)), + exit = fadeOut(animationSpec = tween(500)) + + shrinkVertically(animationSpec = tween(500)) + ) { + Row( + modifier = modifier, + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(id = R.string.online), + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Preview +@Composable +fun OfflinePreview() { + ConnectivityStatusView(isOnlineViewVisible = false, isOfflineViewVisible = true) +} + +@Preview +@Composable +fun OnlinePreview() { + ConnectivityStatusView(isOnlineViewVisible = true, isOfflineViewVisible = false) +} \ No newline at end of file diff --git a/ui/common/src/main/java/com/example/ui/common/component/bar/SearchBar.kt b/ui/common/src/main/java/com/example/ui/common/component/bar/SearchBar.kt new file mode 100644 index 0000000..2f3cc6a --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/component/bar/SearchBar.kt @@ -0,0 +1,163 @@ +package com.example.ui.common.component.bar + +import android.content.res.Configuration.UI_MODE_NIGHT_MASK +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.example.ui.common.R +import com.example.ui.common.component.icon.AppIcons + +@Composable +fun SearchBar( + modifier: Modifier = Modifier, + hint: String, + onQueryChange: (query: String) -> Unit, + onCancelClick: () -> Unit +) { + val focusRequester = FocusRequester() + val focusManager = LocalFocusManager.current + var value by remember { mutableStateOf("") } + + Row( + modifier = modifier + .shadow( + elevation = 5.dp, + spotColor = MaterialTheme.colorScheme.onBackground + ) + .background(MaterialTheme.colorScheme.background) + .padding(3.dp) + .zIndex(1F), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier + .weight(1f) + .padding(start = 4.dp) + .background( + MaterialTheme.colorScheme.secondary, + RoundedCornerShape(20) + ) + .padding(6.dp) + .shadow(elevation = (-5).dp) + .zIndex(-1f), + verticalAlignment = Alignment.CenterVertically + + ) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = AppIcons.Search, + contentDescription = stringResource(id = R.string.search), + tint = MaterialTheme.colorScheme.onSecondary + ) + BasicTextField(modifier = Modifier + .focusRequester(focusRequester) + .weight(1f) + .padding(start = 2.dp) + .height(24.dp) + .background(color = Color.Transparent, shape = RoundedCornerShape(20)), + singleLine = true, + keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }), + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Search), + cursorBrush = SolidColor(MaterialTheme.colorScheme.onSecondary), + value = value, + textStyle = MaterialTheme.typography.bodyLarge + .copy(color = MaterialTheme.colorScheme.onSecondary), + onValueChange = { + value = it + onQueryChange(it) + }, + decorationBox = { innerTextField -> + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + + ) { + AnimatedVisibility( + visible = value.isEmpty(), + enter = fadeIn(initialAlpha = 0.3f), + exit = fadeOut() + ) { + Text( + text = hint, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSecondary, + ) + } + } + innerTextField() + } + ) + AnimatedVisibility( + visible = value.isNotEmpty(), + enter = fadeIn(initialAlpha = 0.3f), + exit = fadeOut() + ) { + Icon( + modifier = Modifier + .clickable { + value = "" + onQueryChange(value) + } + .size(20.dp), + imageVector = AppIcons.Close, + contentDescription = stringResource(id = R.string.search), + tint = MaterialTheme.colorScheme.onSecondary + ) + } + } + TextButton( + modifier = Modifier + .wrapContentSize() + .padding(start = 4.dp, end = 4.dp), + contentPadding = PaddingValues(4.dp), + onClick = onCancelClick + ) { + Text( + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyLarge, + text = stringResource(id = R.string.cancel) + ) + } + } + SideEffect { focusRequester.requestFocus() } +} + +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +fun SearchPreviewDark() { + SearchBar(hint = "Search", onQueryChange = {}) {} +} + +@Preview(showBackground = true, showSystemUi = false, uiMode = UI_MODE_NIGHT_MASK) +@Composable +fun SearchPreviewLight() { + SearchBar(hint = "Search", onQueryChange = {}) {} +} \ No newline at end of file diff --git a/ui/common/src/main/java/com/example/ui/common/component/bar/TopAppBar.kt b/ui/common/src/main/java/com/example/ui/common/component/bar/TopAppBar.kt new file mode 100644 index 0000000..bd8cdac --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/component/bar/TopAppBar.kt @@ -0,0 +1,75 @@ +package com.example.ui.common.component.bar + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TopAppBar( + modifier: Modifier = Modifier, + title: String, + navigationIcon: ImageVector? = null, + navigationIconContentDescription: String? = null, + actionIcon: ImageVector? = null, + actionIconColor: Color = MaterialTheme.colorScheme.onSurface, + actionIconContentDescription: String? = null, + colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(), + onNavigationClick: () -> Unit = {}, + onActionClick: () -> Unit = {}, +) { + CenterAlignedTopAppBar( + modifier = modifier + .zIndex(1F) + .shadow( + elevation = 5.dp, + spotColor = MaterialTheme.colorScheme.onBackground + ), + title = { Text(text = title) }, + navigationIcon = { + if (navigationIcon != null) { + IconButton(onClick = onNavigationClick) { + Icon( + imageVector = navigationIcon, + contentDescription = navigationIconContentDescription, + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + }, + actions = { + if (actionIcon != null) { + IconButton(onClick = onActionClick) { + Icon( + imageVector = actionIcon, + contentDescription = actionIconContentDescription, + tint = actionIconColor + ) + } + } + }, + colors = colors + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview("Top App Bar") +@Composable +fun TopAppBarPreview() { + TopAppBar( + title = "Title", + navigationIcon = Icons.Default.Menu, + navigationIconContentDescription = "Navigation icon", + actionIcon = Icons.Default.Search, + actionIconContentDescription = "Action icon" + ) +} diff --git a/ui/common/src/main/java/com/example/ui/common/component/cell/RateCell.kt b/ui/common/src/main/java/com/example/ui/common/component/cell/RateCell.kt new file mode 100644 index 0000000..f0bf61a --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/component/cell/RateCell.kt @@ -0,0 +1,248 @@ +package com.example.ui.common.component.cell + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.data.common.model.ExchangeRate +import com.example.ui.common.R +import com.example.ui.common.component.icon.AppIcons +import com.example.ui.common.component.view.ShimmerAxis +import com.example.ui.common.component.view.ShimmerGradient +import com.example.ui.common.component.view.shimmerBackground +import java.util.* + +@Composable +fun RateCell( + modifier: Modifier = Modifier, + rate: ExchangeRate, + onClick: () -> Unit, + leadingIcon: ImageVector? = null, + onLeadingIconClick: (rate: ExchangeRate) -> Unit +) { + Card(modifier = modifier + .height(120.dp) + .clickable { onClick() } + .padding(8.dp), + shape = RoundedCornerShape(12.dp)) + { + Content( + modifier = modifier, + rate = rate, + leadingIcon = leadingIcon, + onLeadingIconClick = onLeadingIconClick + ) + } +} + +@Composable +private fun Content( + modifier: Modifier, + rate: ExchangeRate, + leadingIcon: ImageVector?, + onLeadingIconClick: (rate: ExchangeRate) -> Unit +) { + Row( + modifier = modifier + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = modifier + .size(74.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSecondaryContainer), + contentAlignment = Alignment.Center + ) { + Text( + textAlign = TextAlign.Center, + text = rate.currencySymbol ?: rate.symbol, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.secondary + ) + } + + Column( + modifier = modifier.padding(start = 12.dp, 4.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = "Rate: ${rate.rateUsd}", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = "Symbol: ${rate.symbol}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "Type: ${ + rate.type.replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.ROOT) + else it.toString() + } + }", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.weight(1f)) + if (leadingIcon != null) { + Column { + Icon( + modifier = Modifier + .size(24.dp) + .clickable { onLeadingIconClick(rate) }, + imageVector = leadingIcon, + contentDescription = stringResource(id = R.string.favoriteIconDescription), + tint = Color.Red + ) + Spacer(modifier = Modifier.weight(1f)) + } + } + } +} + +@Composable +fun RateShimmerCell( + modifier: Modifier = Modifier, + shimmerAxis: ShimmerAxis, +) { + Card( + modifier = modifier + .width(400.dp) + .height(120.dp) + .padding(8.dp), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = modifier + .shimmerBackground( + ShimmerGradient.Linear( + MaterialTheme.colorScheme.secondaryContainer, + shimmerAxis + ) + ) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = modifier + .size(74.dp) + .clip(CircleShape) + .background(Color.Gray), + ) + Column( + modifier = modifier.padding(start = 12.dp, 4.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = modifier + .size(150.dp, 12.dp) + .clip(CircleShape) + .background(Color.Gray), + ) + Box( + modifier = modifier + .size(100.dp, 10.dp) + .clip(CircleShape) + .background(Color.Gray), + ) + Box( + modifier = modifier + .size(70.dp, 10.dp) + .clip(CircleShape) + .background(Color.Gray), + ) + } + Spacer(modifier = Modifier.weight(1f)) + Column { + Icon( + modifier = Modifier.size(24.dp), + imageVector = Icons.Default.FavoriteBorder, + contentDescription = null, + tint = Color.Gray + ) + Spacer(modifier = Modifier.weight(1f)) + } + } + } +} + +@Composable +fun ExchangeRate.toCell( + favoritesRates: List, + navigateToDetail: (id: String) -> Unit, + onFavoriteClick: (rate: ExchangeRate) -> Unit +): @Composable () -> Unit = { + val leadingIcon = if (favoritesRates.any { it.id == this.id && it.symbol == this.symbol }) { + AppIcons.Favorite + } else { + AppIcons.FavoriteBorder + } + RateCell( + rate = this, + leadingIcon = leadingIcon, + onClick = { navigateToDetail(this.id) }, + onLeadingIconClick = onFavoriteClick + ) +} + +@Composable +fun ExchangeRate.toCell( + navigateToDetail: (id: String) -> Unit, + onFavoriteClick: (rate: ExchangeRate) -> Unit +): @Composable () -> Unit = { + val leadingIcon = AppIcons.Favorite + RateCell( + rate = this, + leadingIcon = leadingIcon, + onClick = { navigateToDetail(this.id) }, + onLeadingIconClick = onFavoriteClick + ) +} + +@Preview +@Composable +fun RateShimmerCellPreview() { + RateShimmerCell(shimmerAxis = ShimmerAxis(200f, 200f, 400f, 400f)) +} + +@Preview +@Composable +fun RateCellPreview() { + RateCell( + rate = ExchangeRate( + id = "1", + rateUsd = 3.343234342.toBigDecimal(), + symbol = "$", + currencySymbol = "USD", + type = "Fiat" + ), + onClick = {}, + leadingIcon = AppIcons.Favorite + ) {} +} diff --git a/ui/common/src/main/java/com/example/ui/common/component/icon/AppIcons.kt b/ui/common/src/main/java/com/example/ui/common/component/icon/AppIcons.kt new file mode 100644 index 0000000..db0d54a --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/component/icon/AppIcons.kt @@ -0,0 +1,41 @@ +package com.example.ui.common.component.icon + +import androidx.annotation.DrawableRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.outlined.AccountCircle +import androidx.compose.material.icons.rounded.* +import androidx.compose.ui.graphics.vector.ImageVector + +/** + * Material icons are [ImageVector]s, custom icons are drawable resource IDs. + */ +object AppIcons { + val AccountCircle = Icons.Outlined.AccountCircle + val Add = Icons.Rounded.Add + val ArrowBack = Icons.Rounded.ArrowBack + val ArrowDropDown = Icons.Rounded.ArrowDropDown + val Check = Icons.Rounded.Check + val Close = Icons.Rounded.Close + val MoreVert = Icons.Default.MoreVert + val Person = Icons.Rounded.Person + val PlayArrow = Icons.Rounded.PlayArrow + val Search = Icons.Rounded.Search + val Menu = Icons.Rounded.Menu + val Settings = Icons.Rounded.Settings + val ThumbUp = Icons.Rounded.ThumbUp + val Face = Icons.Rounded.Face + val Warning = Icons.Rounded.Warning + val Favorite = Icons.Default.Favorite + val FavoriteBorder = Icons.Default.FavoriteBorder +} + +/** + * A sealed class to make dealing with [ImageVector] and [DrawableRes] icons easier. + */ +sealed class IconType { + data class ImageVectorIcon(val imageVector: ImageVector) : IconType() + data class DrawableResourceIcon(@DrawableRes val id: Int) : IconType() +} diff --git a/ui/common/src/main/java/com/example/ui/common/component/screen/BaseScreen.kt b/ui/common/src/main/java/com/example/ui/common/component/screen/BaseScreen.kt new file mode 100644 index 0000000..48c3e1f --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/component/screen/BaseScreen.kt @@ -0,0 +1,111 @@ +package com.example.ui.common.component.screen + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.navigation.NavHostController +import com.example.ui.common.BaseViewModel +import com.example.ui.common.UIEvent +import com.example.ui.common.UIState +import com.example.ui.common.component.bar.TopAppBar +import com.example.ui.common.component.icon.AppIcons +import com.example.ui.common.component.view.AutoRetryView +import com.example.ui.common.component.view.LoadingView +import com.example.ui.common.component.view.RetryView + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BaseScreen( + modifier: Modifier = Modifier, + navController: NavHostController, + viewModel: BaseViewModel, + screenTitle: String, + navigationIcon: ImageVector? = AppIcons.ArrowBack, + navigationIconContentDescription: String? = "Back Icon", + onNavigationClick: () -> Unit = { navController.popBackStack() }, + actionIcon: ImageVector? = null, + actionIconContentDescription: String? = null, + onActionClick: () -> Unit = {}, + onRetry: () -> Unit = {}, + contentView: @Composable () -> Unit = {}, +) { + val uiState = viewModel.state.value + + Scaffold( + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + TopAppBar( + title = screenTitle, + modifier = Modifier + .zIndex(1F) + .shadow( + elevation = 5.dp, + spotColor = MaterialTheme.colorScheme.onBackground + ), + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.colorScheme.background + ), + navigationIcon = navigationIcon, + onNavigationClick = onNavigationClick, + navigationIconContentDescription = navigationIconContentDescription, + actionIcon = actionIcon, + actionIconContentDescription = actionIconContentDescription, + onActionClick = onActionClick, + ) + } + ) { padding -> + + LoadingView(isVisible = uiState.isLoading) + + RetryView( + isVisible = uiState.isRetry, + retryMessage = uiState.retryMsg, + icon = AppIcons.Warning, + onRetry = onRetry + ) + + AutoRetryView( + isVisible = uiState.isAutoRetry, + errorMessage = uiState.autoRetryMsg, + icon = AppIcons.Warning, + ) + + ContentView( + modifier = modifier + .padding(padding) + .background(MaterialTheme.colorScheme.surface), + isVisible = uiState.isLoaded, + content = contentView + ) + } +} + + +open class BaseUiState( + open var isLoading: Boolean = false, + open val isLoaded: Boolean = false, + open val isRetry: Boolean = false, + open val retryMsg: String = "", + open val isAutoRetry: Boolean = false, + open val autoRetryMsg: String = "", +) : UIState + +@Composable +private fun ContentView( + modifier: Modifier, + isVisible: Boolean, + content: @Composable () -> Unit +) { + if (isVisible) { + Column(modifier = modifier) { + content() + } + } +} diff --git a/ui/common/src/main/java/com/example/ui/common/component/screen/SearchBarScaffold.kt b/ui/common/src/main/java/com/example/ui/common/component/screen/SearchBarScaffold.kt new file mode 100644 index 0000000..fded6d1 --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/component/screen/SearchBarScaffold.kt @@ -0,0 +1,36 @@ +package com.example.ui.common.component.screen + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.example.ui.common.component.bar.SearchBar + +@Composable +fun SearchBarScaffold( + modifier: Modifier = Modifier, + hint: String, + onQueryChange: (query: String) -> Unit, + onCancelClick: () -> Unit, + content: @Composable () -> Unit +) { + Surface( + modifier = modifier + .background(MaterialTheme.colorScheme.surface) + .fillMaxSize() + ) { + Column(modifier = modifier.background(MaterialTheme.colorScheme.surface)) { + + SearchBar( + modifier = modifier.background(MaterialTheme.colorScheme.surface), + hint = hint, + onQueryChange = onQueryChange, + onCancelClick = onCancelClick + ) + content() + } + } +} \ No newline at end of file diff --git a/ui/common/src/main/java/com/example/ui/common/component/screen/TopBarScaffold.kt b/ui/common/src/main/java/com/example/ui/common/component/screen/TopBarScaffold.kt new file mode 100644 index 0000000..50db4a2 --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/component/screen/TopBarScaffold.kt @@ -0,0 +1,55 @@ +package com.example.ui.common.component.screen + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.example.ui.common.component.bar.TopAppBar + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TopBarScaffold( + title: String, + navigationIcon: ImageVector? = null, + navigationIconContentDescription: String? = null, + onNavigationClick: () -> Unit = {}, + actionIcon: ImageVector? = null, + actionIconContentDescription: String? = null, + actionIconColor: Color = MaterialTheme.colorScheme.onSurface, + onActionClick: () -> Unit = {}, + content: @Composable (PaddingValues) -> Unit +) { + Scaffold( + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + TopAppBar( + title = title, + modifier = Modifier + .zIndex(1F) + .shadow( + elevation = 5.dp, + spotColor = MaterialTheme.colorScheme.onBackground + ), + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.colorScheme.background + ), + navigationIcon = navigationIcon, + onNavigationClick = onNavigationClick, + navigationIconContentDescription = navigationIconContentDescription, + actionIcon = actionIcon, + actionIconContentDescription = actionIconContentDescription, + actionIconColor = actionIconColor, + onActionClick = onActionClick, + ) + }, + content = content + ) +} \ No newline at end of file diff --git a/ui/common/src/main/java/com/example/ui/common/component/view/AutoRetryView.kt b/ui/common/src/main/java/com/example/ui/common/component/view/AutoRetryView.kt new file mode 100644 index 0000000..904c095 --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/component/view/AutoRetryView.kt @@ -0,0 +1,83 @@ +package com.example.ui.common.component.view + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.ui.common.component.icon.AppIcons +import com.example.ui.common.test.TestTag +import com.example.ui.common.R + +@Composable +fun AutoRetryView( + modifier: Modifier = Modifier, + isVisible: Boolean = true, + errorMessage: String? = null, + icon: ImageVector, + hint: String = stringResource(id = R.string.autoRetryHint), +) { + AnimatedVisibility( + modifier = modifier.testTag(TestTag.AUTO_RETRY_VIEW), + visible = isVisible, + enter = fadeIn(), + exit = fadeOut() + ) { + Column( + modifier = modifier + .background(MaterialTheme.colorScheme.background) + .padding(10.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy( + space = 30.dp, + alignment = Alignment.CenterVertically + ), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + modifier = modifier + .padding(12.dp) + .size(120.dp), + imageVector = icon, + contentDescription = stringResource(id = R.string.warningIconDescription), + tint = MaterialTheme.colorScheme.error + ) + + Text( + text = errorMessage ?: stringResource(id = R.string.defaultErrorHint), + modifier = modifier.padding(12.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error + ) + + Text( + text = hint, + modifier = modifier.padding(12.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + color = Color.Green + ) + + } + } +} + +@Preview(showBackground = true, showSystemUi = true) +@Composable +fun AutoRetryPreview() { + AutoRetryView(icon = AppIcons.Warning) +} \ No newline at end of file diff --git a/ui/common/src/main/java/com/example/ui/common/component/view/EmptyView.kt b/ui/common/src/main/java/com/example/ui/common/component/view/EmptyView.kt new file mode 100644 index 0000000..5ca7cc8 --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/component/view/EmptyView.kt @@ -0,0 +1,108 @@ +package com.example.ui.common.component.view + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.ui.common.R +import com.example.ui.common.component.icon.AppIcons +import com.example.ui.common.test.TestTag + +@Composable +fun EmptyView( + modifier: Modifier = Modifier, + isVisible: Boolean = true, + icon: ImageVector, + message: String, +) { + ContentView( + modifier = modifier, + isVisible = isVisible, + icon = icon + ) { + Text( + modifier = Modifier.padding(top = 10.dp), + color = MaterialTheme.colorScheme.onBackground, + fontSize = 16.sp, + fontStyle = FontStyle.Normal, + text = message + ) + } +} + +@Composable +fun EmptyView( + modifier: Modifier = Modifier, + isVisible: Boolean = true, + icon: ImageVector, + message: AnnotatedString, +) { + ContentView( + modifier = modifier, + isVisible = isVisible, + icon = icon + ) { + Text( + modifier = Modifier.padding(top = 10.dp), + color = MaterialTheme.colorScheme.onBackground, + fontSize = 16.sp, + text = message + ) + } +} + +@Composable +private fun ContentView( + modifier: Modifier, + isVisible: Boolean, + icon: ImageVector, + message: @Composable () -> Unit +) { + AnimatedVisibility( + modifier = modifier.testTag(TestTag.EMPTY_VIEW), + visible = isVisible, + enter = fadeIn(), + exit = fadeOut() + ) { + Column( + modifier = modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + + Icon( + modifier = Modifier + .padding(12.dp) + .size(80.dp), + imageVector = icon, + contentDescription = stringResource(id = R.string.emptyIcon), + tint = MaterialTheme.colorScheme.onBackground + ) + + message() + } + } +} + +@Preview(showSystemUi = true) +@Composable +fun Preview() { + EmptyView(isVisible = true, icon = AppIcons.Search, message = "Not Found") +} \ No newline at end of file diff --git a/ui/common/src/main/java/com/example/ui/common/component/view/LoadingView.kt b/ui/common/src/main/java/com/example/ui/common/component/view/LoadingView.kt new file mode 100644 index 0000000..4ace805 --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/component/view/LoadingView.kt @@ -0,0 +1,56 @@ +package com.example.ui.common.component.view + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.ui.common.test.TestTag + +/** + * @author yaya (@yahyalmh) + * @since 10th November 2022 + */ + +@Composable +fun LoadingView( + modifier: Modifier = Modifier, + isVisible: Boolean, +) { + AnimatedVisibility( + modifier = modifier.testTag(TestTag.LOADING), + visible = isVisible, + enter = fadeIn(), + exit = fadeOut() + ) { + Column( + modifier = modifier + .background(MaterialTheme.colorScheme.background) + .padding(10.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.onBackground, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun LoadingPreview() { + LoadingView(isVisible = true) +} diff --git a/ui/common/src/main/java/com/example/ui/common/component/view/RetryView.kt b/ui/common/src/main/java/com/example/ui/common/component/view/RetryView.kt new file mode 100644 index 0000000..a13a7d8 --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/component/view/RetryView.kt @@ -0,0 +1,86 @@ +package com.example.ui.common.component.view + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Text +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.ui.common.component.icon.AppIcons +import com.example.ui.common.test.TestTag +import com.example.ui.common.R + + +/** + * @author yaya (@yahyalmh) + * @since 10th November 2022 + */ + +@Composable +fun RetryView( + modifier: Modifier = Modifier, + isVisible: Boolean = true, + retryMessage: String? = null, + icon: ImageVector, onRetry: () -> Unit +) { + AnimatedVisibility( + modifier = modifier.testTag(TestTag.RETRY_VIEW), + visible = isVisible, + enter = fadeIn(), + exit = fadeOut() + ) { + Column( + modifier = modifier + .background(MaterialTheme.colorScheme.background) + .padding(10.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy( + space = 30.dp, + alignment = Alignment.CenterVertically + ), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + modifier = modifier + .padding(12.dp) + .size(120.dp), + imageVector = icon, + contentDescription = stringResource(id = R.string.warningIconDescription), + tint = MaterialTheme.colorScheme.error + ) + + Text( + text = retryMessage ?: stringResource(id = R.string.defaultErrorHint), + modifier = modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error + ) + + OutlinedButton(onClick = onRetry, shape = MaterialTheme.shapes.medium) { + Text(modifier = Modifier.padding(start = 12.dp, end = 12.dp), + text = stringResource(R.string.retry), + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } +} + +@Preview(showBackground = true, showSystemUi = true) +@Composable +fun RetryPreview() { + RetryView(icon = AppIcons.Warning) {} +} diff --git a/ui/common/src/main/java/com/example/ui/common/component/view/ShimmerView.kt b/ui/common/src/main/java/com/example/ui/common/component/view/ShimmerView.kt new file mode 100644 index 0000000..81ce764 --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/component/view/ShimmerView.kt @@ -0,0 +1,135 @@ +package com.example.ui.common.component.view + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.* +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.example.ui.common.test.TestTag + + +@Composable +fun ShimmerView( + modifier: Modifier = Modifier, + duration: Int = 1300, + interval: Int = 300, + isVisible: Boolean = false, + content: @Composable (shimmerAxis: ShimmerAxis) -> Unit, +) { + val padding = 12.dp + AnimatedVisibility( + modifier = modifier.testTag(TestTag.SHIMMER_VIEW), + visible = isVisible, + enter = fadeIn(), + exit = fadeOut() + ) { + BoxWithConstraints( + modifier = Modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + val screenWidthPx = with(LocalDensity.current) { (maxWidth - (padding * 2)).toPx() } + val screenHeightPx = with(LocalDensity.current) { (maxHeight - padding).toPx() } + val gradientWidth: Float = (0.12f * screenHeightPx) + + val infiniteTransition = rememberInfiniteTransition() + val xAnimateValue = infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = (screenWidthPx + gradientWidth), + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = duration, + easing = LinearEasing, + delayMillis = interval + ), + repeatMode = RepeatMode.Restart + ) + ) + val yAnimateValue = infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = (screenHeightPx + gradientWidth), + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 1300, + easing = LinearEasing, + delayMillis = 300 + ), + repeatMode = RepeatMode.Restart + ) + ) + + val endX = xAnimateValue.value + val startX = xAnimateValue.value - gradientWidth + val endY = yAnimateValue.value + val startY = yAnimateValue.value - gradientWidth + content(ShimmerAxis(startX, endX, startY, endY)) + } + } +} + +fun Modifier.shimmerBackground( + gradient: ShimmerGradient.Linear, + shape: Shape = RectangleShape +): Modifier = + this + .fillMaxSize() + .zIndex(1f) + .background(brush = gradient(), shape = shape) + +sealed class ShimmerGradient { + protected fun createColors(color: Color): List = listOf( + color.copy(alpha = .9f), + color.copy(alpha = .3f), + color.copy(alpha = .9f), + ) + + class Linear(val color: Color, val shimmerAxis: ShimmerAxis) : + ShimmerGradient() { + operator fun invoke() = Brush.linearGradient( + createColors(color), + start = Offset(shimmerAxis.startX, shimmerAxis.startY), + end = Offset(shimmerAxis.endX, shimmerAxis.endY) + ) + } + + class Horizontal(val color: Color, private val shimmerAxis: ShimmerAxis) : + ShimmerGradient() { + operator fun invoke() = Brush.horizontalGradient( + createColors(color), + startX = shimmerAxis.startX, + endX = shimmerAxis.endX + ) + } + + class Vertical(private val color: Color, private val shimmerAxis: ShimmerAxis) : + ShimmerGradient() { + operator fun invoke() = Brush.verticalGradient( + createColors(color), + startY = shimmerAxis.startY, + endY = shimmerAxis.endY + ) + } +} + +data class ShimmerAxis( + val startX: Float, + val endX: Float, + val startY: Float, + val endY: Float, +) diff --git a/ui/common/src/main/java/com/example/ui/common/connectivity/ConnectivityMonitor.kt b/ui/common/src/main/java/com/example/ui/common/connectivity/ConnectivityMonitor.kt new file mode 100644 index 0000000..0a5d120 --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/connectivity/ConnectivityMonitor.kt @@ -0,0 +1,84 @@ +package com.example.ui.common.connectivity + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.os.Build +import androidx.core.content.getSystemService +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.conflate +import javax.inject.Inject + +/** + * Utility for reporting app connectivity status + */ +interface ConnectivityMonitor { + val isOnline: Flow +} + +class ConnectivityMonitorImpl @Inject constructor( + @ApplicationContext private val context: Context +) : ConnectivityMonitor { + override val isOnline: Flow = callbackFlow { + val connectivityManager = context.getSystemService() + + /** + * The callback's methods are invoked on changes to *any* network, not just the active + * network. So to check for network connectivity, one must query the active network of the + * ConnectivityManager. + */ + /** + * The callback's methods are invoked on changes to *any* network, not just the active + * network. So to check for network connectivity, one must query the active network of the + * ConnectivityManager. + */ + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + channel.trySend(connectivityManager.isCurrentlyConnected()) + } + + override fun onLost(network: Network) { + channel.trySend(connectivityManager.isCurrentlyConnected()) + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) { + channel.trySend(connectivityManager.isCurrentlyConnected()) + } + } + + connectivityManager?.registerNetworkCallback( + NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build(), + callback + ) + + channel.trySend(connectivityManager.isCurrentlyConnected()) + + awaitClose { + connectivityManager?.unregisterNetworkCallback(callback) + } + } + .conflate() + + @Suppress("DEPRECATION") + private fun ConnectivityManager?.isCurrentlyConnected() = when (this) { + null -> false + else -> when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> + activeNetwork + ?.let(::getNetworkCapabilities) + ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + ?: false + else -> activeNetworkInfo?.isConnected ?: false + } + } +} diff --git a/ui/common/src/main/java/com/example/ui/common/connectivity/di/ConnectivityMonitorModule.kt b/ui/common/src/main/java/com/example/ui/common/connectivity/di/ConnectivityMonitorModule.kt new file mode 100644 index 0000000..54635b7 --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/connectivity/di/ConnectivityMonitorModule.kt @@ -0,0 +1,22 @@ +package com.example.ui.common.connectivity.di + +import com.example.ui.common.connectivity.ConnectivityMonitor +import com.example.ui.common.connectivity.ConnectivityMonitorImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * @author yaya (@yahyalmh) + * @since 04th November 2022 + */ + +@Module +@InstallIn(SingletonComponent::class) +interface ConnectivityMonitorModule { + @Binds + @Singleton + fun bindConnectivityMonitor(networkMonitor: ConnectivityMonitorImpl): ConnectivityMonitor +} \ No newline at end of file diff --git a/ui/common/src/main/java/com/example/ui/common/ext/CollectionExt.kt b/ui/common/src/main/java/com/example/ui/common/ext/CollectionExt.kt new file mode 100644 index 0000000..999475f --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/ext/CollectionExt.kt @@ -0,0 +1,12 @@ +package com.example.ui.common.ext + +import androidx.compose.runtime.Composable + +@Composable +fun create(count: Int = 1, creator: @Composable (Int) -> T): List { + val result = mutableListOf() + for (index in 0 until count) { + result.add(creator(index)) + } + return result.toList() +} \ No newline at end of file diff --git a/ui/common/src/main/java/com/example/ui/common/ext/FlowExt.kt b/ui/common/src/main/java/com/example/ui/common/ext/FlowExt.kt new file mode 100644 index 0000000..5f2d72f --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/ext/FlowExt.kt @@ -0,0 +1,109 @@ +package com.example.ui.common.ext + +import com.example.ui.common.connectivity.ConnectivityMonitor +import kotlinx.coroutines.* +import kotlinx.coroutines.GlobalScope.coroutineContext +import kotlinx.coroutines.flow.* +import java.io.IOException +import kotlin.coroutines.CoroutineContext + +/** + * @author yaya (@yahyalmh) + * @since 09th November 2022 + */ + +fun repeatFlow(interval: Long, block: suspend () -> T): Flow = + flow { + while (true) { + emit(block.invoke()) + delay(interval) + } + } + +fun Flow.retryWithPolicy( + retryPolicy: RetryPolicy = RetryPolicy.DefaultRetryPolicy, + retryHandler: (e: Throwable) -> Unit +): Flow { + var currentDelay = retryPolicy.delayMillis + + return retryWhen { cause, attempt -> + retryHandler(cause) + if (cause is IOException && attempt < retryPolicy.numRetries) { + delay(currentDelay) + currentDelay *= retryPolicy.delayFactor + return@retryWhen true + } else { + return@retryWhen false + } + } +} + +fun Flow.retryOnNetworkConnection( + connectivityMonitor: ConnectivityMonitor, + retryHandler: (e: Throwable) -> Unit +): Flow = flow { + val exception = catchError(this) + if (exception != null && exception is IOException) { + retryHandler(exception) + connectivityMonitor + .isOnline + .distinctUntilChanged() + .collectLatest { isOnline -> + if (isOnline) { + collect() + } + } + } +} + +sealed class RetryPolicy( + val numRetries: Long, + val delayMillis: Long, + val delayFactor: Long +) { + object DefaultRetryPolicy : RetryPolicy(numRetries = 6, delayMillis = 1000, delayFactor = 1) +} + +@Suppress("NAME_SHADOWING") +internal suspend fun Flow.catchError( + collector: FlowCollector +): Throwable? { + var fromDownstream: Throwable? = null + try { + collect { + try { + collector.emit(it) + } catch (e: Throwable) { + fromDownstream = e + throw e + } + } + } catch (e: Throwable) { + val fromDownstream = fromDownstream + if (e.isSameExceptionAs(fromDownstream) || e.isCancellationCause(coroutineContext)) { + throw e + } else { + if (fromDownstream == null) { + return e + } + if (e is CancellationException) { + fromDownstream.addSuppressed(e) + throw fromDownstream + } else { + e.addSuppressed(fromDownstream) + throw e + } + } + } + return null +} + +@OptIn(InternalCoroutinesApi::class) +private fun Throwable.isCancellationCause(coroutineContext: CoroutineContext): Boolean { + val job = coroutineContext[Job] + if (job == null || !job.isCancelled) return false + return isSameExceptionAs(job.getCancellationException()) +} + +private fun Throwable.isSameExceptionAs(other: Throwable?): Boolean = + other != null && other == this \ No newline at end of file diff --git a/ui/common/src/main/java/com/example/ui/common/test/AppTestRunner.kt b/ui/common/src/main/java/com/example/ui/common/test/AppTestRunner.kt new file mode 100644 index 0000000..80246cf --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/test/AppTestRunner.kt @@ -0,0 +1,12 @@ +package com.example.ui.common.test + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +class AppTestRunner : AndroidJUnitRunner() { + override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { + return super.newApplication(cl, HiltTestApplication::class.java.name, context) + } +} diff --git a/ui/common/src/main/java/com/example/ui/common/test/MainDispatcherRule.kt b/ui/common/src/main/java/com/example/ui/common/test/MainDispatcherRule.kt new file mode 100644 index 0000000..546909e --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/test/MainDispatcherRule.kt @@ -0,0 +1,32 @@ +package com.example.ui.common.test + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +/** + * A JUnit TestRule that sets the Main dispatcher to [testDispatcher] + * for the duration of the test. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), +) : BeforeEachCallback, AfterEachCallback { + + /** + * Set TestCoroutine dispatcher as main + */ + override fun beforeEach(context: ExtensionContext?) { + Dispatchers.setMain(testDispatcher) + } + + override fun afterEach(context: ExtensionContext?) { + Dispatchers.resetMain() + } +} \ No newline at end of file diff --git a/ui/common/src/main/java/com/example/ui/common/test/TestExt.kt b/ui/common/src/main/java/com/example/ui/common/test/TestExt.kt new file mode 100644 index 0000000..3a9f0bc --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/test/TestExt.kt @@ -0,0 +1,62 @@ +package com.example.ui.common.test + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.test.ext.junit.rules.ActivityScenarioRule +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import org.junit.rules.TestRule +import org.mockito.stubbing.OngoingStubbing + +fun OngoingStubbing>.thenEmitError(e: Throwable) { + thenReturn(flow { throw e }) +} + +fun OngoingStubbing>.thenEmitNothing() { + thenReturn(flow {}) +} + +fun AndroidComposeTestRule.wait(timeoutMillis: Long) = + waitUntil(timeoutMillis) { + mainClock.currentTime >= timeoutMillis + } + +fun AndroidComposeTestRule.logTree() = + onRoot(useUnmergedTree = true).printToLog("LogTree") + +fun AndroidComposeTestRule.getString(id: Int) = + activity.getString(id) + +fun AndroidComposeTestRule, T>.waitUntilDisplayed( + matcher: SemanticsMatcher, + timeoutMillis: Long = 5000, +) { + waitUntil(timeoutMillis) { + try { + onNode(matcher).assertIsDisplayed() + true + } catch (e: AssertionError) { + false + } + } +} + +fun AndroidComposeTestRule, T>.scrollToEnd( + matcher: SemanticsMatcher, + step: Int = 5 +) { + try { + for (index in 0..Int.MAX_VALUE step step) { + onNode(matcher).performScrollToIndex(index) + } + } catch (e: IllegalArgumentException) { + e.message?.let { message -> + val numbers = "\\d+".toRegex().findAll(message).map { it.groupValues[0].toInt() } + val lastIndex = numbers.filter { it > 0 }.min() - 1 + if (lastIndex >= 0) { + onNode(hasScrollToIndexAction()).performScrollToIndex(lastIndex) + } + } + } +} \ No newline at end of file diff --git a/ui/common/src/main/java/com/example/ui/common/test/TestTag.kt b/ui/common/src/main/java/com/example/ui/common/test/TestTag.kt new file mode 100644 index 0000000..293c25c --- /dev/null +++ b/ui/common/src/main/java/com/example/ui/common/test/TestTag.kt @@ -0,0 +1,11 @@ +package com.example.ui.common.test + +object TestTag { + const val LOADING = "loading view" + const val EMPTY_VIEW = "empty view" + const val RETRY_VIEW = "retry view" + const val SHIMMER_VIEW = "shimmer view" + const val AUTO_RETRY_VIEW = "auto retry view" + const val ONLINE_STATUS_VIEW = "Online status view" + const val OFFLINE_STATUS_VIEW = "Offline status view" +} \ No newline at end of file diff --git a/ui/common/src/main/res/values/strings.xml b/ui/common/src/main/res/values/strings.xml new file mode 100644 index 0000000..cf9e792 --- /dev/null +++ b/ui/common/src/main/res/values/strings.xml @@ -0,0 +1,16 @@ + + + Retry + We\'r sorry \n Something went wrong \n\n Pleas try again + Empty Icon + Search + Go online to try again + Auto retry icon + warning icon + Favorite Icon + Back Icon + Cancel + Offline + Waiting for network… + Online + \ No newline at end of file diff --git a/ui/common/src/test/java/com/example/common/ExampleUnitTest.kt b/ui/common/src/test/java/com/example/common/ExampleUnitTest.kt new file mode 100644 index 0000000..6c9f3e0 --- /dev/null +++ b/ui/common/src/test/java/com/example/common/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.example.common + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/ui/detail/.gitignore b/ui/detail/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/ui/detail/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/ui/detail/build.gradle.kts b/ui/detail/build.gradle.kts new file mode 100644 index 0000000..c34e561 --- /dev/null +++ b/ui/detail/build.gradle.kts @@ -0,0 +1,55 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + kotlin("kapt") + id("com.google.dagger.hilt.android") +} + +android { + namespace = "com.example.ui.detail" + compileSdk = AppConfig.compileSdk + + defaultConfig { + minSdk = AppConfig.minSdk + targetSdk = AppConfig.targetSdk + + testInstrumentationRunner = AppConfig.androidTestInstrumentation + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = Version.KOTLIN_COMPILER_EXTENSION_VERSION + } +} + +dependencies { + compose() + composeNavigation() + composeViewModel() + composeMaterial() + hilt() + junit4() + + moduleDependency(":data:exchangerate") + moduleDependency(":data:favorite") + moduleDependency(":data:common") + moduleDependency(":ui:common") +} \ No newline at end of file diff --git a/ui/detail/consumer-rules.pro b/ui/detail/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/ui/detail/proguard-rules.pro b/ui/detail/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/ui/detail/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/ui/detail/src/androidTest/java/com/example/detail/ExampleInstrumentedTest.kt b/ui/detail/src/androidTest/java/com/example/detail/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..0ab8ebe --- /dev/null +++ b/ui/detail/src/androidTest/java/com/example/detail/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package com.example.detail + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.ui.detail.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/ui/detail/src/main/AndroidManifest.xml b/ui/detail/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/ui/detail/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/ui/detail/src/main/java/com/example/detail/DetailScreen.kt b/ui/detail/src/main/java/com/example/detail/DetailScreen.kt new file mode 100644 index 0000000..9ba31fd --- /dev/null +++ b/ui/detail/src/main/java/com/example/detail/DetailScreen.kt @@ -0,0 +1,249 @@ +package com.example.detail + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.example.data.common.model.ExchangeDetailRate +import com.example.ui.common.component.icon.AppIcons +import com.example.ui.common.component.screen.TopBarScaffold +import com.example.ui.common.component.view.RetryView +import com.example.ui.common.component.view.ShimmerGradient +import com.example.ui.common.component.view.ShimmerView +import com.example.ui.common.component.view.shimmerBackground +import com.example.ui.detail.R +import com.example.ui.common.R.string as commonString + +/** + * @author yaya (@yahyalmh) + * @since 10th November 2022 + */ + +@Composable +fun DetailScreen( + modifier: Modifier = Modifier, + navController: NavController, + viewModel: DetailViewModel = hiltViewModel() +) { + DetailScreenContent( + modifier = modifier, + uiState = viewModel.state.value, + onFavoriteClick = { rateDetail -> viewModel.onEvent(DetailUiEvent.OnFavoriteClick(rateDetail)) }, + onBackClick = { + viewModel.onEvent(DetailUiEvent.NavigationBack) + navController.popBackStack() + }, + onRetry = { viewModel.onEvent(DetailUiEvent.Retry) } + ) +} + +@Composable +private fun DetailScreenContent( + modifier: Modifier, + uiState: DetailUiState, + onFavoriteClick: (rate: ExchangeDetailRate?) -> Unit, + onBackClick: () -> Unit, + onRetry: () -> Unit, +) { + TopBarScaffold( + title = stringResource(id = R.string.detail), + actionIcon = if (uiState.isFavorite) { + AppIcons.Favorite + } else { + AppIcons.FavoriteBorder + }, + actionIconContentDescription = stringResource(id = R.string.favoriteIcon), + onActionClick = { onFavoriteClick(uiState.rateDetail) }, + actionIconColor = Color.Red, + navigationIcon = AppIcons.ArrowBack, + navigationIconContentDescription = stringResource(id = commonString.backIconContentDescription), + onNavigationClick = { onBackClick() } + ) { padding -> + + DetailShimmerView(modifier = modifier.padding(padding), isVisible = uiState.isLoading) + + RetryView( + modifier = modifier.padding(padding), + isVisible = uiState.isError, + retryMessage = uiState.errorMsg, + icon = AppIcons.Warning, + onRetry = onRetry + ) + + DataView( + modifier = modifier.padding(padding), + isVisible = uiState.isLoaded, + rateDetail = uiState.rateDetail + ) + } +} + +@Composable +private fun DataView( + modifier: Modifier, + isVisible: Boolean, + rateDetail: ExchangeDetailRate?, +) { + if (isVisible) { + Column( + modifier = modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .clip(CircleShape) + .size(150.dp) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(vertical = 15.dp), + contentAlignment = Alignment.Center + ) { + Text(text = rateDetail?.let { it.currencySymbol ?: it.symbol } ?: "", + style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.ExtraBold), + color = MaterialTheme.colorScheme.onSecondaryContainer) + } + rateDetail?.run { + Text( + modifier = modifier.padding(top = 10.dp), + textAlign = TextAlign.Center, + text = "Rate Usd: ${rateUsd.toPlainString()}", + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.ExtraBold), + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + modifier = Modifier.padding(vertical = 20.dp), + text = "Symbol: $symbol", + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.ExtraBold), + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + modifier = Modifier.padding(bottom = 10.dp), + text = "Type: $type", + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.ExtraBold), + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = "TimeStamp: $timestamp", + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.ExtraBold), + color = MaterialTheme.colorScheme.onBackground, + ) + } + } + } +} + +@Composable +fun DetailShimmerView( + modifier: Modifier = Modifier, + isVisible: Boolean +) { + ShimmerView( + modifier = modifier, + isVisible = isVisible, + ) { shimmerAxis -> + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy( + space = 30.dp, + alignment = Alignment.CenterVertically + ), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .clip(CircleShape) + .size(150.dp) + .shimmerBackground( + gradient = ShimmerGradient.Linear( + color = MaterialTheme.colorScheme.secondaryContainer, + shimmerAxis = shimmerAxis + ) + ) + .padding(vertical = 15.dp), + ) + Box( + modifier = Modifier + .size(270.dp, 22.dp) + .clip(CircleShape) + .background(Color.Gray) + .shimmerBackground( + gradient = ShimmerGradient.Linear( + color = MaterialTheme.colorScheme.secondaryContainer, + shimmerAxis = shimmerAxis + ) + ), + ) + Box( + modifier = Modifier + .size(150.dp, 18.dp) + .clip(CircleShape) + .background(Color.Gray) + .shimmerBackground( + gradient = ShimmerGradient.Linear( + color = MaterialTheme.colorScheme.secondaryContainer, + shimmerAxis = shimmerAxis + ) + ), + ) + Box( + modifier = Modifier + .size(120.dp, 18.dp) + .clip(CircleShape) + .background(Color.Gray) + .shimmerBackground( + gradient = ShimmerGradient.Linear( + color = MaterialTheme.colorScheme.secondaryContainer, + shimmerAxis = shimmerAxis + ) + ), + ) + Box( + modifier = Modifier + .size(240.dp, 18.dp) + .clip(CircleShape) + .background(Color.Gray) + .shimmerBackground( + gradient = ShimmerGradient.Linear( + color = MaterialTheme.colorScheme.secondaryContainer, + shimmerAxis = shimmerAxis + ) + ), + ) + } + } +} + +@Preview +@Composable +fun DetailShimmerPreview() { + DetailShimmerView(isVisible = true) +} + +@Composable +@Preview +fun ContentPreview() { + val rateDetail = ExchangeDetailRate( + "Id", + symbol = "$", + currencySymbol = "##", + type = "Fiat", + rateUsd = 0.16544654.toBigDecimal(), + timestamp = 1324654312 + ) + DataView(modifier = Modifier, isVisible = true, rateDetail = rateDetail) +} \ No newline at end of file diff --git a/ui/detail/src/main/java/com/example/detail/DetailViewModel.kt b/ui/detail/src/main/java/com/example/detail/DetailViewModel.kt new file mode 100644 index 0000000..4373243 --- /dev/null +++ b/ui/detail/src/main/java/com/example/detail/DetailViewModel.kt @@ -0,0 +1,126 @@ +package com.example.detail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.example.data.common.model.ExchangeDetailRate +import com.example.data.common.model.toExchangeRate +import com.example.detail.nav.DetailArgs +import com.example.favorite.FavoriteRatesInteractor +import com.example.rate.ExchangeRateInteractor +import com.example.ui.common.BaseViewModel +import com.example.ui.common.SharedState +import com.example.ui.common.UIEvent +import com.example.ui.common.UIState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * @author yaya (@yahyalmh) + * @since 10th November 2022 + */ + +@HiltViewModel +class DetailViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val exchangeRateInteractor: ExchangeRateInteractor, + private val favoriteRatesInteractor: FavoriteRatesInteractor, +) : BaseViewModel(DetailUiState.Loading) { + private val detailArgs: DetailArgs = DetailArgs(savedStateHandle) + + init { + changeBottomBarVisibility(false) + fetchRateDetail() + } + + private fun changeBottomBarVisibility(enabled: Boolean) { + viewModelScope.launch { + SharedState.bottomBarVisible.emit(enabled) + } + } + + private fun fetchRateDetail() { + combine( + exchangeRateInteractor.getLiveRate(detailArgs.rateId), + favoriteRatesInteractor.getFavoriteRates() + ) { rate, favoritesRate -> + setState( + DetailUiState.Loaded( + rate = rate, + favoritesRate.any { it.id == rate.id }) + ) + } + .catch { e -> handleError(e) } + .launchIn(viewModelScope) + } + + private fun handleError(e: Throwable) { + val errorMessage = e.message ?: "Error while fetching the exchange rate" + setState(DetailUiState.Retry(errorMessage)) + } + + private fun handleFavoriteClick(rate: ExchangeDetailRate?) { + viewModelScope.launch { + rate?.toExchangeRate()?.let { exchangeRate -> + val favoriteRates = favoriteRatesInteractor.getFavoriteRates().firstOrNull() + when { + favoriteRates.isNullOrEmpty() -> favoriteRatesInteractor.addFavorite( + exchangeRate + ) + else -> { + if (favoriteRates.any { it.id == exchangeRate.id }) { + favoriteRatesInteractor.removeFavorite(exchangeRate) + } else { + favoriteRatesInteractor.addFavorite(exchangeRate) + } + } + } + } + } + } + + override fun onEvent(event: DetailUiEvent) { + when (event) { + DetailUiEvent.Retry -> { + fetchRateDetail() + setState(DetailUiState.Loading) + } + DetailUiEvent.NavigationBack -> changeBottomBarVisibility(true) + is DetailUiEvent.OnFavoriteClick -> handleFavoriteClick(event.rate) + + } + } +} + +sealed interface DetailUiEvent : UIEvent { + object Retry : DetailUiEvent + object NavigationBack : DetailUiEvent + class OnFavoriteClick(val rate: ExchangeDetailRate?) : DetailUiEvent + +} + +sealed class DetailUiState( + val rateDetail: ExchangeDetailRate? = null, + val isFavorite: Boolean = false, + val isLoading: Boolean = false, + val isLoaded: Boolean = false, + val isError: Boolean = false, + val errorMsg: String = "", +) : UIState { + object Loading : DetailUiState(isLoading = true) + + class Retry(errorMsg: String) : DetailUiState( + isError = true, + errorMsg = errorMsg + ) + + class Loaded(rate: ExchangeDetailRate, isFavorite: Boolean) : DetailUiState( + isLoaded = true, + rateDetail = rate, + isFavorite = isFavorite, + ) +} \ No newline at end of file diff --git a/ui/detail/src/main/java/com/example/detail/nav/DetailNav.kt b/ui/detail/src/main/java/com/example/detail/nav/DetailNav.kt new file mode 100644 index 0000000..34ac4d0 --- /dev/null +++ b/ui/detail/src/main/java/com/example/detail/nav/DetailNav.kt @@ -0,0 +1,29 @@ +package com.example.detail.nav + +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.* +import androidx.navigation.compose.composable +import com.example.detail.DetailScreen + +const val detailRoute = "detail_route" + +@VisibleForTesting +internal const val rateIdArgKey = "rateId" + +internal class DetailArgs(savedStateHandle: SavedStateHandle) { + var rateId: String = savedStateHandle.get(rateIdArgKey).toString() +} + +fun NavController.navigateToDetail(rateId: String, navOptions: NavOptions? = null) { + this.navigate("$detailRoute/$rateId", navOptions) +} + +fun NavGraphBuilder.detailGraph(navController: NavHostController) { + composable( + route = "$detailRoute/{$rateIdArgKey}", + arguments = listOf(navArgument(rateIdArgKey) { type = NavType.StringType }) + ) { + DetailScreen(navController = navController) + } +} \ No newline at end of file diff --git a/ui/detail/src/main/res/values/strings.xml b/ui/detail/src/main/res/values/strings.xml new file mode 100644 index 0000000..a4a55e2 --- /dev/null +++ b/ui/detail/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + Detail + Favorite Icon + \ No newline at end of file diff --git a/ui/detail/src/test/java/com/example/detail/ExampleUnitTest.kt b/ui/detail/src/test/java/com/example/detail/ExampleUnitTest.kt new file mode 100644 index 0000000..b008de4 --- /dev/null +++ b/ui/detail/src/test/java/com/example/detail/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package com.example.detail + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/ui/favorite/.gitignore b/ui/favorite/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/ui/favorite/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/ui/favorite/build.gradle.kts b/ui/favorite/build.gradle.kts new file mode 100644 index 0000000..8fb1dc6 --- /dev/null +++ b/ui/favorite/build.gradle.kts @@ -0,0 +1,51 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + kotlin("kapt") + id("com.google.dagger.hilt.android") +} +android { + namespace = "com.example.ui.favorite" + compileSdk = AppConfig.compileSdk + + defaultConfig { + minSdk = AppConfig.minSdk + targetSdk = AppConfig.targetSdk + + testInstrumentationRunner = AppConfig.androidTestInstrumentation + } + + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { jvmTarget = "1.8" } + + buildFeatures { compose = true } + composeOptions { kotlinCompilerExtensionVersion = Version.KOTLIN_COMPILER_EXTENSION_VERSION } +} + +dependencies { + + compose() + composeNavigation() + composeViewModel() + composeMaterial() + hilt() + junit4() + + moduleDependency(":data:favorite") + moduleDependency(":ui:detail") + moduleDependency(":ui:common") + moduleDependency(":data:common") +} \ No newline at end of file diff --git a/ui/favorite/consumer-rules.pro b/ui/favorite/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/ui/favorite/proguard-rules.pro b/ui/favorite/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/ui/favorite/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/ui/favorite/src/androidTest/java/com/example/favorite/ExampleInstrumentedTest.kt b/ui/favorite/src/androidTest/java/com/example/favorite/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..0ccd38b --- /dev/null +++ b/ui/favorite/src/androidTest/java/com/example/favorite/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.favorite + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.favorite.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/ui/favorite/src/main/AndroidManifest.xml b/ui/favorite/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/ui/favorite/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/ui/favorite/src/main/java/com/example/favorite/FavoriteScreen.kt b/ui/favorite/src/main/java/com/example/favorite/FavoriteScreen.kt new file mode 100644 index 0000000..1c8d540 --- /dev/null +++ b/ui/favorite/src/main/java/com/example/favorite/FavoriteScreen.kt @@ -0,0 +1,137 @@ +package com.example.favorite + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import com.example.data.common.model.ExchangeRate +import com.example.detail.nav.navigateToDetail +import com.example.ui.common.component.BaseLazyColumn +import com.example.ui.common.component.cell.RateShimmerCell +import com.example.ui.common.component.cell.toCell +import com.example.ui.common.component.icon.AppIcons +import com.example.ui.common.component.screen.TopBarScaffold +import com.example.ui.common.component.view.AutoRetryView +import com.example.ui.common.component.view.EmptyView +import com.example.ui.common.component.view.RetryView +import com.example.ui.common.component.view.ShimmerView +import com.example.ui.common.ext.create +import com.example.ui.favorite.R + +@Composable +fun FavoriteScreen( + modifier: Modifier = Modifier, + navController: NavController, + viewModel: FavoriteViewModel = hiltViewModel() +) { + FavoriteScreenContent( + modifier = modifier, + uiState = viewModel.state.value, + navController = navController, + onRetry = { viewModel.onEvent(FavoriteUiEvent.Retry) }, + onFavoriteClick = { rate -> viewModel.onEvent(FavoriteUiEvent.OnFavorite(rate)) } + ) +} + +@Composable +private fun FavoriteScreenContent( + modifier: Modifier = Modifier, + uiState: FavoriteUiState, + navController: NavController, + onRetry: () -> Unit, + onFavoriteClick: (rate: ExchangeRate) -> Unit +) { + TopBarScaffold( + title = stringResource(id = R.string.favorite), + ) { padding -> + + FavoriteShimmerView(modifier = modifier.padding(padding), isVisible = uiState.isLoading) + + EmptyView( + modifier = Modifier, isVisible = uiState.isEmpty, + icon = AppIcons.FavoriteBorder, + message = stringResource(id = R.string.noFavoriteItemFound) + ) + + + RetryView( + isVisible = uiState.isRetry, + retryMessage = uiState.retryMsg, + icon = AppIcons.Warning, + onRetry = onRetry + ) + + AutoRetryView( + isVisible = uiState.isAutoRetry, + errorMessage = uiState.autoRetryMsg, + icon = AppIcons.Warning, + hint = stringResource(id = R.string.autoRetryHint) + ) + + + DataView( + isVisible = uiState.isLoaded, + modifier = Modifier + .fillMaxSize() + .padding(padding), + rates = uiState.rates, + navigateToDetail = { rateId -> navController.navigateToDetail(rateId) }, + onFavoriteClick = onFavoriteClick + ) + } +} + +@Composable +fun DataView( + modifier: Modifier = Modifier, + isVisible: Boolean, + rates: List, + navigateToDetail: (id: String) -> Unit, + onFavoriteClick: (rate: ExchangeRate) -> Unit +) { + val models = rates.map { + it.toCell( + navigateToDetail = navigateToDetail, + onFavoriteClick = onFavoriteClick + ) + } + BaseLazyColumn( + modifier = modifier.background(MaterialTheme.colorScheme.surface), + isVisible = isVisible, + models = models + ) +} + +@Composable +fun FavoriteShimmerView( + modifier: Modifier = Modifier, + isVisible: Boolean +) { + ShimmerView( + modifier = modifier, + isVisible = isVisible, + ) { shimmerAxis -> + BaseLazyColumn( + isVisible = true, + models = create(count = 5) { { RateShimmerCell(shimmerAxis = shimmerAxis) } } + ) + } +} + +@Preview +@Composable +fun FavoriteScreenPreview() { + FavoriteScreenContent( + uiState = FavoriteUiState.Loading, + navController = rememberNavController(), + onRetry = {}, + onFavoriteClick = {} + ) +} \ No newline at end of file diff --git a/ui/favorite/src/main/java/com/example/favorite/FavoriteViewModel.kt b/ui/favorite/src/main/java/com/example/favorite/FavoriteViewModel.kt new file mode 100644 index 0000000..c19dc7a --- /dev/null +++ b/ui/favorite/src/main/java/com/example/favorite/FavoriteViewModel.kt @@ -0,0 +1,103 @@ +package com.example.favorite + +import androidx.lifecycle.viewModelScope +import com.example.ui.common.BaseViewModel +import com.example.ui.common.UIEvent +import com.example.ui.common.UIState +import com.example.ui.common.ext.retryWithPolicy +import com.example.data.common.model.ExchangeRate +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class FavoriteViewModel @Inject constructor( + private val favoriteRatesInteractor: FavoriteRatesInteractor +) : BaseViewModel(FavoriteUiState.Loading) { + init { + fetchFavoriteRates() + } + + private fun fetchFavoriteRates() { + viewModelScope.launch { + favoriteRatesInteractor.getLiveFavoriteRates() + .retryWithPolicy { e -> handleRetry(e) } + .catch { e -> handleError(e) } + .onEach { result -> + when { + result.isEmpty() -> setState(FavoriteUiState.Empty()) + else -> setState(FavoriteUiState.Loaded(rates = result)) + } + }.launchIn(viewModelScope) + } + } + + private fun handleFavorite(rate: ExchangeRate) { + viewModelScope.launch { + val favoriteRates = favoriteRatesInteractor.getFavoriteRates().firstOrNull() + if (favoriteRates.isNullOrEmpty()) { + favoriteRatesInteractor.addFavorite(rate) + } else { + if (favoriteRates.any { it.id == rate.id }) { + favoriteRatesInteractor.removeFavorite(rate) + } else { + favoriteRatesInteractor.addFavorite(rate) + } + } + } + } + + + private fun handleError(e: Throwable) { + val errorMessage = e.message ?: "Error while fetching the exchange rates" + setState(FavoriteUiState.Retry(retryMsg = errorMessage)) + } + + private fun handleRetry(e: Throwable) { + val retryMsg = e.message ?: "Loading data is failed" + setState(FavoriteUiState.AutoRetry(retryMsg)) + } + + override fun onEvent(event: FavoriteUiEvent) { + when (event) { + FavoriteUiEvent.Retry -> { + fetchFavoriteRates() + setState(FavoriteUiState.Loading) + } + is FavoriteUiEvent.OnFavorite -> handleFavorite(event.rate) + } + } +} + +sealed interface FavoriteUiEvent : UIEvent { + object Retry : FavoriteUiEvent + class OnFavorite(val rate: ExchangeRate) : FavoriteUiEvent +} + +sealed class FavoriteUiState( + val isLoading: Boolean = false, + val rates: List = emptyList(), + val isRetry: Boolean = false, + val retryMsg: String = "", + val isAutoRetry: Boolean = false, + val autoRetryMsg: String = "", + val isEmpty: Boolean = false, + val isLoaded: Boolean = false, +) : UIState { + + object Loading : FavoriteUiState(isLoading = true) + + class Retry(retryMsg: String) : FavoriteUiState(isRetry = true, retryMsg = retryMsg) + + class AutoRetry(autoRetryMsg: String) : + FavoriteUiState(isAutoRetry = true, autoRetryMsg = autoRetryMsg) + + class Loaded(rates: List) : FavoriteUiState(isLoaded = true, rates = rates) + + class Empty() : FavoriteUiState(isEmpty = true) + +} diff --git a/ui/favorite/src/main/java/com/example/favorite/nav/FavoriteNav.kt b/ui/favorite/src/main/java/com/example/favorite/nav/FavoriteNav.kt new file mode 100644 index 0000000..f17eea7 --- /dev/null +++ b/ui/favorite/src/main/java/com/example/favorite/nav/FavoriteNav.kt @@ -0,0 +1,20 @@ +package com.example.favorite.nav + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.example.favorite.FavoriteScreen + +const val favoriteRoute = "favorite_route" + +fun NavController.navigateToFavorite(navOptions: NavOptions? = null) { + this.navigate(favoriteRoute, navOptions) +} + +fun NavGraphBuilder.favoriteGraph(navController: NavHostController) { + composable(route = favoriteRoute) { + FavoriteScreen(navController = navController) + } +} \ No newline at end of file diff --git a/ui/favorite/src/main/res/values/strings.xml b/ui/favorite/src/main/res/values/strings.xml new file mode 100644 index 0000000..9368911 --- /dev/null +++ b/ui/favorite/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + Favorite + Go online to try again + No Favorite Item was found! + \ No newline at end of file diff --git a/ui/favorite/src/test/java/com/example/favorite/ExampleUnitTest.kt b/ui/favorite/src/test/java/com/example/favorite/ExampleUnitTest.kt new file mode 100644 index 0000000..e5f6c3a --- /dev/null +++ b/ui/favorite/src/test/java/com/example/favorite/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.example.favorite + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/ui/home/.gitignore b/ui/home/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/ui/home/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/ui/home/build.gradle.kts b/ui/home/build.gradle.kts new file mode 100644 index 0000000..b54a344 --- /dev/null +++ b/ui/home/build.gradle.kts @@ -0,0 +1,70 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + kotlin("kapt") + id("com.google.dagger.hilt.android") + id("de.mannodermaus.android-junit5") +} + +android { + namespace = "com.example.ui.home" + compileSdk = AppConfig.compileSdk + + defaultConfig { + minSdk = AppConfig.minSdk + targetSdk = AppConfig.targetSdk + + testInstrumentationRunner = AppConfig.appCustomTestRunner + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = Version.KOTLIN_COMPILER_EXTENSION_VERSION + } +} + +dependencies { + compose() + composeNavigation() + composeViewModel() + composeMaterial() + + coroutines() + + junit5() + junit4() + androidXTest() + espresso() + mockito() + composeTest() + + composeConstraintLayout() + hilt() + hiltTest() + + moduleDependency(":data:exchangerate") + moduleDependency(":data:common") + moduleDependency(":data:favorite") + moduleDependency(":ui:detail") + moduleDependency(":ui:search") + moduleDependency(":ui:common") + testImplementation(project(":ui:common")) +} \ No newline at end of file diff --git a/ui/home/consumer-rules.pro b/ui/home/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/ui/home/proguard-rules.pro b/ui/home/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/ui/home/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/ui/home/src/androidTest/java/com/example/home/HomeScreenTest.kt b/ui/home/src/androidTest/java/com/example/home/HomeScreenTest.kt new file mode 100644 index 0000000..d18a69e --- /dev/null +++ b/ui/home/src/androidTest/java/com/example/home/HomeScreenTest.kt @@ -0,0 +1,85 @@ +package com.example.home + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.navigation.compose.rememberNavController +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.example.ui.common.test.TestTag +import com.example.ui.common.test.getString +import com.example.ui.common.test.logTree +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import com.example.ui.common.R.string as commonString + +@RunWith(AndroidJUnit4::class) +internal class HomeScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun test_homeScreen_loading_state() { + with(composeTestRule) { + setContent { + HomeScreenContent( + navController = rememberNavController(), + uiState = HomeUiState.Loading + ) + } + onNode(hasTestTag(TestTag.LOADING)).assertIsDisplayed() + } + } + + @Test + fun test_homeScreen_retry_state() { + with(composeTestRule) { + setContent { + HomeScreenContent( + navController = rememberNavController(), + uiState = HomeUiState.Retry() + ) + } + logTree() + onNodeWithText(getString(commonString.retry)).assertIsDisplayed() + onNodeWithContentDescription(getString(commonString.warningIconDescription)).assertIsDisplayed() + onNodeWithText(getString(commonString.defaultErrorHint)).assertIsDisplayed() + } + } + + @Test + fun test_homeScreen_autoRetry_state() { + with(composeTestRule) { + setContent { + HomeScreenContent( + navController = rememberNavController(), + uiState = HomeUiState.AutoRetry() + ) + } + logTree() + onNodeWithText(getString(commonString.autoRetryHint)).assertIsDisplayed() + onNodeWithContentDescription(getString(commonString.warningIconDescription)).assertIsDisplayed() + onNodeWithText(getString(commonString.defaultErrorHint)).assertIsDisplayed() + } + } + + @Test + fun test_homeScreen_data_state() { + with(composeTestRule) { + val rates = exchangeRatesStub() + setContent { + HomeScreenContent( + navController = rememberNavController(), + uiState = HomeUiState.Loaded(rates = rates, favoriteRates = rates.subList(0, 3)) + ) + } + logTree() + onNodeWithText(getString(commonString.autoRetryHint)).assertDoesNotExist() + onNode(hasScrollToIndexAction()).assertIsDisplayed() + onNode(hasScrollAction()).performScrollToIndex(rates.size - 1) + onAllNodesWithContentDescription(getString(commonString.favoriteIconDescription)) + .assertCountEquals(rates.size) + } + } +} diff --git a/ui/home/src/androidTest/java/com/example/home/Stubs.kt b/ui/home/src/androidTest/java/com/example/home/Stubs.kt new file mode 100644 index 0000000..a62e2ac --- /dev/null +++ b/ui/home/src/androidTest/java/com/example/home/Stubs.kt @@ -0,0 +1,21 @@ +package com.example.home + +import com.example.data.common.ext.RandomString +import com.example.data.common.model.ExchangeRate +import kotlin.random.Random + +fun exchangeRatesStub(count: Int = 10): List { + val result = mutableListOf() + repeat(count) { + result.add(exchangeRateStub()) + } + return result +} + +fun exchangeRateStub() = ExchangeRate( + id = Random.nextInt().toString(), + symbol = RandomString.next(), + currencySymbol = RandomString.next(), + type = RandomString.next(), + rateUsd = Random.nextLong().toBigDecimal() +) \ No newline at end of file diff --git a/ui/home/src/main/AndroidManifest.xml b/ui/home/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/ui/home/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/ui/home/src/main/java/com/example/home/HomeScreen.kt b/ui/home/src/main/java/com/example/home/HomeScreen.kt new file mode 100644 index 0000000..1c939c7 --- /dev/null +++ b/ui/home/src/main/java/com/example/home/HomeScreen.kt @@ -0,0 +1,150 @@ +package com.example.home + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavHostController +import com.example.data.common.model.ExchangeRate +import com.example.detail.nav.navigateToDetail +import com.example.search.nav.navigateToSearch +import com.example.ui.common.ReferenceDevices +import com.example.ui.common.component.BaseLazyColumn +import com.example.ui.common.component.cell.RateShimmerCell +import com.example.ui.common.component.cell.toCell +import com.example.ui.common.component.icon.AppIcons +import com.example.ui.common.component.screen.TopBarScaffold +import com.example.ui.common.component.view.AutoRetryView +import com.example.ui.common.component.view.RetryView +import com.example.ui.common.component.view.ShimmerView +import com.example.ui.common.ext.create +import com.example.ui.home.R + +@Composable +fun HomeScreen( + modifier: Modifier = Modifier, + navController: NavHostController, + viewModel: HomeViewModel = hiltViewModel(), +) { + HomeScreenContent( + modifier = modifier, + navController = navController, + uiState = viewModel.state.value, + onRetry = { viewModel.onEvent(HomeUiEvent.Retry) }, + onFavoriteClick = { rate -> viewModel.onEvent(HomeUiEvent.OnFavorite(rate)) } + ) +} + +@Composable +fun HomeScreenContent( + modifier: Modifier = Modifier, + navController: NavHostController, + uiState: HomeUiState, + onRetry: () -> Unit = {}, + onFavoriteClick: (rate: ExchangeRate) -> Unit = {}, +) { + TopBarScaffold( + title = stringResource(id = R.string.home), + navigationIcon = AppIcons.Menu, + navigationIconContentDescription = stringResource(id = R.string.menu), + actionIcon = AppIcons.Search, + actionIconContentDescription = stringResource(id = R.string.searchIconContentDescription), + onActionClick = { navController.navigateToSearch() } + ) { padding -> + + HomeShimmerView( + modifier = modifier.padding(padding), + isVisible = uiState.isLoading + ) + + RetryView( + isVisible = uiState.isRetry, + retryMessage = uiState.retryMsg, + icon = AppIcons.Warning, + onRetry = onRetry + ) + + AutoRetryView( + isVisible = uiState.isAutoRetry, + errorMessage = uiState.autoRetryMsg, + icon = AppIcons.Warning, + ) + + DataView( + isVisible = uiState.isLoaded, + modifier = modifier.padding(padding), + rates = uiState.rates, + favoritesRates = uiState.favoriteRates, + navigateToDetail = { rateId -> navController.navigateToDetail(rateId) }, + onFavoriteClick = onFavoriteClick + ) + } +} + +@Composable +private fun HomeShimmerView( + modifier: Modifier = Modifier, + isVisible: Boolean, +) { + ShimmerView( + modifier = modifier, + isVisible = isVisible, + ) { shimmerAxis -> + BaseLazyColumn( + isVisible = true, + models = create(count = 6) { { RateShimmerCell(shimmerAxis = shimmerAxis) } } + ) + } +} + +@Composable +private fun DataView( + modifier: Modifier, + isVisible: Boolean, + rates: List, + favoritesRates: List, + navigateToDetail: (id: String) -> Unit, + onFavoriteClick: (rate: ExchangeRate) -> Unit +) { + val models = rates.map { + it.toCell( + favoritesRates = favoritesRates, + navigateToDetail = navigateToDetail, + onFavoriteClick = onFavoriteClick + ) + } + BaseLazyColumn( + modifier = modifier.background(MaterialTheme.colorScheme.surface), + isVisible = isVisible, + models = models + ) +} + + +@Composable +@Preview +fun HomeShimmerPreview() { + HomeShimmerView(isVisible = true) +} + +@Composable +@ReferenceDevices +fun DataPreview() = DataView( + modifier = Modifier, + rates = create(20) { rateStub() }, + favoritesRates = create(10) { rateStub() }, + isVisible = true, + navigateToDetail = {} +) {} + +internal fun rateStub(): ExchangeRate = ExchangeRate( + id = "1", + symbol = "$", + currencySymbol = "USD", + type = "fiat", + rateUsd = 0.165451654889.toBigDecimal() +) \ No newline at end of file diff --git a/ui/home/src/main/java/com/example/home/HomeViewModel.kt b/ui/home/src/main/java/com/example/home/HomeViewModel.kt new file mode 100644 index 0000000..cd9ae67 --- /dev/null +++ b/ui/home/src/main/java/com/example/home/HomeViewModel.kt @@ -0,0 +1,101 @@ +package com.example.home + +import androidx.lifecycle.viewModelScope +import com.example.data.common.model.ExchangeRate +import com.example.favorite.FavoriteRatesInteractor +import com.example.home.util.Constant +import com.example.rate.ExchangeRateInteractor +import com.example.ui.common.BaseViewModel +import com.example.ui.common.UIEvent +import com.example.ui.common.UIState +import com.example.ui.common.connectivity.ConnectivityMonitor +import com.example.ui.common.ext.retryOnNetworkConnection +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * @author yaya (@yahyalmh) + * @since 05th November 2022 + */ + +@HiltViewModel +open class HomeViewModel @Inject constructor( + private val exchangeRateInteractor: ExchangeRateInteractor, + private val favoriteRatesInteractor: FavoriteRatesInteractor, + private val connectivityMonitor: ConnectivityMonitor, +) : BaseViewModel(HomeUiState.Loading) { + + init { + fetchRates() + } + + private fun fetchRates() { + combine( + exchangeRateInteractor.getLiveRates(Constant.liveRateFetchInterval), + favoriteRatesInteractor.getFavoriteRates() + ) { rates, favoriteRates -> + setState(HomeUiState.Loaded(rates = rates, favoriteRates = favoriteRates)) + } + .retryOnNetworkConnection(connectivityMonitor) { e -> handleAutoRetry(e) } + .catch { e -> handleRetry(e) } + .launchIn(viewModelScope) + } + + private fun handleFavorite(rate: ExchangeRate) { + viewModelScope.launch { + val favoriteRates = favoriteRatesInteractor.getFavoriteRates().firstOrNull() + if (favoriteRates.isNullOrEmpty()) { + favoriteRatesInteractor.addFavorite(rate) + } else { + if (favoriteRates.any { it.id == rate.id }) { + favoriteRatesInteractor.removeFavorite(rate) + } else { + favoriteRatesInteractor.addFavorite(rate) + } + } + } + } + + private fun handleRetry(e: Throwable) = setState(HomeUiState.Retry(retryMsg = e.message)) + private fun handleAutoRetry(e: Throwable) = setState(HomeUiState.AutoRetry(e.message)) + override fun onEvent(event: HomeUiEvent) { + when (event) { + HomeUiEvent.Retry -> { + setState(HomeUiState.Loading) + fetchRates() + } + is HomeUiEvent.OnFavorite -> handleFavorite(event.rate) + } + } +} + +sealed class HomeUiState( + val rates: List = emptyList(), + val favoriteRates: List = emptyList(), + val isLoading: Boolean = false, + val isRetry: Boolean = false, + val retryMsg: String? = null, + val isAutoRetry: Boolean = false, + val autoRetryMsg: String? = null, + val isLoaded: Boolean = false +) : UIState { + object Loading : HomeUiState(isLoading = true) + + class Retry(retryMsg: String? = null) : HomeUiState(isRetry = true, retryMsg = retryMsg) + + class AutoRetry(autoRetryMsg: String? = null) : + HomeUiState(isAutoRetry = true, autoRetryMsg = autoRetryMsg) + + class Loaded(rates: List, favoriteRates: List) : + HomeUiState(isLoaded = true, rates = rates, favoriteRates = favoriteRates) +} + +sealed interface HomeUiEvent : UIEvent { + object Retry : HomeUiEvent + class OnFavorite(val rate: ExchangeRate) : HomeUiEvent +} \ No newline at end of file diff --git a/ui/home/src/main/java/com/example/home/nav/HomeNav.kt b/ui/home/src/main/java/com/example/home/nav/HomeNav.kt new file mode 100644 index 0000000..f3a709e --- /dev/null +++ b/ui/home/src/main/java/com/example/home/nav/HomeNav.kt @@ -0,0 +1,26 @@ +package com.example.home.nav + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.example.home.HomeScreen + +/** + * @author yaya (@yahyalmh) + * @since 05th November 2022 + */ + + +const val homeRoute = "home_route" + +fun NavController.navigateToHome(navOptions: NavOptions? = null) { + this.navigate(homeRoute, navOptions) +} + +fun NavGraphBuilder.homeGraph(navController: NavHostController) { + composable(route = homeRoute) { + HomeScreen(navController = navController) + } +} \ No newline at end of file diff --git a/ui/home/src/main/java/com/example/home/util/Constant.kt b/ui/home/src/main/java/com/example/home/util/Constant.kt new file mode 100644 index 0000000..d03ed53 --- /dev/null +++ b/ui/home/src/main/java/com/example/home/util/Constant.kt @@ -0,0 +1,5 @@ +package com.example.home.util + +object Constant { + const val liveRateFetchInterval = 3000L +} \ No newline at end of file diff --git a/ui/home/src/main/res/values/strings.xml b/ui/home/src/main/res/values/strings.xml new file mode 100644 index 0000000..1918e96 --- /dev/null +++ b/ui/home/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + Home + Search Icon + Menu + \ No newline at end of file diff --git a/ui/home/src/test/java/com/example/home/HomeViewModelTest.kt b/ui/home/src/test/java/com/example/home/HomeViewModelTest.kt new file mode 100644 index 0000000..c2b89d5 --- /dev/null +++ b/ui/home/src/test/java/com/example/home/HomeViewModelTest.kt @@ -0,0 +1,162 @@ +package com.example.home + +import com.example.data.common.model.ExchangeRate +import com.example.ui.common.test.MainDispatcherRule +import com.example.ui.common.test.thenEmitError +import com.example.ui.common.test.thenEmitNothing +import com.example.favorite.FavoriteRatesInteractorImpl +import com.example.home.util.Constant +import com.example.rate.ExchangeRateInteractorImpl +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(MockitoExtension::class, MainDispatcherRule::class) +internal class HomeViewModelTest { + + @get:Rule + val dispatcherRule = MainDispatcherRule() + + @Mock + lateinit var exchangeRateInteractor: ExchangeRateInteractorImpl + + @Mock + lateinit var favoriteRatesInteractor: FavoriteRatesInteractorImpl + private lateinit var homeViewModel: HomeViewModel + + private lateinit var exchangeRates: List + private lateinit var favoriteRates: MutableList + + @BeforeEach + fun setup() { + exchangeRates = exchangeRatesStub() + favoriteRates = mutableListOf(exchangeRates.first()) + } + + + @Test + fun `WHEN fetching rates return data THEN ui state is Loaded`() = runTest { + whenever(exchangeRateInteractor.getLiveRates(Constant.liveRateFetchInterval)).thenReturn( + flowOf(exchangeRates) + ) + whenever(favoriteRatesInteractor.getFavoriteRates()).thenReturn(flowOf(favoriteRates)) + + homeViewModel = HomeViewModel(exchangeRateInteractor, favoriteRatesInteractor) + val uiState = homeViewModel.state.value + + Assertions.assertTrue(uiState is HomeUiState.Loaded) + Assertions.assertEquals(exchangeRates, uiState.rates) + Assertions.assertEquals(favoriteRates, uiState.favoriteRates) + Assertions.assertFalse(uiState.isLoading) + } + + @Test + fun `WHEN fetching rates return error THEN ui state is AutoRetry`() = + runTest { + whenever(favoriteRatesInteractor.getFavoriteRates()).thenReturn(flowOf(favoriteRates)) + whenever(exchangeRateInteractor.getLiveRates(Constant.liveRateFetchInterval)) + .thenReturn(flow { + if (currentTime < 3000) { + throw IOException() + } else { + emit(exchangeRates) + } + }) + + homeViewModel = HomeViewModel(exchangeRateInteractor, favoriteRatesInteractor) + Assertions.assertTrue(homeViewModel.state.value is HomeUiState.AutoRetry) + } + + @Test + fun `WHEN fetching rates return error THEN ui state is AutoRetry THEN ui state is Loaded`() = + runTest { + whenever(favoriteRatesInteractor.getFavoriteRates()).thenReturn(flowOf(favoriteRates)) + whenever(exchangeRateInteractor.getLiveRates(Constant.liveRateFetchInterval)) + .thenReturn(flow { + if (currentTime < 3000) { + throw IOException() + } else { + emit(exchangeRates) + } + }) + + homeViewModel = HomeViewModel(exchangeRateInteractor, favoriteRatesInteractor) + Assertions.assertTrue(homeViewModel.state.value is HomeUiState.AutoRetry) + + advanceTimeBy(4000) + advanceUntilIdle() + Assertions.assertTrue(homeViewModel.state.value is HomeUiState.Loaded) + } + + + @Test + fun `WHEN fetching rates return error THEN after while ui state is Retry`() = runTest { + whenever(exchangeRateInteractor.getLiveRates(Constant.liveRateFetchInterval)) + .thenEmitError(IOException()) + whenever(favoriteRatesInteractor.getFavoriteRates()).thenEmitNothing() + + homeViewModel = HomeViewModel(exchangeRateInteractor, favoriteRatesInteractor) + Assertions.assertTrue(homeViewModel.state.value is HomeUiState.AutoRetry) + + advanceUntilIdle() + Assertions.assertTrue(homeViewModel.state.value is HomeUiState.Retry) + } + + + @Test + fun `GIVEN retry event THEN data load successfully`() = runTest { + whenever(exchangeRateInteractor.getLiveRates(Constant.liveRateFetchInterval)) + .thenEmitError(IOException()) + + homeViewModel = HomeViewModel(exchangeRateInteractor, favoriteRatesInteractor) + advanceUntilIdle() + Assertions.assertTrue(homeViewModel.state.value is HomeUiState.Retry) + + whenever(exchangeRateInteractor.getLiveRates(Constant.liveRateFetchInterval)).thenReturn( + flowOf(exchangeRates) + ) + whenever(favoriteRatesInteractor.getFavoriteRates()).thenReturn(flowOf(favoriteRates)) + homeViewModel.onEvent(HomeUiEvent.Retry) + + advanceUntilIdle() + Assertions.assertTrue(homeViewModel.state.value is HomeUiState.Loaded) + } + + @Test + fun `GIVEN favorite event THEN item added or removed from favorites`() = runTest { + whenever(exchangeRateInteractor.getLiveRates(Constant.liveRateFetchInterval)).thenReturn( + flowOf(exchangeRates) + ) + whenever(favoriteRatesInteractor.getFavoriteRates()).thenReturn(flowOf(favoriteRates)) + + homeViewModel = HomeViewModel(exchangeRateInteractor, favoriteRatesInteractor) + advanceUntilIdle() + Assertions.assertTrue(homeViewModel.state.value is HomeUiState.Loaded) + + homeViewModel.onEvent(HomeUiEvent.OnFavorite(exchangeRates.last())) + advanceUntilIdle() + + verify(favoriteRatesInteractor).addFavorite(exchangeRates.last()) + favoriteRates.add(exchangeRates.last()) + + homeViewModel.onEvent(HomeUiEvent.OnFavorite(exchangeRates.last())) + advanceUntilIdle() + + verify(favoriteRatesInteractor).removeFavorite(exchangeRates.last()) + } +} \ No newline at end of file diff --git a/ui/home/src/test/java/com/example/home/Stubs.kt b/ui/home/src/test/java/com/example/home/Stubs.kt new file mode 100644 index 0000000..a62e2ac --- /dev/null +++ b/ui/home/src/test/java/com/example/home/Stubs.kt @@ -0,0 +1,21 @@ +package com.example.home + +import com.example.data.common.ext.RandomString +import com.example.data.common.model.ExchangeRate +import kotlin.random.Random + +fun exchangeRatesStub(count: Int = 10): List { + val result = mutableListOf() + repeat(count) { + result.add(exchangeRateStub()) + } + return result +} + +fun exchangeRateStub() = ExchangeRate( + id = Random.nextInt().toString(), + symbol = RandomString.next(), + currencySymbol = RandomString.next(), + type = RandomString.next(), + rateUsd = Random.nextLong().toBigDecimal() +) \ No newline at end of file diff --git a/ui/main/.gitignore b/ui/main/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/ui/main/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/ui/main/build.gradle.kts b/ui/main/build.gradle.kts new file mode 100644 index 0000000..29d6320 --- /dev/null +++ b/ui/main/build.gradle.kts @@ -0,0 +1,63 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + kotlin("kapt") + id("com.google.dagger.hilt.android") +} + +android { + namespace = "com.example.ui.main" + compileSdk = AppConfig.compileSdk + + defaultConfig { + minSdk = AppConfig.minSdk + targetSdk = AppConfig.targetSdk + + testInstrumentationRunner = AppConfig.androidTestInstrumentation + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = Version.KOTLIN_COMPILER_EXTENSION_VERSION + } +} + +dependencies { + compose() + composeNavigation() + composeViewModel() + composeMaterial() + + junit4() + composeTest() + + composeConstraintLayout() + hilt() + hiltTest() + + moduleDependency(":ui:common") + moduleDependency(":ui:home") + moduleDependency(":ui:detail") + moduleDependency(":ui:search") + moduleDependency(":ui:favorite") + moduleDependency(":ui:setting") + moduleDependency(":data:datastore") +} \ No newline at end of file diff --git a/ui/main/consumer-rules.pro b/ui/main/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/ui/main/proguard-rules.pro b/ui/main/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/ui/main/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/ui/main/src/androidTest/java/com/example/main/ExampleInstrumentedTest.kt b/ui/main/src/androidTest/java/com/example/main/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..fd95368 --- /dev/null +++ b/ui/main/src/androidTest/java/com/example/main/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package com.example.main + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.main.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/ui/main/src/main/AndroidManifest.xml b/ui/main/src/main/AndroidManifest.xml new file mode 100644 index 0000000..69fc412 --- /dev/null +++ b/ui/main/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/ui/main/src/main/java/com/example/main/MainScreen.kt b/ui/main/src/main/java/com/example/main/MainScreen.kt new file mode 100644 index 0000000..323bbdb --- /dev/null +++ b/ui/main/src/main/java/com/example/main/MainScreen.kt @@ -0,0 +1,176 @@ +package com.example.main + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.material.Scaffold +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavHostController +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.example.compose.nav.AppNavHost +import com.example.favorite.nav.favoriteRoute +import com.example.home.nav.homeRoute +import com.example.main.theme.AppTheme +import com.example.setting.nav.settingRoute +import com.example.ui.common.ThemeType +import com.example.ui.common.component.bar.BottomAppBar +import com.example.ui.common.component.bar.BottomBarTab +import com.example.ui.common.component.bar.ConnectivityStatusView +import com.example.ui.main.R + +/** + * @author yaya (@yahyalmh) + * @since 29th October 2022 + */ +@Composable +fun MainScreen( + modifier: Modifier = Modifier, + navController: NavHostController, + viewModel: MainViewModel = hiltViewModel() +) { + val uiState = viewModel.state.value + + val useDarkTheme = shouldUseDarkTheme(uiState.themeType) + AppTheme(useDarkTheme = useDarkTheme) { + ContentView( + modifier = modifier, + uiState = uiState, + navController = navController, + bottomBarTabs = bottomBarTabs() + ) { tab -> + viewModel.onEvent(MainUiEvent.ChangeTab(navController, tab)) + } + } +} + +@Composable +private fun ContentView( + modifier: Modifier = Modifier, + navController: NavHostController, + uiState: MainUiState, + bottomBarTabs: List, + onNavigateToDestination: (BottomBarTab) -> Unit +) { + Column { + ConnectivityStatusView( + modifier = modifier, + isOnlineViewVisible = uiState.isOnlineViewVisible, + isOfflineViewVisible = uiState.isOfflineViewVisible + ) + + Scaffold( + modifier = modifier.fillMaxSize(), + contentColor = MaterialTheme.colorScheme.surface, + bottomBar = { + AnimatedVisibility(visible = uiState.isBottomBarVisible) { + BottomAppBar( + modifier = Modifier.testTag(TestTag.BOTTOM_BAR), + tabs = bottomBarTabs, + onNavigateToDestination = onNavigateToDestination, + currentDestination = navController.currentBackStackEntryAsState().value?.destination + ) + } + } + ) { paddingValues -> + SetupAppNavHost(navController, paddingValues) + } + } +} + +@Composable +fun bottomBarTabs() = listOf( + BottomBarTab( + title = stringResource(id = R.string.home), + route = homeRoute, + selectedIcon = Icons.Default.Home, + unselectedIcon = Icons.Default.Home, + contentDescription = stringResource(id = R.string.homeTabContentDescription) + ), + + BottomBarTab( + title = stringResource(id = R.string.favorite), + route = favoriteRoute, + selectedIcon = Icons.Default.Favorite, + unselectedIcon = Icons.Default.FavoriteBorder, + contentDescription = stringResource(id = R.string.favoriteTabContentDescription) + ), + + BottomBarTab( + title = stringResource(id = R.string.setting), + route = settingRoute, + selectedIcon = Icons.Default.Settings, + unselectedIcon = Icons.Default.Settings, + contentDescription = stringResource(id = R.string.settingTabContentDescription) + ) +) + +@Composable +@OptIn(ExperimentalLayoutApi::class) +private fun SetupAppNavHost( + navHostController: NavHostController, + padding: PaddingValues = PaddingValues.Absolute() +) { + AppNavHost( + navController = navHostController, + modifier = Modifier + .padding(padding) + .consumedWindowInsets(padding) + ) +} + +@Composable +fun shouldUseDarkTheme(themeType: ThemeType?): Boolean = + when (themeType) { + ThemeType.SYSTEM -> isSystemInDarkTheme() + ThemeType.LIGHT -> false + ThemeType.DARK -> true + else -> isSystemInDarkTheme() + } + +@Composable +@Preview( + showSystemUi = false, + name = "OfflinePreview", + device = Devices.PHONE +) +fun OfflineContentPreview() { + val navController = rememberNavController() + ContentView( + uiState = MainUiState.Offline(), + bottomBarTabs = bottomBarTabs(), + navController = navController, + onNavigateToDestination = {} + ) +} + +@Composable +@Preview( + showSystemUi = false, + name = "OnlinePreview", + device = Devices.PHONE, + uiMode = UI_MODE_NIGHT_YES +) +fun OnlineContentPreview() { + val navController = rememberNavController() + ContentView( + uiState = MainUiState.Online(), + bottomBarTabs = bottomBarTabs(), + navController = navController, + onNavigateToDestination = {} + ) +} + diff --git a/ui/main/src/main/java/com/example/main/MainViewModel.kt b/ui/main/src/main/java/com/example/main/MainViewModel.kt new file mode 100644 index 0000000..cb0296f --- /dev/null +++ b/ui/main/src/main/java/com/example/main/MainViewModel.kt @@ -0,0 +1,180 @@ +package com.example.main + +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavController +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.navOptions +import com.example.datastore.DatastoreInteractor +import com.example.favorite.nav.favoriteRoute +import com.example.favorite.nav.navigateToFavorite +import com.example.home.nav.homeRoute +import com.example.home.nav.navigateToHome +import com.example.setting.nav.navigateToSetting +import com.example.setting.nav.settingRoute +import com.example.ui.common.* +import com.example.ui.common.component.bar.BottomBarTab +import com.example.ui.common.connectivity.ConnectivityMonitor +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MainViewModel @Inject constructor( + private val connectivityMonitor: ConnectivityMonitor, + private val datastoreInteractor: DatastoreInteractor, +) : BaseViewModel( + MainUiState.HideNetStatusView() +) { + private var isAppLaunchedForFirstTime: Boolean = true + + init { + observeAppTheme() + observeBottomBarState() + observeConnectivityState() + } + + private fun observeBottomBarState() { + SharedState.bottomBarVisible.onEach { + setState( + MainUiState.BottomVisibleChange( + state.value.isOnlineViewVisible, + state.value.isOfflineViewVisible, + isBottomBarVisible = it, + themeType = state.value.themeType + ) + ) + }.launchIn(viewModelScope) + } + + private fun observeAppTheme() { + datastoreInteractor + .getThemeType() + .onEach { + val themeType = it?.toThemeType() + setState( + MainUiState.SetAppTheme( + state.value.isOnlineViewVisible, + state.value.isOfflineViewVisible, + themeType + ) + ) + } + .launchIn(viewModelScope) + + } + + private fun observeConnectivityState() { + connectivityMonitor.isOnline + .distinctUntilChanged() + .onEach { isOnline -> + if (isOnline) { + handelOnlineState() + } else { + setState(MainUiState.Offline(state.value.themeType)) + } + }.launchIn(viewModelScope) + } + + private fun handelOnlineState() { + if (isAppLaunchedForFirstTime) { + isAppLaunchedForFirstTime = false + return + } + setState(MainUiState.Online(state.value.themeType)) + hideOnlineViewAfterWhile() + } + + private fun hideOnlineViewAfterWhile() { + val hideOnlineViewDelay: Long = 2000 + viewModelScope.launch { + delay(hideOnlineViewDelay) + setState(MainUiState.HideNetStatusView(state.value.themeType)) + } + } + + override fun onEvent(event: MainUiEvent) { + when (event) { + is MainUiEvent.ChangeTab -> changeBottomBarDestination( + event.navController, + destination = event.destination + ) + } + } + + private fun changeBottomBarDestination( + navController: NavController, + destination: BottomBarTab + ) { + val id = navController.graph.findStartDestination().id + val navOptions = navOptions { + popUpTo(id) { +// saveState = true + } + launchSingleTop = true +// restoreState = true + } + + when (destination.route) { + homeRoute -> navController.navigateToHome(navOptions) + favoriteRoute -> navController.navigateToFavorite(navOptions) + settingRoute -> navController.navigateToSetting(navOptions) + } + } +} + +sealed interface MainUiEvent : UIEvent { + class ChangeTab(val navController: NavController, val destination: BottomBarTab) : MainUiEvent +} + +sealed class MainUiState( + val isOnlineViewVisible: Boolean = false, + val isOfflineViewVisible: Boolean = false, + val themeType: ThemeType? = ThemeType.SYSTEM, + val isBottomBarVisible: Boolean = true, +) : UIState { + + object None : MainUiState(false, false) + + class HideNetStatusView( + themeType: ThemeType? = null + ) : MainUiState( + isOnlineViewVisible = false, + isOfflineViewVisible = false, + themeType = themeType + ) + + class Offline( + themeType: ThemeType? = null + ) : MainUiState(isOfflineViewVisible = true, themeType = themeType) + + class Online( + themeType: ThemeType? = null + ) : MainUiState(isOnlineViewVisible = true, themeType = themeType) + + class BottomVisibleChange( + isOnlineViewVisible: Boolean, + isOfflineViewVisible: Boolean, + isBottomBarVisible: Boolean, + themeType: ThemeType? = null + ) : MainUiState( + isOnlineViewVisible = isOnlineViewVisible, + isOfflineViewVisible = isOfflineViewVisible, + isBottomBarVisible = isBottomBarVisible, + themeType = themeType + ) + + class SetAppTheme( + isOnlineViewVisible: Boolean, + isOfflineViewVisible: Boolean, + themeType: ThemeType? = null + ) : MainUiState( + isOnlineViewVisible = isOnlineViewVisible, + isOfflineViewVisible = isOfflineViewVisible, + themeType = themeType + ) +} + diff --git a/ui/main/src/main/java/com/example/main/TestTag.kt b/ui/main/src/main/java/com/example/main/TestTag.kt new file mode 100644 index 0000000..8c5f3cc --- /dev/null +++ b/ui/main/src/main/java/com/example/main/TestTag.kt @@ -0,0 +1,5 @@ +package com.example.main + +object TestTag { + const val BOTTOM_BAR = "Bottom bar" +} \ No newline at end of file diff --git a/ui/main/src/main/java/com/example/main/nav/AppNav.kt b/ui/main/src/main/java/com/example/main/nav/AppNav.kt new file mode 100644 index 0000000..4fd7dfd --- /dev/null +++ b/ui/main/src/main/java/com/example/main/nav/AppNav.kt @@ -0,0 +1,36 @@ +package com.example.compose.nav + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import com.example.detail.nav.detailGraph +import com.example.favorite.nav.favoriteGraph +import com.example.home.nav.homeGraph +import com.example.home.nav.homeRoute +import com.example.search.nav.searchGraph +import com.example.setting.nav.settingGraph + +/** + * @author yaya (@yahyalmh) + * @since 29th October 2022 + */ +@Composable +fun AppNavHost( + navController: NavHostController, + modifier: Modifier = Modifier, + startDestination: String = homeRoute +) { + + NavHost( + navController = navController, + startDestination = startDestination, + modifier = modifier, + ) { + homeGraph(navController) + detailGraph(navController) + searchGraph(navController) + settingGraph(navController) + favoriteGraph(navController) + } +} \ No newline at end of file diff --git a/ui/main/src/main/java/com/example/main/theme/Color.kt b/ui/main/src/main/java/com/example/main/theme/Color.kt new file mode 100644 index 0000000..f84ca01 --- /dev/null +++ b/ui/main/src/main/java/com/example/main/theme/Color.kt @@ -0,0 +1,69 @@ +package com.example.main.theme +import androidx.compose.ui.graphics.Color + +val md_theme_light_primary = Color(0xFF6750A4) +val md_theme_light_onPrimary = Color(0xFFFFFFFF) +val md_theme_light_primaryContainer = Color(0xFFEADDFF) +val md_theme_light_onPrimaryContainer = Color(0xFF21005D) +val md_theme_light_secondary = Color(0xFF625B71) +val md_theme_light_onSecondary = Color(0xFFFFFFFF) +val md_theme_light_secondaryContainer = Color(0xFFE8DEF8) +val md_theme_light_onSecondaryContainer = Color(0xFF1D192B) +val md_theme_light_tertiary = Color(0xFF7D5260) +val md_theme_light_onTertiary = Color(0xFFFFFFFF) +val md_theme_light_tertiaryContainer = Color(0xFFFFD8E4) +val md_theme_light_onTertiaryContainer = Color(0xFF31111D) +val md_theme_light_error = Color(0xFFB3261E) +val md_theme_light_onError = Color(0xFFFFFFFF) +val md_theme_light_errorContainer = Color(0xFFF9DEDC) +val md_theme_light_onErrorContainer = Color(0xFF410E0B) +val md_theme_light_outline = Color(0xFF79747E) +val md_theme_light_outline_variant = Color(0xFFCAC4D0) +val md_theme_light_background = Color(0xFFFFFBFE) +val md_theme_light_onBackground = Color(0xFF1C1B1F) +val md_theme_light_surface = Color(0xFFFFFBFE) +val md_theme_light_onSurface = Color(0xFF1C1B1F) +val md_theme_light_surfaceVariant = Color(0xFFE7E0EC) +val md_theme_light_onSurfaceVariant = Color(0xFF49454F) +val md_theme_light_inverseSurface = Color(0xFF313033) +val md_theme_light_inverseOnSurface = Color(0xFFF4EFF4) +val md_theme_light_inversePrimary = Color(0xFFD0BCFF) +val md_theme_light_shadow = Color(0xFF000000) +val md_theme_light_surfaceTint = Color(0xFF6750A4) +val md_theme_light_outlineVariant = Color(0xFFCAC4D0) +val md_theme_light_scrim = Color(0xFF000000) + +val md_theme_dark_primary = Color(0xFFD0BCFF) +val md_theme_dark_onPrimary = Color(0xFF381E72) +val md_theme_dark_primaryContainer = Color(0xFF4F378B) +val md_theme_dark_onPrimaryContainer = Color(0xFFEADDFF) +val md_theme_dark_secondary = Color(0xFFCCC2DC) +val md_theme_dark_onSecondary = Color(0xFF332D41) +val md_theme_dark_secondaryContainer = Color(0xFF4A4458) +val md_theme_dark_onSecondaryContainer = Color(0xFFE8DEF8) +val md_theme_dark_tertiary = Color(0xFFEFB8C8) +val md_theme_dark_onTertiary = Color(0xFF492532) +val md_theme_dark_tertiaryContainer = Color(0xFF633B48) +val md_theme_dark_onTertiaryContainer = Color(0xFFFFD8E4) +val md_theme_dark_error = Color(0xFFF2B8B5) +val md_theme_dark_onError = Color(0xFF601410) +val md_theme_dark_errorContainer = Color(0xFF8C1D18) +val md_theme_dark_onErrorContainer = Color(0xFFF9DEDC) +val md_theme_dark_outline = Color(0xFF938F99) +val md_theme_dark_outline_variant = Color(0xFF49454F) +val md_theme_dark_background = Color(0xFF1C1B1F) +val md_theme_dark_onBackground = Color(0xFFE6E1E5) +val md_theme_dark_surface = Color(0xFF1C1B1F) +val md_theme_dark_onSurface = Color(0xFFE6E1E5) +val md_theme_dark_surfaceVariant = Color(0xFF49454F) +val md_theme_dark_onSurfaceVariant = Color(0xFFCAC4D0) +val md_theme_dark_inverseSurface = Color(0xFFE6E1E5) +val md_theme_dark_inverseOnSurface = Color(0xFF313033) +val md_theme_dark_inversePrimary = Color(0xFF6750A4) +val md_theme_dark_shadow = Color(0xFF000000) +val md_theme_dark_surfaceTint = Color(0xFFD0BCFF) +val md_theme_dark_outlineVariant = Color(0xFF49454F) +val md_theme_dark_scrim = Color(0xFF000000) + + +val seed = Color(0xFF6750A4) diff --git a/ui/main/src/main/java/com/example/main/theme/Shape.kt b/ui/main/src/main/java/com/example/main/theme/Shape.kt new file mode 100644 index 0000000..fb7d4e8 --- /dev/null +++ b/ui/main/src/main/java/com/example/main/theme/Shape.kt @@ -0,0 +1,11 @@ +package com.example.main.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +val Shapes = Shapes( + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(4.dp), + large = RoundedCornerShape(0.dp) +) \ No newline at end of file diff --git a/ui/main/src/main/java/com/example/main/theme/Theme.kt b/ui/main/src/main/java/com/example/main/theme/Theme.kt new file mode 100644 index 0000000..b429429 --- /dev/null +++ b/ui/main/src/main/java/com/example/main/theme/Theme.kt @@ -0,0 +1,92 @@ +package com.example.main.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + + +private val LightColors = lightColorScheme( + primary = md_theme_light_primary, + onPrimary = md_theme_light_onPrimary, + primaryContainer = md_theme_light_primaryContainer, + onPrimaryContainer = md_theme_light_onPrimaryContainer, + secondary = md_theme_light_secondary, + onSecondary = md_theme_light_onSecondary, + secondaryContainer = md_theme_light_secondaryContainer, + onSecondaryContainer = md_theme_light_onSecondaryContainer, + tertiary = md_theme_light_tertiary, + onTertiary = md_theme_light_onTertiary, + tertiaryContainer = md_theme_light_tertiaryContainer, + onTertiaryContainer = md_theme_light_onTertiaryContainer, + error = md_theme_light_error, + onError = md_theme_light_onError, + errorContainer = md_theme_light_errorContainer, + onErrorContainer = md_theme_light_onErrorContainer, + outline = md_theme_light_outline, + background = md_theme_light_background, + onBackground = md_theme_light_onBackground, + surface = md_theme_light_surface, + onSurface = md_theme_light_onSurface, + surfaceVariant = md_theme_light_surfaceVariant, + onSurfaceVariant = md_theme_light_onSurfaceVariant, + inverseSurface = md_theme_light_inverseSurface, + inverseOnSurface = md_theme_light_inverseOnSurface, + inversePrimary = md_theme_light_inversePrimary, + surfaceTint = md_theme_light_surfaceTint, + outlineVariant = md_theme_light_outlineVariant, + scrim = md_theme_light_scrim, +) + + +private val DarkColors = darkColorScheme( + primary = md_theme_dark_primary, + onPrimary = md_theme_dark_onPrimary, + primaryContainer = md_theme_dark_primaryContainer, + onPrimaryContainer = md_theme_dark_onPrimaryContainer, + secondary = md_theme_dark_secondary, + onSecondary = md_theme_dark_onSecondary, + secondaryContainer = md_theme_dark_secondaryContainer, + onSecondaryContainer = md_theme_dark_onSecondaryContainer, + tertiary = md_theme_dark_tertiary, + onTertiary = md_theme_dark_onTertiary, + tertiaryContainer = md_theme_dark_tertiaryContainer, + onTertiaryContainer = md_theme_dark_onTertiaryContainer, + error = md_theme_dark_error, + onError = md_theme_dark_onError, + errorContainer = md_theme_dark_errorContainer, + onErrorContainer = md_theme_dark_onErrorContainer, + outline = md_theme_dark_outline, + background = md_theme_dark_background, + onBackground = md_theme_dark_onBackground, + surface = md_theme_dark_surface, + onSurface = md_theme_dark_onSurface, + surfaceVariant = md_theme_dark_surfaceVariant, + onSurfaceVariant = md_theme_dark_onSurfaceVariant, + inverseSurface = md_theme_dark_inverseSurface, + inverseOnSurface = md_theme_dark_inverseOnSurface, + inversePrimary = md_theme_dark_inversePrimary, + surfaceTint = md_theme_dark_surfaceTint, + outlineVariant = md_theme_dark_outlineVariant, + scrim = md_theme_dark_scrim, +) + +@Composable +fun AppTheme( + useDarkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colors = if (!useDarkTheme) { + LightColors + } else { + DarkColors + } + + MaterialTheme( + colorScheme = colors, + typography = Typography, + shapes = Shapes, + content = content + ) +} \ No newline at end of file diff --git a/ui/main/src/main/java/com/example/main/theme/Type.kt b/ui/main/src/main/java/com/example/main/theme/Type.kt new file mode 100644 index 0000000..88681f8 --- /dev/null +++ b/ui/main/src/main/java/com/example/main/theme/Type.kt @@ -0,0 +1,94 @@ +package com.example.main.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +internal val Typography = Typography( + displayLarge = TextStyle( + fontWeight = FontWeight.W400, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp + ), + displayMedium = TextStyle( + fontWeight = FontWeight.W400, + fontSize = 45.sp, + lineHeight = 52.sp + ), + displaySmall = TextStyle( + fontWeight = FontWeight.W400, + fontSize = 36.sp, + lineHeight = 44.sp + ), + headlineLarge = TextStyle( + fontWeight = FontWeight.W400, + fontSize = 32.sp, + lineHeight = 40.sp + ), + headlineMedium = TextStyle( + fontWeight = FontWeight.W400, + fontSize = 28.sp, + lineHeight = 36.sp + ), + headlineSmall = TextStyle( + fontWeight = FontWeight.W400, + fontSize = 24.sp, + lineHeight = 32.sp + ), + titleLarge = TextStyle( + fontWeight = FontWeight.W700, + fontSize = 22.sp, + lineHeight = 28.sp + ), + titleMedium = TextStyle( + fontWeight = FontWeight.W700, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.1.sp + ), + titleSmall = TextStyle( + fontWeight = FontWeight.W500, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + bodyLarge = TextStyle( + fontWeight = FontWeight.W400, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.W400, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp + ), + bodySmall = TextStyle( + fontWeight = FontWeight.W400, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp + ), + labelLarge = TextStyle( + fontWeight = FontWeight.W400, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + labelMedium = TextStyle( + fontWeight = FontWeight.W400, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.W500, + fontSize = 10.sp, + lineHeight = 16.sp + ) +) \ No newline at end of file diff --git a/ui/main/src/main/res/values/strings.xml b/ui/main/src/main/res/values/strings.xml new file mode 100644 index 0000000..3a9b315 --- /dev/null +++ b/ui/main/src/main/res/values/strings.xml @@ -0,0 +1,13 @@ + + + Home + Retry + Search + Empty Icon + Auto Retry Icon + Favorite + Setting + home Tab + favorite tab + setting tab + \ No newline at end of file diff --git a/ui/main/src/test/java/com/example/main/ExampleUnitTest.kt b/ui/main/src/test/java/com/example/main/ExampleUnitTest.kt new file mode 100644 index 0000000..b50272e --- /dev/null +++ b/ui/main/src/test/java/com/example/main/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package com.example.main + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/ui/search/.gitignore b/ui/search/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/ui/search/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/ui/search/build.gradle.kts b/ui/search/build.gradle.kts new file mode 100644 index 0000000..90acca5 --- /dev/null +++ b/ui/search/build.gradle.kts @@ -0,0 +1,56 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + kotlin("kapt") + id("com.google.dagger.hilt.android") +} +android { + namespace = "com.example.ui.search" + compileSdk = AppConfig.compileSdk + + defaultConfig { + minSdk = AppConfig.minSdk + targetSdk = AppConfig.targetSdk + + testInstrumentationRunner = AppConfig.androidTestInstrumentation + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = Version.KOTLIN_COMPILER_EXTENSION_VERSION + } +} + +dependencies { + compose() + composeNavigation() + composeViewModel() + composeMaterial() + hilt() + junit4() + + moduleDependency(":data:exchangerate") + moduleDependency(":data:favorite") + moduleDependency(":data:common") + moduleDependency(":ui:detail") + moduleDependency(":ui:common") +} \ No newline at end of file diff --git a/ui/search/consumer-rules.pro b/ui/search/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/ui/search/proguard-rules.pro b/ui/search/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/ui/search/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/ui/search/src/androidTest/java/com/example/search/ExampleInstrumentedTest.kt b/ui/search/src/androidTest/java/com/example/search/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..346385a --- /dev/null +++ b/ui/search/src/androidTest/java/com/example/search/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package com.example.search + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.ui.search.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/ui/search/src/main/AndroidManifest.xml b/ui/search/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/ui/search/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/ui/search/src/main/java/com/example/search/SearchScreen.kt b/ui/search/src/main/java/com/example/search/SearchScreen.kt new file mode 100644 index 0000000..85f1c27 --- /dev/null +++ b/ui/search/src/main/java/com/example/search/SearchScreen.kt @@ -0,0 +1,211 @@ +package com.example.search + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import com.example.data.common.model.ExchangeRate +import com.example.detail.nav.navigateToDetail +import com.example.ui.common.component.* +import com.example.ui.common.component.cell.RateShimmerCell +import com.example.ui.common.component.cell.toCell +import com.example.ui.common.component.icon.AppIcons +import com.example.ui.common.component.screen.SearchBarScaffold +import com.example.ui.common.component.view.AutoRetryView +import com.example.ui.common.component.view.EmptyView +import com.example.ui.common.component.view.RetryView +import com.example.ui.common.component.view.ShimmerView +import com.example.ui.common.ext.create +import com.example.ui.search.R + +@Composable +fun SearchScreen( + modifier: Modifier = Modifier, + navController: NavController, + viewModel: SearchViewModel = hiltViewModel() +) { + SearchViewContent( + modifier = modifier, + uiState = viewModel.state.value, + navController = navController, + onRetry = { viewModel.onEvent(SearchUiEvent.Retry) }, + onQueryChange = { query -> viewModel.onEvent(SearchUiEvent.QueryChange(query)) }, + onCancelClick = { + viewModel.onEvent(SearchUiEvent.NavigationBack) + navController.popBackStack() + }, + onFavoriteClick = { rate -> viewModel.onEvent(SearchUiEvent.OnFavorite(rate)) } + ) +} + +@Composable +private fun SearchViewContent( + modifier: Modifier = Modifier, + uiState: SearchUiState, + navController: NavController, + onRetry: () -> Unit, + onCancelClick: () -> Unit, + onQueryChange: (query: String) -> Unit, + onFavoriteClick: (rate: ExchangeRate) -> Unit +) { + HandleKeyboard(uiState.isKeyboardHidden) + SearchBarScaffold( + modifier = modifier.background(MaterialTheme.colorScheme.surface), + hint = stringResource(id = R.string.searchBarHint), + onQueryChange = onQueryChange, + onCancelClick = onCancelClick + ) { + SearchShimmerView( + modifier = modifier, + isVisible = uiState.isLoading + ) + AutoRetryView( + isVisible = uiState.isAutoRetry, + errorMessage = uiState.autoRetryMsg, + icon = AppIcons.Warning, + hint = stringResource(id = R.string.searchAutoRetryHint) + ) + + RetryView( + isVisible = uiState.isRetry, + retryMessage = uiState.retryMsg, + icon = AppIcons.Warning, onRetry = onRetry + ) + + StartView( + modifier = modifier, + uiState = uiState + ) + + EmptyView( + modifier = modifier, + isVisible = uiState.isEmpty, + icon = AppIcons.Search, + message = buildNoResultHint(uiState.query) + ) + + DataView( + modifier = modifier + .background(MaterialTheme.colorScheme.surface) + .fillMaxSize(), + isVisible = uiState.isLoaded, + result = uiState.result, + favoriteRates = uiState.favoriteRates, + navigateToDetail = { id -> navController.navigateToDetail(id) }, + onFavoriteClick = onFavoriteClick, + ) + } +} + +@Composable +private fun DataView( + modifier: Modifier = Modifier, + isVisible: Boolean, + result: List, + favoriteRates: List, + navigateToDetail: (id: String) -> Unit, + onFavoriteClick: (rate: ExchangeRate) -> Unit +) { + val models = result.map { + it.toCell( + favoritesRates = favoriteRates, + navigateToDetail = navigateToDetail, + onFavoriteClick = onFavoriteClick + ) + } + + val lazyListState = rememberLazyListState() + if (lazyListState.isScrollInProgress) { + HandleKeyboard(isKeyboardHidden = true) + } + BaseLazyColumn( + modifier = modifier.background(MaterialTheme.colorScheme.surface), + lazyListState = lazyListState, + isVisible = isVisible, + models = models + ) +} + +@Composable +fun SearchShimmerView( + modifier: Modifier, + isVisible: Boolean +) { + ShimmerView( + modifier = modifier, + isVisible = isVisible, + ) { shimmerAxis -> + BaseLazyColumn( + isVisible = true, + models = create(count = 7) { { RateShimmerCell(shimmerAxis = shimmerAxis) } } + ) + } +} + +@Composable +private fun StartView( + modifier: Modifier, + uiState: SearchUiState +) { + EmptyView( + modifier = modifier, + isVisible = uiState.isStart, + icon = AppIcons.Search, + message = stringResource(id = R.string.startSearchHint) + ) +} + +@Composable +private fun buildNoResultHint(query: String) = buildAnnotatedString { + withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.onBackground)) { + append(stringResource(id = R.string.noItemFound)) + } + withStyle( + style = SpanStyle( + color = Color.Green, + fontStyle = FontStyle.Italic, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) + ) { + append(" $query") + } +} + +@Composable +@OptIn(ExperimentalComposeUiApi::class) +private fun HandleKeyboard(isKeyboardHidden: Boolean) { + val keyboardController = LocalSoftwareKeyboardController.current + if (isKeyboardHidden) { + keyboardController?.hide() + } +} + +@Preview +@Composable +fun SearchPreview() { + SearchViewContent( + uiState = SearchUiState.Loading(""), + navController = rememberNavController(), + onRetry = {}, + onCancelClick = {}, + onQueryChange = {}, + onFavoriteClick = {} + ) +} diff --git a/ui/search/src/main/java/com/example/search/SearchViewModel.kt b/ui/search/src/main/java/com/example/search/SearchViewModel.kt new file mode 100644 index 0000000..cfc53cf --- /dev/null +++ b/ui/search/src/main/java/com/example/search/SearchViewModel.kt @@ -0,0 +1,184 @@ +package com.example.search + +import androidx.lifecycle.viewModelScope +import com.example.data.common.model.ExchangeRate +import com.example.favorite.FavoriteRatesInteractor +import com.example.rate.ExchangeRateInteractor +import com.example.ui.common.BaseViewModel +import com.example.ui.common.SharedState +import com.example.ui.common.UIEvent +import com.example.ui.common.UIState +import com.example.ui.common.connectivity.ConnectivityMonitor +import com.example.ui.common.ext.retryOnNetworkConnection +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SearchViewModel @Inject constructor( + private val exchangeRateInteractor: ExchangeRateInteractor, + private val favoriteRatesInteractor: FavoriteRatesInteractor, + private val connectivityMonitor: ConnectivityMonitor, +) : BaseViewModel(SearchUiState.Start()) { + private val searchFlow = MutableStateFlow(state.value.query) + lateinit var searchJob: Job + + init { + changeBottomBarVisibility(false) + observeQueryChange() + } + + private fun changeBottomBarVisibility(enabled: Boolean) { + viewModelScope.launch { + SharedState.bottomBarVisible.emit(enabled) + } + } + + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + private fun observeQueryChange() { + searchFlow + .debounce(300) + .distinctUntilChanged() + .mapLatest { query -> + when { + query.isBlank() && query.isEmpty() -> setState(SearchUiState.Start()) + query.isNotResetState() -> { + if (::searchJob.isInitialized && searchJob.isActive) { + searchJob.cancel() + } + searchQuery(query) + } + } + }.launchIn(viewModelScope) + } + + private fun searchQuery(query: String) { + setState(SearchUiState.Loading(query = query)) + searchJob = combine( + exchangeRateInteractor.getRates().map { rates -> + rates.filter { + it.symbol.contains(query, ignoreCase = true) + } + }, + favoriteRatesInteractor.getFavoriteRates() + ) { result, favoriteRates -> + when { + result.isEmpty() -> setState(SearchUiState.Empty(state.value.query)) + else -> setState( + SearchUiState.Loaded( + result = result, + favoriteRates = favoriteRates, + query = state.value.query + ) + ) + } + } + .retryOnNetworkConnection(connectivityMonitor) { e -> handleAutoRetry(e) } + .catch { e -> handleError(e) } + .launchIn(viewModelScope) + } + + private fun handleError(e: Throwable) { + searchFlow.value = restString + setState(SearchUiState.Retry(e.message, query = state.value.query)) + } + + private fun handleAutoRetry(e: Throwable) { + searchFlow.value = restString + setState(SearchUiState.AutoRetry(e.message, query = state.value.query)) + } + + private fun handleFavorite(rate: ExchangeRate) { + viewModelScope.launch { + val favoriteRates = favoriteRatesInteractor.getFavoriteRates().firstOrNull() + if (favoriteRates.isNullOrEmpty()) { + favoriteRatesInteractor.addFavorite(rate) + } else { + if (favoriteRates.any { it.id == rate.id }) { + favoriteRatesInteractor.removeFavorite(rate) + } else { + favoriteRatesInteractor.addFavorite(rate) + } + } + } + } + + override fun onEvent(event: SearchUiEvent) { + when (event) { + SearchUiEvent.NavigationBack -> changeBottomBarVisibility(true) + SearchUiEvent.Retry -> { + searchFlow.value = state.value.query + } + is SearchUiEvent.QueryChange -> { + searchFlow.tryEmit(event.text) + } + SearchUiEvent.ClearSearch -> { + searchFlow.value = "" + setState(SearchUiState.Empty()) + } + is SearchUiEvent.OnFavorite -> handleFavorite(event.rate) + } + } +} + +fun String.isNotResetState() = this != restString +const val restString = "__ResetString__" + +sealed interface SearchUiEvent : UIEvent { + object Retry : SearchUiEvent + class QueryChange(val text: String) : SearchUiEvent + object ClearSearch : SearchUiEvent + object NavigationBack : SearchUiEvent + class OnFavorite(val rate: ExchangeRate) : SearchUiEvent +} + +sealed class SearchUiState( + val result: List = emptyList(), + val favoriteRates: List = emptyList(), + val isLoading: Boolean = false, + val isLoaded: Boolean = false, + val isKeyboardHidden: Boolean = false, + val isRetry: Boolean = false, + val isAutoRetry: Boolean = false, + val isEmpty: Boolean = false, + val isStart: Boolean = false, + val retryMsg: String? = null, + val autoRetryMsg: String? = null, + var query: String +) : UIState { + class Loading(query: String) : SearchUiState(isLoading = true, query = query) + + class Retry(retryMsg: String? = null, query: String) : SearchUiState( + isRetry = true, + retryMsg = retryMsg, + isKeyboardHidden = true, + query = query + ) + + class AutoRetry(autoRetryMsg: String? = null, query: String) : SearchUiState( + isAutoRetry = true, + isKeyboardHidden = true, + autoRetryMsg = autoRetryMsg, + query = query + ) + + class Empty(query: String = "") : SearchUiState(isEmpty = true, query = query) + + class Start(query: String = "") : SearchUiState(isStart = true, query = query) + + class Loaded( + result: List, + favoriteRates: List, + query: String + ) : + SearchUiState( + isLoaded = true, + result = result, + favoriteRates = favoriteRates, + query = query + ) +} \ No newline at end of file diff --git a/ui/search/src/main/java/com/example/search/nav/SearchNav.kt b/ui/search/src/main/java/com/example/search/nav/SearchNav.kt new file mode 100644 index 0000000..98435b7 --- /dev/null +++ b/ui/search/src/main/java/com/example/search/nav/SearchNav.kt @@ -0,0 +1,26 @@ +package com.example.search.nav + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.example.search.SearchScreen + +/** + * @author yaya (@yahyalmh) + * @since 05th November 2022 + */ + + +const val searchRoute = "search_route" + +fun NavController.navigateToSearch(navOptions: NavOptions? = null) { + this.navigate(searchRoute, navOptions) +} + +fun NavGraphBuilder.searchGraph(navController: NavHostController) { + composable(route = searchRoute) { + SearchScreen(navController = navController) + } +} \ No newline at end of file diff --git a/ui/search/src/main/res/values/strings.xml b/ui/search/src/main/res/values/strings.xml new file mode 100644 index 0000000..1f9c29d --- /dev/null +++ b/ui/search/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + No item found for + Search and mark currencies you like + Go online to search again + Search Currencies + \ No newline at end of file diff --git a/ui/search/src/test/java/com/example/search/ExampleUnitTest.kt b/ui/search/src/test/java/com/example/search/ExampleUnitTest.kt new file mode 100644 index 0000000..0cd716b --- /dev/null +++ b/ui/search/src/test/java/com/example/search/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package com.example.search + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/ui/setting/.gitignore b/ui/setting/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/ui/setting/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/ui/setting/build.gradle.kts b/ui/setting/build.gradle.kts new file mode 100644 index 0000000..d8a2d20 --- /dev/null +++ b/ui/setting/build.gradle.kts @@ -0,0 +1,57 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + kotlin("kapt") + id("com.google.dagger.hilt.android") +} +android { + namespace = "com.example.setting" + compileSdk = AppConfig.compileSdk + + defaultConfig { + minSdk = AppConfig.minSdk + targetSdk = AppConfig.targetSdk + + testInstrumentationRunner = AppConfig.androidTestInstrumentation + } + + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = Version.KOTLIN_COMPILER_EXTENSION_VERSION + } +} + +dependencies { + + compose() + composeNavigation() + composeViewModel() + composeMaterial() + junit4() + + datastore() + + hilt() + moduleDependency(":ui:common") + moduleDependency(":data:datastore") +} \ No newline at end of file diff --git a/ui/setting/consumer-rules.pro b/ui/setting/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/ui/setting/proguard-rules.pro b/ui/setting/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/ui/setting/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/ui/setting/src/androidTest/java/com/example/setting/ExampleInstrumentedTest.kt b/ui/setting/src/androidTest/java/com/example/setting/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..ba3b870 --- /dev/null +++ b/ui/setting/src/androidTest/java/com/example/setting/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.setting + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.setting.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/ui/setting/src/main/AndroidManifest.xml b/ui/setting/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/ui/setting/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/ui/setting/src/main/java/com/example/setting/SettingScreen.kt b/ui/setting/src/main/java/com/example/setting/SettingScreen.kt new file mode 100644 index 0000000..c9c0ba3 --- /dev/null +++ b/ui/setting/src/main/java/com/example/setting/SettingScreen.kt @@ -0,0 +1,110 @@ +package com.example.setting + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavHostController +import com.example.ui.common.ThemeType +import com.example.ui.common.component.screen.TopBarScaffold +import com.example.ui.common.component.view.* + +@Composable +fun SettingScreen( + modifier: Modifier = Modifier, + navController: NavHostController, + viewModel: SettingViewModel = hiltViewModel() +) { + SettingScreenContent( + modifier = modifier, + uiState = viewModel.state.value, + onChangeTheme = { viewModel.onEvent(SettingUiEvent.ChangeTheme(it)) } + ) +} + +@Composable +private fun SettingScreenContent( + modifier: Modifier = Modifier, + uiState: SettingUiState, + onChangeTheme: (theme: ThemeType) -> Unit +) { + TopBarScaffold( + title = stringResource(id = R.string.setting), + ) { padding -> + + LoadingView(isVisible = uiState.isLoading) + + ContentView( + modifier = modifier.padding(padding), + isVisible = uiState.isLoaded, + currentThemeType = uiState.currentThemeType, onChangeTheme = onChangeTheme + ) + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun ContentView( + modifier: Modifier = Modifier, + isVisible: Boolean, + currentThemeType: ThemeType?, + onChangeTheme: (theme: ThemeType) -> Unit +) { + val themeTypes = ThemeType.values().asList() + + AnimatedVisibility(modifier = modifier, visible = isVisible) { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top + ) { + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + color = MaterialTheme.colorScheme.onBackground, + text = stringResource(id = R.string.theme), + style = MaterialTheme.typography.titleLarge + ) + + Row { + themeTypes.forEach { type -> + AssistChip( + modifier = Modifier.padding(8.dp), + onClick = { onChangeTheme(type) }, + label = { Text(type.name) }, + leadingIcon = { + AnimatedVisibility(visible = currentThemeType == type) { + Icon( + modifier = Modifier.size(AssistChipDefaults.IconSize), + imageVector = Icons.Filled.Check, + contentDescription = stringResource(id = R.string.checkIconDescription) + ) + } + } + ) + } + } + + Divider(modifier = Modifier.padding(top = 6.dp)) + } + } +} + +@Preview(showSystemUi = true) +@Composable +fun ContentPreview() { + SettingScreenContent(uiState = SettingUiState.SetSetting(ThemeType.SYSTEM), onChangeTheme = {}) +} \ No newline at end of file diff --git a/ui/setting/src/main/java/com/example/setting/SettingViewModel.kt b/ui/setting/src/main/java/com/example/setting/SettingViewModel.kt new file mode 100644 index 0000000..39f1213 --- /dev/null +++ b/ui/setting/src/main/java/com/example/setting/SettingViewModel.kt @@ -0,0 +1,53 @@ +package com.example.setting + +import androidx.lifecycle.viewModelScope +import com.example.datastore.DatastoreInteractor +import com.example.ui.common.* +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SettingViewModel @Inject constructor( + private val datastoreInteractor: DatastoreInteractor +) : BaseViewModel(SettingUiState.Loading) { + + init { + observeThemType() + } + + private fun observeThemType() { + datastoreInteractor + .getThemeType() + .onEach { + val themeType = it?.toThemeType() ?: ThemeType.SYSTEM + setState(SettingUiState.SetSetting(themeType)) + } + .launchIn(viewModelScope) + } + + override fun onEvent(event: SettingUiEvent) { + when (event) { + is SettingUiEvent.ChangeTheme -> { + viewModelScope.launch { datastoreInteractor.setThemeType(event.type.name) } + } + } + } +} + + +sealed interface SettingUiEvent : UIEvent { + class ChangeTheme(val type: ThemeType) : SettingUiEvent +} + +sealed class SettingUiState( + val isLoading: Boolean = false, + val isLoaded: Boolean = false, + val currentThemeType: ThemeType? = null +) : UIState { + object Loading : SettingUiState(isLoading = true) + class SetSetting(currentThemeType: ThemeType?) : + SettingUiState(isLoaded = true, currentThemeType = currentThemeType) +} \ No newline at end of file diff --git a/ui/setting/src/main/java/com/example/setting/nav/SettingNav.kt b/ui/setting/src/main/java/com/example/setting/nav/SettingNav.kt new file mode 100644 index 0000000..b778fda --- /dev/null +++ b/ui/setting/src/main/java/com/example/setting/nav/SettingNav.kt @@ -0,0 +1,20 @@ +package com.example.setting.nav + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.example.setting.SettingScreen + +const val settingRoute = "setting_route" + +fun NavController.navigateToSetting(navOptions: NavOptions? = null) { + this.navigate(settingRoute, navOptions) +} + +fun NavGraphBuilder.settingGraph(navController: NavHostController) { + composable(route = settingRoute) { + SettingScreen(navController = navController) + } +} \ No newline at end of file diff --git a/ui/setting/src/main/res/values/strings.xml b/ui/setting/src/main/res/values/strings.xml new file mode 100644 index 0000000..b5cd5d7 --- /dev/null +++ b/ui/setting/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + Setting + Theme + Check Icon + \ No newline at end of file diff --git a/ui/setting/src/test/java/com/example/setting/ExampleUnitTest.kt b/ui/setting/src/test/java/com/example/setting/ExampleUnitTest.kt new file mode 100644 index 0000000..44b9f31 --- /dev/null +++ b/ui/setting/src/test/java/com/example/setting/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.example.setting + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file