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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ xmlns:android
+
+ ^$
+
+
+
+
+
+
+
+
+ xmlns:.*
+
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*:id
+
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ .*:name
+
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ name
+
+ ^$
+
+
+
+
+
+
+
+
+ style
+
+ ^$
+
+
+
+
+
+
+
+
+ .*
+
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*
+
+ http://schemas.android.com/apk/res/android
+
+
+ ANDROID_ATTRIBUTE_ORDER
+
+
+
+
+
+
+ .*
+
+ .*
+
+
+ BY_NAME
+
+
+
+
+
+
+
+
+
+
\ 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