diff --git a/.github/workflows/android_build.yml b/.github/workflows/android_build.yml
new file mode 100644
index 0000000..5ec2764
--- /dev/null
+++ b/.github/workflows/android_build.yml
@@ -0,0 +1,42 @@
+name: Build
+
+on:
+ push:
+ branches: [ develop, main ]
+ pull_request:
+ branches: [ main, develop ]
+
+concurrency:
+ group: build-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+
+ build:
+ name: π¨ Build
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Validate Gradle Wrapper
+ uses: gradle/wrapper-validation-action@v1
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'adopt'
+ cache: gradle
+
+ - name: Make gradle executable
+ run: chmod +x ./gradlew
+
+ - name: Get local.properties from secrets
+ run: echo "${{secrets.LOCAL_PROPERTIES }}" > $GITHUB_WORKSPACE/local.properties
+
+ - name: Run tests
+ run: ./gradlew test --stacktrace
+
+ - name: Build app
+ run: ./gradlew assemble --stacktrace
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..93166e0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,19 @@
+*.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
+/*.apk
+/*.aab
+/app/release
+/app/build
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/.name b/.idea/.name
new file mode 100644
index 0000000..95982d5
--- /dev/null
+++ b/.idea/.name
@@ -0,0 +1 @@
+CatBreedBrowser
\ No newline at end of file
diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml
new file mode 100644
index 0000000..b7d0533
--- /dev/null
+++ b/.idea/androidTestResultsUserPreferences.xml
@@ -0,0 +1,283 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml
new file mode 100644
index 0000000..5a57561
--- /dev/null
+++ b/.idea/appInsightsSettings.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
new file mode 100644
index 0000000..cffba4e
--- /dev/null
+++ b/.idea/codeStyles/Project.xml
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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..b589d56
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml
new file mode 100644
index 0000000..0c0c338
--- /dev/null
+++ b/.idea/deploymentTargetDropDown.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
new file mode 100644
index 0000000..b268ef3
--- /dev/null
+++ b/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
new file mode 100644
index 0000000..d6775cf
--- /dev/null
+++ b/.idea/gradle.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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..44ca2d9
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml
new file mode 100644
index 0000000..fdc392f
--- /dev/null
+++ b/.idea/jarRepositories.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
new file mode 100644
index 0000000..8d81632
--- /dev/null
+++ b/.idea/kotlinc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/ktlint.xml b/.idea/ktlint.xml
new file mode 100644
index 0000000..6c881ef
--- /dev/null
+++ b/.idea/ktlint.xml
@@ -0,0 +1,6 @@
+
+
+
+ false
+
+
\ No newline at end of file
diff --git a/.idea/migrations.xml b/.idea/migrations.xml
new file mode 100644
index 0000000..f8051a6
--- /dev/null
+++ b/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..07c366e
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/sonarlint/issuestore/5/2/523770b56b38111798cfd3b374784614aee4657e b/.idea/sonarlint/issuestore/5/2/523770b56b38111798cfd3b374784614aee4657e
new file mode 100644
index 0000000..e69de29
diff --git a/.idea/sonarlint/issuestore/index.pb b/.idea/sonarlint/issuestore/index.pb
new file mode 100644
index 0000000..32c959d
--- /dev/null
+++ b/.idea/sonarlint/issuestore/index.pb
@@ -0,0 +1,39 @@
+
+l
+feature/auth/src/main/java/com/joelkanyi/auth/di/AuthModule.kt,1/a/1a549ed4d3b295872655bd93bb153a53bb96969e
+
+efeature/kitchen-timer/src/main/java/com/joelkanyi/kitchen_timer/presentation/KitchenTimerViewModel.kt,e/c/ec8fd33f791afe428756d4e1d257c8e1ba18a629
+n
+>app/src/main/java/com/kanyideveloper/mealtime/MainViewModel.kt,5/d/5d89cb9046bbb473be182a65223fe659d546e615
+
+bfeature/kitchen-timer/src/main/java/com/joelkanyi/kitchen_timer/presentation/KitchenTimerScreen.kt,7/8/781baf1da6708e32df7fd944cb3e859de8f4d5a3
+k
+;core/src/main/java/com/kanyideveloper/core/util/UiEvents.kt,8/b/8b86d119d2430cdf869f398c02bcb585a5d4127f
+N
+buildSrc/src/main/java/Deps.kt,5/b/5ba22e5e14186e1d61c2d047b757b3161c473375
+r
+Bcore/src/main/java/com/kanyideveloper/core/state/TextFieldState.kt,7/3/73b175326aed1d522f1a9ce04624b2d0a9bfed93
+
+Qfeature/auth/src/main/java/com/joelkanyi/auth/presentation/signin/SignInScreen.kt,a/8/a8d5b03ebcb81f090cfca3caff9a2eff78056b17
+B
+base-module.gradle,e/3/e33a6bc3c3db1f16f571736a6b80850b7b3ef758
+
+Tfeature/auth/src/main/java/com/joelkanyi/auth/presentation/signin/SignInViewModel.kt,4/7/4746fd820866301e8e2a515cfd4296a931c5f27c
+
+Ufeature/mealplanner/src/main/java/com/kanyideveloper/mealplanner/MealPlannerScreen.kt,7/f/7f7ac89a4aff649b06fea5d7c564505f343e63b4
+}
+Mfeature/home/src/main/java/com/kanyideveloper/presentation/home/HomeScreen.kt,3/5/35365c97c2d652a5ba5f887a4cb67ec9a8c16cf6
+W
+'buildSrc/src/main/java/AndroidConfig.kt,a/9/a9973f1d73e524cebdfc89f4d3ddb6dde8ac1570
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..261eeb9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ 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
+
+ http://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.
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..46f7539
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,95 @@
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.android.kotlin)
+ alias(libs.plugins.kapt)
+ alias(libs.plugins.hilt.android)
+ alias(libs.plugins.parcelize)
+ alias(libs.plugins.ksp)
+}
+
+apply {
+ from("$rootDir/base-module.gradle")
+}
+
+android {
+ compileSdk = AndroidConfig.compileSDK
+
+ defaultConfig {
+ applicationId = AndroidConfig.applicationId
+ minSdk = AndroidConfig.minSDK
+ targetSdk = AndroidConfig.targetSDK
+ versionCode = AndroidConfig.versionCode
+ versionName = AndroidConfig.versionName
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = AndroidConfig.javaVersion
+ targetCompatibility = AndroidConfig.javaVersion
+ }
+ kotlinOptions {
+ jvmTarget = AndroidConfig.jvmTarget
+ }
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+ namespace = "com.aliumujib.catbrowser"
+
+ applicationVariants.all {
+ kotlin.sourceSets {
+ getByName(name) {
+ kotlin.srcDir("build/generated/ksp/$name/kotlin")
+ }
+ }
+ }
+}
+
+dependencies {
+ // Modules
+ implementation(projects.core.common)
+ implementation(projects.core.models)
+ implementation(projects.core.preferences)
+ implementation(projects.core.designsystem)
+ implementation(projects.core.network)
+ implementation(projects.core.commonDomain)
+ implementation(projects.feature.settings)
+ implementation(projects.feature.breeds.allBreeds)
+ implementation(projects.feature.breeds.favoriteBreeds)
+ implementation(projects.feature.breeds.common)
+ implementation(projects.feature.breeds.breedDetails)
+ implementation(projects.feature.breeds.breedsDomain)
+ implementation(projects.feature.breeds.breedsData)
+
+ // RamCosta Navigation
+ implementation(libs.compose.destinations.animations)
+ implementation(libs.androidx.media3.session)
+ ksp(libs.compose.destinations.ksp)
+
+ implementation(libs.android.material)
+
+ // Splash Screen API
+ implementation(libs.core.splash.screen)
+}
diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml
new file mode 100644
index 0000000..608cb77
--- /dev/null
+++ b/app/lint-baseline.xml
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/debug/ic_launcher-playstore.png b/app/src/debug/ic_launcher-playstore.png
new file mode 100644
index 0000000..da6840a
Binary files /dev/null and b/app/src/debug/ic_launcher-playstore.png differ
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..932a14f
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png
new file mode 100644
index 0000000..da6840a
Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ
diff --git a/app/src/main/java/com/aliumujib/catbrowser/BottomNavItem.kt b/app/src/main/java/com/aliumujib/catbrowser/BottomNavItem.kt
new file mode 100644
index 0000000..78a42a4
--- /dev/null
+++ b/app/src/main/java/com/aliumujib/catbrowser/BottomNavItem.kt
@@ -0,0 +1,30 @@
+package com.aliumujib.catbrowser
+
+import android.content.Context
+import com.aliumujib.catbrowser.navigation.NavGraphs
+import com.ramcosta.composedestinations.spec.NavGraphSpec
+import io.eyram.iconsax.IconSax
+
+data class BottomNavItem(val title: String, val icon: Int, val screen: NavGraphSpec)
+
+fun getNavItems(context: Context): List {
+ val home = BottomNavItem(
+ title = context.getString(R.string.tab_title_home),
+ icon = IconSax.Outline.Musicnote,
+ screen = NavGraphs.home
+ )
+
+ val favorites = BottomNavItem(
+ title = context.getString(R.string.tab_title_favorites),
+ icon = IconSax.Outline.Heart,
+ screen = NavGraphs.favorites
+ )
+
+ val settings = BottomNavItem(
+ title = context.getString(R.string.tab_title_settings),
+ icon = IconSax.Outline.Setting2,
+ screen = NavGraphs.settings
+ )
+
+ return listOf(home, favorites, settings)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aliumujib/catbrowser/ElevenLabsMusicApp.kt b/app/src/main/java/com/aliumujib/catbrowser/ElevenLabsMusicApp.kt
new file mode 100644
index 0000000..b04ac82
--- /dev/null
+++ b/app/src/main/java/com/aliumujib/catbrowser/ElevenLabsMusicApp.kt
@@ -0,0 +1,29 @@
+package com.aliumujib.catbrowser
+
+import android.app.Application
+import coil.ImageLoader
+import coil.ImageLoaderFactory
+import dagger.hilt.android.HiltAndroidApp
+import timber.log.Timber
+import javax.inject.Inject
+
+@HiltAndroidApp
+class ElevenLabsMusicApp : Application(), ImageLoaderFactory {
+
+ @Inject
+ lateinit var imageLoader: ImageLoader
+
+ override fun onCreate() {
+ super.onCreate()
+ setupTimber()
+ }
+
+ private fun setupTimber() {
+ Timber.plant(Timber.DebugTree())
+ }
+
+ override fun newImageLoader(): ImageLoader {
+ return imageLoader
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aliumujib/catbrowser/MainActivity.kt b/app/src/main/java/com/aliumujib/catbrowser/MainActivity.kt
new file mode 100644
index 0000000..cb7e06a
--- /dev/null
+++ b/app/src/main/java/com/aliumujib/catbrowser/MainActivity.kt
@@ -0,0 +1,180 @@
+package com.aliumujib.catbrowser
+
+import android.Manifest
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import androidx.compose.animation.ExperimentalAnimationApi
+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.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.currentBackStackEntryAsState
+import androidx.navigation.compose.rememberNavController
+import com.aliumujib.all.breeds.ui.destinations.BreedsScreenDestination
+import com.aliumujib.designsystem.theme.AppTheme
+import com.aliumujib.designsystem.theme.Theme
+import com.aliumujib.catbrowser.component.StandardScaffold
+import com.aliumujib.catbrowser.component.navGraph
+import com.aliumujib.catbrowser.navigation.CoreFeatureNavigator
+import com.aliumujib.catbrowser.navigation.NavGraphs
+import com.aliumujib.favorite.breeds.ui.destinations.FavoritesScreenDestination
+import com.aliumujib.settings.presentation.settings.destinations.SettingsScreenDestination
+import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi
+import com.ramcosta.composedestinations.DestinationsNavHost
+import com.ramcosta.composedestinations.animations.defaults.NestedNavGraphDefaultAnimations
+import com.ramcosta.composedestinations.animations.defaults.RootNavGraphDefaultAnimations
+import com.ramcosta.composedestinations.animations.rememberAnimatedNavHostEngine
+import com.ramcosta.composedestinations.navigation.DependenciesContainerBuilder
+import com.ramcosta.composedestinations.navigation.dependency
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.Dispatchers
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+
+ private val viewModel: MainViewModel by viewModels()
+ private var navigator: CoreFeatureNavigator? = null
+
+ private val requestPermissionLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
+ viewModel.setGrantedPermissions(isGranted)
+ }
+
+ @OptIn(ExperimentalAnimationApi::class)
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ installSplashScreen().apply {
+ setKeepOnScreenCondition(
+ condition = viewModel
+ )
+ }
+
+ requestPermission()
+
+ setContent {
+ val state by viewModel.state.collectAsState()
+
+ val themeValue by viewModel.theme.collectAsState(
+ initial = Theme.FOLLOW_SYSTEM.themeValue,
+ context = Dispatchers.Main.immediate
+ )
+
+ AppTheme(
+ theme = themeValue
+ ) {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ val navController = rememberNavController()
+ val newBackStackEntry by navController.currentBackStackEntryAsState()
+ val route = newBackStackEntry?.destination?.route
+
+ if (state.hasGrantedPermissions) {
+
+ StandardScaffold(
+ navController = navController,
+ items = getNavItems(context = this),
+ isLoggedIn = true,
+ showBottomBar = route in listOf(
+ "home/${BreedsScreenDestination.route}",
+ "favorites/${FavoritesScreenDestination.route}",
+ "settings/${SettingsScreenDestination.route}",
+ )
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier.padding(innerPadding),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+
+ AppNavigation(
+ navController = navController,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ } else {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+
+ CircularProgressIndicator()
+
+ Text(
+ text = stringResource(id = R.string.home_screen_grant_storage_permissions),
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @OptIn(ExperimentalMaterialNavigationApi::class)
+ @ExperimentalAnimationApi
+ @Composable
+ internal fun AppNavigation(
+ navController: NavHostController,
+ modifier: Modifier = Modifier,
+ ) {
+ val navHostEngine = rememberAnimatedNavHostEngine(
+ navHostContentAlignment = Alignment.TopCenter,
+ rootDefaultAnimations = RootNavGraphDefaultAnimations.ACCOMPANIST_FADING,
+ defaultAnimationsForNestedNavGraph = mapOf(
+ NavGraphs.home to NestedNavGraphDefaultAnimations(),
+ NavGraphs.favorites to NestedNavGraphDefaultAnimations(),
+ NavGraphs.settings to NestedNavGraphDefaultAnimations(),
+ )
+ )
+
+ DestinationsNavHost(
+ engine = navHostEngine,
+ navController = navController,
+ navGraph = NavGraphs.root(),
+ modifier = modifier,
+ dependenciesContainerBuilder = {
+ dependency(
+ currentNavigator()
+ )
+ }
+ )
+ }
+
+ private fun requestPermission() {
+ requestPermissionLauncher.launch(
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ Manifest.permission.READ_MEDIA_AUDIO
+ } else {
+ Manifest.permission.READ_EXTERNAL_STORAGE
+ }
+ )
+ }
+
+ private fun DependenciesContainerBuilder<*>.currentNavigator(): CoreFeatureNavigator {
+ return CoreFeatureNavigator(
+ navGraph = navBackStackEntry.destination.navGraph(),
+ navController = navController,
+ ).also { navigator = it }
+ }
+
+}
diff --git a/app/src/main/java/com/aliumujib/catbrowser/MainViewModel.kt b/app/src/main/java/com/aliumujib/catbrowser/MainViewModel.kt
new file mode 100644
index 0000000..6021ac3
--- /dev/null
+++ b/app/src/main/java/com/aliumujib/catbrowser/MainViewModel.kt
@@ -0,0 +1,59 @@
+package com.aliumujib.catbrowser
+
+import androidx.core.splashscreen.SplashScreen
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.aliumujib.preferences.domain.usecase.GetAppThemeUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class MainViewModel @Inject constructor(
+ getAppThemeUseCase: GetAppThemeUseCase,
+) : ViewModel(), SplashScreen.KeepOnScreenCondition {
+
+ private val hasRequiredPermissions = MutableStateFlow(false)
+
+ private var isLoadingData: Boolean = true
+
+ val state = hasRequiredPermissions
+ .map { hasSeenPermissions ->
+ MainUiState(hasSeenPermissions)
+ }.onEach {
+ viewModelScope.launch {
+ delay(500L)
+ isLoadingData = false
+ }
+ }.stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = MainUiState(),
+ )
+
+ val theme = getAppThemeUseCase()
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = 0,
+ )
+
+ fun setGrantedPermissions(granted: Boolean) {
+ hasRequiredPermissions.value = granted
+ }
+
+ override fun shouldKeepOnScreen(): Boolean {
+ return isLoadingData
+ }
+
+}
+
+data class MainUiState(
+ val hasGrantedPermissions: Boolean = false,
+)
diff --git a/app/src/main/java/com/aliumujib/catbrowser/coil/ImageLoadingModule.kt b/app/src/main/java/com/aliumujib/catbrowser/coil/ImageLoadingModule.kt
new file mode 100644
index 0000000..f8faa2a
--- /dev/null
+++ b/app/src/main/java/com/aliumujib/catbrowser/coil/ImageLoadingModule.kt
@@ -0,0 +1,21 @@
+package com.aliumujib.catbrowser.coil
+
+import android.content.Context
+import coil.ImageLoader
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+class ImageLoadingModule {
+
+ @Provides
+ fun provideImageLoader(
+ @ApplicationContext context: Context,
+ ): ImageLoader = ImageLoader.Builder(context)
+ .build()
+
+}
diff --git a/app/src/main/java/com/aliumujib/catbrowser/component/StandardScaffold.kt b/app/src/main/java/com/aliumujib/catbrowser/component/StandardScaffold.kt
new file mode 100644
index 0000000..c801251
--- /dev/null
+++ b/app/src/main/java/com/aliumujib/catbrowser/component/StandardScaffold.kt
@@ -0,0 +1,137 @@
+package com.aliumujib.catbrowser.component
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.NavigationBarItemDefaults
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+import androidx.navigation.NavController
+import androidx.navigation.NavDestination
+import androidx.navigation.NavDestination.Companion.hierarchy
+import androidx.navigation.NavGraph.Companion.findStartDestination
+import androidx.navigation.NavOptionsBuilder
+import com.aliumujib.catbrowser.BottomNavItem
+import com.aliumujib.catbrowser.navigation.NavGraphs
+import com.ramcosta.composedestinations.navigation.navigate
+import com.ramcosta.composedestinations.spec.NavGraphSpec
+
+@Composable
+fun StandardScaffold(
+ navController: NavController,
+ showBottomBar: Boolean = true,
+ isLoggedIn: Boolean,
+ items: List,
+ content: @Composable (paddingValues: PaddingValues) -> Unit
+) {
+ Scaffold(
+ bottomBar = {
+ if (showBottomBar) {
+ val currentSelectedItem by navController.currentScreenAsState(isLoggedIn)
+
+ NavigationBar(
+ containerColor = MaterialTheme.colorScheme.background,
+ ) {
+ items.forEach { item ->
+ NavigationBarItem(
+ colors = NavigationBarItemDefaults.colors(
+ indicatorColor = Color.Transparent,
+ selectedIconColor = MaterialTheme.colorScheme.primary,
+ selectedTextColor = MaterialTheme.colorScheme.primary
+ ),
+ icon = {
+ Icon(
+ painterResource(id = item.icon),
+ contentDescription = item.title,
+ tint = if (currentSelectedItem == item.screen) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ }
+ )
+ },
+ label = {
+ Text(
+ text = item.title,
+ fontSize = 9.sp,
+ color = if (currentSelectedItem == item.screen) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ fontWeight = if (currentSelectedItem == item.screen) {
+ FontWeight.ExtraBold
+ } else {
+ FontWeight.Normal
+ }
+ )
+ },
+ alwaysShowLabel = true,
+ selected = currentSelectedItem == item.screen,
+ onClick = {
+ navController.navigate(item.screen, fun NavOptionsBuilder.() {
+ launchSingleTop = true
+ restoreState = true
+
+ popUpTo(navController.graph.findStartDestination().id) {
+ saveState = true
+ }
+ })
+ }
+ )
+ }
+ }
+ }
+ }
+ ) { paddingValues ->
+ content(paddingValues)
+ }
+}
+
+/**
+ * Adds an [NavController.OnDestinationChangedListener] to this [NavController] and updates the
+ * returned [State] which is updated as the destination changes.
+ */
+@Stable
+@Composable
+fun NavController.currentScreenAsState(isLoggedIn: Boolean): State {
+ val selectedItem = remember { mutableStateOf(NavGraphs.home) }
+
+ DisposableEffect(this) {
+ val listener = NavController.OnDestinationChangedListener { _, destination, _ ->
+ selectedItem.value = destination.navGraph()
+ }
+ addOnDestinationChangedListener(listener)
+
+ onDispose {
+ removeOnDestinationChangedListener(listener)
+ }
+ }
+
+ return selectedItem
+}
+
+fun NavDestination.navGraph(): NavGraphSpec {
+ hierarchy.forEach { destination ->
+ NavGraphs.root().nestedNavGraphs.forEach { navGraph ->
+ if (destination.route == navGraph.route) {
+ return navGraph
+ }
+ }
+ }
+
+ throw RuntimeException("Unknown nav graph for destination $route")
+}
diff --git a/app/src/main/java/com/aliumujib/catbrowser/di/BuildConfigModule.kt b/app/src/main/java/com/aliumujib/catbrowser/di/BuildConfigModule.kt
new file mode 100644
index 0000000..6b8c893
--- /dev/null
+++ b/app/src/main/java/com/aliumujib/catbrowser/di/BuildConfigModule.kt
@@ -0,0 +1,29 @@
+package com.aliumujib.catbrowser.di
+
+import com.aliumujib.catbrowser.BuildConfig
+import com.aliumujib.network.auth.BuildConfiguration
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object BuildConfigModule {
+
+ @Singleton
+ @Provides
+ fun providesBuildConfiguration(): BuildConfiguration {
+ return BuildConfiguration(
+ debug = BuildConfig.DEBUG,
+ appId = BuildConfig.APPLICATION_ID,
+ buildType = BuildConfig.BUILD_TYPE,
+ versionCode = BuildConfig.VERSION_CODE,
+ versionName = BuildConfig.VERSION_NAME,
+ baseUrl = "https://api.thecatapi.com/v1/",
+ apiKey = "live_WRRSUbYuEPByUfgMPRWgq3lPRWWYBLNUwmtzHr5L7FAkCPynWDM23oldD5kNQFfm"
+ )
+ }
+
+}
diff --git a/app/src/main/java/com/aliumujib/catbrowser/di/DomainModule.kt b/app/src/main/java/com/aliumujib/catbrowser/di/DomainModule.kt
new file mode 100644
index 0000000..aebb16d
--- /dev/null
+++ b/app/src/main/java/com/aliumujib/catbrowser/di/DomainModule.kt
@@ -0,0 +1,19 @@
+package com.aliumujib.catbrowser.di
+
+import com.aliumujib.common.domain.utils.DispatcherProvider
+import com.aliumujib.catbrowser.domain.DispatcherProviderImpl
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class DomainModule {
+
+ @Binds
+ @Singleton
+ abstract fun bindsPostExecutionThread(postExecutionThread: DispatcherProviderImpl): DispatcherProvider
+
+}
diff --git a/app/src/main/java/com/aliumujib/catbrowser/domain/DispatcherProviderImpl.kt b/app/src/main/java/com/aliumujib/catbrowser/domain/DispatcherProviderImpl.kt
new file mode 100644
index 0000000..a35d343
--- /dev/null
+++ b/app/src/main/java/com/aliumujib/catbrowser/domain/DispatcherProviderImpl.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2020 Abdul-Mujeeb Aliu
+ *
+ * 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
+ *
+ * http://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.
+ */
+package com.aliumujib.catbrowser.domain
+
+import com.aliumujib.common.domain.utils.DispatcherProvider
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import javax.inject.Inject
+
+class DispatcherProviderImpl @Inject constructor() : DispatcherProvider {
+
+ override val ui: CoroutineDispatcher = Dispatchers.Main
+ override val io: CoroutineDispatcher = Dispatchers.IO
+ override val default: CoroutineDispatcher = Dispatchers.Default
+
+}
diff --git a/app/src/main/java/com/aliumujib/catbrowser/navigation/CoreFeatureNavigator.kt b/app/src/main/java/com/aliumujib/catbrowser/navigation/CoreFeatureNavigator.kt
new file mode 100644
index 0000000..9a37176
--- /dev/null
+++ b/app/src/main/java/com/aliumujib/catbrowser/navigation/CoreFeatureNavigator.kt
@@ -0,0 +1,26 @@
+package com.aliumujib.catbrowser.navigation
+
+import androidx.navigation.NavController
+import com.aliumujib.all.breeds.navigation.BreedsNavigator
+import com.aliumujib.favorite.breeds.navigation.FavoritesNavigator
+import com.aliumujib.model.BreedId
+import com.aliumujib.breed.details.navigator.BreedDetailsNavigator
+import com.aliumujib.breed.details.ui.destinations.CatBreedDetailsScreenDestination
+import com.ramcosta.composedestinations.dynamic.within
+import com.ramcosta.composedestinations.navigation.navigate
+import com.ramcosta.composedestinations.spec.NavGraphSpec
+
+class CoreFeatureNavigator(
+ private val navGraph: NavGraphSpec,
+ private val navController: NavController,
+) : FavoritesNavigator, BreedsNavigator, BreedDetailsNavigator {
+
+ override fun goToDetails(id: BreedId) {
+ navController.navigate(CatBreedDetailsScreenDestination(breedId = id) within navGraph)
+ }
+
+ override fun goToBack() {
+ navController.navigate(navController.graph.startDestinationId)
+ }
+
+}
diff --git a/app/src/main/java/com/aliumujib/catbrowser/navigation/NavGraphs.kt b/app/src/main/java/com/aliumujib/catbrowser/navigation/NavGraphs.kt
new file mode 100644
index 0000000..bd53f31
--- /dev/null
+++ b/app/src/main/java/com/aliumujib/catbrowser/navigation/NavGraphs.kt
@@ -0,0 +1,56 @@
+package com.aliumujib.catbrowser.navigation
+
+import com.aliumujib.all.breeds.ui.destinations.BreedsScreenDestination
+import com.aliumujib.favorite.breeds.ui.destinations.FavoritesScreenDestination
+import com.aliumujib.breed.details.ui.destinations.CatBreedDetailsScreenDestination
+import com.aliumujib.settings.presentation.settings.destinations.SettingsScreenDestination
+import com.ramcosta.composedestinations.dynamic.routedIn
+import com.ramcosta.composedestinations.spec.DestinationSpec
+import com.ramcosta.composedestinations.spec.NavGraphSpec
+
+object NavGraphs {
+
+ val home = object : NavGraphSpec {
+ override val route = "home"
+
+ override val startRoute = BreedsScreenDestination routedIn this
+
+ override val destinationsByRoute = listOf>(
+ BreedsScreenDestination, CatBreedDetailsScreenDestination
+ ).routedIn(this)
+ .associateBy { it.route }
+ }
+
+ val favorites = object : NavGraphSpec {
+ override val route = "favorites"
+
+ override val startRoute = FavoritesScreenDestination routedIn this
+
+ override val destinationsByRoute = listOf>(
+ FavoritesScreenDestination, CatBreedDetailsScreenDestination
+ ).routedIn(this)
+ .associateBy { it.route }
+ }
+
+ val settings = object : NavGraphSpec {
+ override val route = "settings"
+
+ override val startRoute = SettingsScreenDestination routedIn this
+
+ override val destinationsByRoute = listOf>(
+ SettingsScreenDestination,
+ ).routedIn(this)
+ .associateBy { it.route }
+ }
+
+ fun root() = object : NavGraphSpec {
+ override val route = "root"
+ override val startRoute = home
+ override val destinationsByRoute = emptyMap>()
+ override val nestedNavGraphs = listOf(
+ home,
+ settings,
+ favorites
+ )
+ }
+}
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..036d09b
--- /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..036d09b
--- /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.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..b6af425
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..d81bfb1
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..52fef63
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..20d00e8
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..fb8efba
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..212ec12
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..f1ec6eb
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..f9232e8
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..428e2c3
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..f7b6a64
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..1c023b6
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..f9d6d32
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..b823442
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..d6dfd4a
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..676ad5b
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/values-night/splash_screen_theme.xml b/app/src/main/res/values-night/splash_screen_theme.xml
new file mode 100644
index 0000000..53c6db0
--- /dev/null
+++ b/app/src/main/res/values-night/splash_screen_theme.xml
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..917c986
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,6 @@
+
+
+ #FF000000
+ #e8e8e8
+ #ffffff
+
\ No newline at end of file
diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..4463f65
--- /dev/null
+++ b/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #FF4500
+
\ No newline at end of file
diff --git a/app/src/main/res/values/splash_screen_theme.xml b/app/src/main/res/values/splash_screen_theme.xml
new file mode 100644
index 0000000..f45b9b2
--- /dev/null
+++ b/app/src/main/res/values/splash_screen_theme.xml
@@ -0,0 +1,8 @@
+
+
+
+
\ 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..5be0bd1
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,9 @@
+
+ Cat Browser Test
+
+ Home
+ Favorites
+ Settings
+ Please grant the storage permissions, The app requires permissions to function properly.
+
+
\ 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..01f26e4
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
\ 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/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..b0c0896
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+
\ No newline at end of file
diff --git a/base-module.gradle b/base-module.gradle
new file mode 100644
index 0000000..4a056d3
--- /dev/null
+++ b/base-module.gradle
@@ -0,0 +1,89 @@
+dependencies {
+ implementation(libs.androidx.ktx)
+ implementation(libs.lifecycle.runtime.ktx)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.junit.ext)
+ androidTestImplementation(libs.espresso.core)
+
+ // Coroutines
+ implementation(libs.coroutines.core)
+ implementation(libs.coroutines.android)
+
+ // Coroutine Lifecycle Scopes
+ implementation(libs.viewmodel.ktx)
+
+ // Timber
+ implementation(libs.timber)
+
+ coreLibraryDesugaring(libs.desugar.jdk.libs)
+
+ // Dagger - Hilt
+ implementation(libs.dagger.hilt.android)
+ ksp(libs.dagger.hilt.android.compiler)
+ ksp(libs.hilt.compiler)
+
+ // UI
+ implementation(libs.activity.compose)
+ implementation(libs.compose.ui)
+ implementation(libs.compose.ui.tooling.preview)
+ androidTestImplementation(libs.compose.ui.test.junit4)
+ debugImplementation(libs.compose.ui.tooling)
+ implementation(libs.compose.test.manifest)
+ implementation(libs.lifecycle.viewmodel.compose)
+ implementation(libs.compose.hilt.navigation)
+
+ // Coil
+ implementation(libs.coil.compose)
+
+ // Paging Compose
+ implementation(libs.compose.paging)
+
+ // Livedata
+ implementation(libs.compose.livedata)
+
+ // collapsing Toolbar
+ implementation(libs.toolbar.compose)
+
+ // Compose livedata
+ implementation(libs.compose.livedata)
+
+ // Swipe to refresh
+ implementation(libs.accompanist.swiperefresh)
+
+ // Pager
+ implementation(libs.accompanist.pager)
+ implementation(libs.accompanist.pager.indicators)
+
+ // Leak Canary
+ debugImplementation(libs.leakcanary.android)
+
+ // Truth library
+ testImplementation(libs.truth)
+ androidTestImplementation(libs.truth)
+
+ androidTestImplementation(libs.arch.core.testing)
+
+ // Material 3
+ implementation(libs.compose.material3)
+
+ // Gson
+ implementation(libs.retrofit.converter.gson)
+
+ // Lottie
+ api(libs.lottie.compose)
+
+ // Compose Paging
+ implementation(libs.paging.runtime)
+
+ // Material Extended Icons
+ implementation(libs.compose.material.icons.extended)
+
+ // IconSax
+ implementation(libs.iconsax.android)
+
+ // Konsist
+ testImplementation(libs.konsist)
+
+ // Kotlinx DateTime
+ implementation(libs.kotlinx.datetime)
+}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..5399f6e
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,43 @@
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.android.library) apply false
+ alias(libs.plugins.android.kotlin) apply false
+ alias(libs.plugins.jvm) apply false
+ alias(libs.plugins.hilt.android) apply false
+ alias(libs.plugins.spotless)
+ alias(libs.plugins.kapt) apply false
+ alias(libs.plugins.parcelize) apply false
+ alias(libs.plugins.ksp) apply false
+}
+
+subprojects {
+ apply(plugin = "com.diffplug.spotless")
+ spotless {
+ kotlin {
+ target("**/*.kt")
+ licenseHeaderFile(
+ rootProject.file("${project.rootDir}/spotless/copyright.kt"),
+ "^(package|object|import|interface)"
+ )
+ trimTrailingWhitespace()
+ endWithNewline()
+ }
+ format("misc") {
+ target("**/*.md", "**/.gitignore")
+ trimTrailingWhitespace()
+ indentWithTabs()
+ endWithNewline()
+ }
+ java {
+ target("src/*/java/**/*.java")
+ googleJavaFormat("1.7").aosp()
+ indentWithSpaces()
+ licenseHeaderFile(rootProject.file("spotless/copyright.java"))
+ removeUnusedImports()
+ }
+ groovyGradle {
+ target("**/*.gradle")
+ }
+ }
+}
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..b22ed73
--- /dev/null
+++ b/buildSrc/build.gradle.kts
@@ -0,0 +1,7 @@
+plugins {
+ `kotlin-dsl`
+}
+
+repositories {
+ mavenCentral()
+}
\ No newline at end of file
diff --git a/buildSrc/src/main/java/AndroidConfig.kt b/buildSrc/src/main/java/AndroidConfig.kt
new file mode 100644
index 0000000..9f7f875
--- /dev/null
+++ b/buildSrc/src/main/java/AndroidConfig.kt
@@ -0,0 +1,16 @@
+import org.gradle.api.JavaVersion
+
+/**
+ * A collection of configuration properties for Android modules.
+ */
+object AndroidConfig {
+ const val minSDK = 26
+ const val targetSDK = 34
+ const val compileSDK = 34
+ const val versionCode = 21
+ const val versionName = "0.0.1"
+ const val applicationId = "com.aliumujib.takehomestarter"
+
+ val javaVersion = JavaVersion.VERSION_17
+ const val jvmTarget = "17"
+}
\ No newline at end of file
diff --git a/core/analytics/.gitignore b/core/analytics/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/core/analytics/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/analytics/build.gradle.kts b/core/analytics/build.gradle.kts
new file mode 100644
index 0000000..b22b73a
--- /dev/null
+++ b/core/analytics/build.gradle.kts
@@ -0,0 +1,75 @@
+import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
+
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.android.kotlin)
+ alias(libs.plugins.kapt)
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.hilt.android)
+ alias(libs.plugins.parcelize)
+}
+
+apply {
+ from("$rootDir/base-module.gradle")
+}
+
+android {
+ namespace = "com.aliumujib.analytics"
+ compileSdk = AndroidConfig.compileSDK
+
+ defaultConfig {
+ minSdk = AndroidConfig.minSDK
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ buildTypes {
+ debug {
+ val mixPanelToken: String = gradleLocalProperties(rootDir).getProperty("MIXPANEL_TOKEN") ?: ""
+ buildConfigField("String", "MIXPANEL_TOKEN", "\"$mixPanelToken\"")
+ }
+
+ getByName("release") {
+ isMinifyEnabled = true
+
+ val mixPanelToken: String = gradleLocalProperties(rootDir).getProperty("MIXPANEL_TOKEN") ?: ""
+ buildConfigField("String", "MIXPANEL_TOKEN", "\"$mixPanelToken\"")
+
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = AndroidConfig.javaVersion
+ targetCompatibility = AndroidConfig.javaVersion
+ }
+
+ kotlinOptions {
+ jvmTarget = AndroidConfig.jvmTarget
+ }
+
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ // Mixpanel
+ implementation(libs.mixpanel)
+}
diff --git a/core/analytics/consumer-rules.pro b/core/analytics/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/core/analytics/proguard-rules.pro b/core/analytics/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/core/analytics/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/core/analytics/src/main/java/com/aliumujib/analytics/data/repository/AnalyticsRepositoryImpl.kt b/core/analytics/src/main/java/com/aliumujib/analytics/data/repository/AnalyticsRepositoryImpl.kt
new file mode 100644
index 0000000..e187d56
--- /dev/null
+++ b/core/analytics/src/main/java/com/aliumujib/analytics/data/repository/AnalyticsRepositoryImpl.kt
@@ -0,0 +1,26 @@
+package com.aliumujib.analytics.data.repository
+
+import com.aliumujib.analytics.BuildConfig
+import com.aliumujib.analytics.domain.repository.AnalyticsRepository
+import com.mixpanel.android.mpmetrics.MixpanelAPI
+import org.json.JSONObject
+
+class AnalyticsRepositoryImpl(
+ private val mixpanelAPI: MixpanelAPI
+) : AnalyticsRepository {
+
+ override fun trackUserEvent(eventName: String, eventProperties: JSONObject?) {
+ if (BuildConfig.BUILD_TYPE != "release") return
+ eventProperties
+ ?.let { mixpanelAPI.track(eventName, eventProperties) }
+ ?: mixpanelAPI.track(eventName)
+ }
+
+ override fun setUserProfile(userID: String, userProperties: JSONObject?) {
+ userProperties
+ ?.let {
+ mixpanelAPI.identify(userID)
+ mixpanelAPI.people.set(it)
+ } ?: mixpanelAPI.identify(userID)
+ }
+}
diff --git a/core/analytics/src/main/java/com/aliumujib/analytics/di/AnalyticsModule.kt b/core/analytics/src/main/java/com/aliumujib/analytics/di/AnalyticsModule.kt
new file mode 100644
index 0000000..c00d6e4
--- /dev/null
+++ b/core/analytics/src/main/java/com/aliumujib/analytics/di/AnalyticsModule.kt
@@ -0,0 +1,30 @@
+package com.aliumujib.analytics.di
+
+import android.content.Context
+import com.aliumujib.analytics.BuildConfig
+import com.aliumujib.analytics.data.repository.AnalyticsRepositoryImpl
+import com.aliumujib.analytics.domain.repository.AnalyticsRepository
+import com.mixpanel.android.mpmetrics.MixpanelAPI
+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)
+object AnalyticsModule {
+
+ @Singleton
+ @Provides
+ fun providesMixPaneApi(@ApplicationContext context: Context): MixpanelAPI {
+ return MixpanelAPI.getInstance(context, BuildConfig.MIXPANEL_TOKEN, false)
+ }
+
+ @Singleton
+ @Provides
+ fun providesAnalyticsRepository(mixpanelAPI: MixpanelAPI): AnalyticsRepository {
+ return AnalyticsRepositoryImpl(mixpanelAPI)
+ }
+}
diff --git a/core/analytics/src/main/java/com/aliumujib/analytics/domain/repository/AnalyticsRepository.kt b/core/analytics/src/main/java/com/aliumujib/analytics/domain/repository/AnalyticsRepository.kt
new file mode 100644
index 0000000..d7d0e0a
--- /dev/null
+++ b/core/analytics/src/main/java/com/aliumujib/analytics/domain/repository/AnalyticsRepository.kt
@@ -0,0 +1,8 @@
+package com.aliumujib.analytics.domain.repository
+
+import org.json.JSONObject
+
+interface AnalyticsRepository {
+ fun trackUserEvent(eventName: String, eventProperties: JSONObject? = null)
+ fun setUserProfile(userID: String, userProperties: JSONObject?)
+}
diff --git a/core/analytics/src/main/java/com/aliumujib/analytics/domain/usecase/SetUserProfileUseCase.kt b/core/analytics/src/main/java/com/aliumujib/analytics/domain/usecase/SetUserProfileUseCase.kt
new file mode 100644
index 0000000..0c31de7
--- /dev/null
+++ b/core/analytics/src/main/java/com/aliumujib/analytics/domain/usecase/SetUserProfileUseCase.kt
@@ -0,0 +1,12 @@
+package com.aliumujib.analytics.domain.usecase
+
+import com.aliumujib.analytics.domain.repository.AnalyticsRepository
+import org.json.JSONObject
+import javax.inject.Inject
+
+class SetUserProfileUseCase @Inject constructor(
+ private val analyticsRepository: AnalyticsRepository
+) {
+ operator fun invoke(userID: String, userProperties: JSONObject?) =
+ analyticsRepository.setUserProfile(userID, userProperties)
+}
diff --git a/core/analytics/src/main/java/com/aliumujib/analytics/domain/usecase/TrackUserEventUseCase.kt b/core/analytics/src/main/java/com/aliumujib/analytics/domain/usecase/TrackUserEventUseCase.kt
new file mode 100644
index 0000000..7b632a6
--- /dev/null
+++ b/core/analytics/src/main/java/com/aliumujib/analytics/domain/usecase/TrackUserEventUseCase.kt
@@ -0,0 +1,10 @@
+package com.aliumujib.analytics.domain.usecase
+
+import com.aliumujib.analytics.domain.repository.AnalyticsRepository
+import javax.inject.Inject
+
+class TrackUserEventUseCase @Inject constructor(
+ private val analyticsRepository: AnalyticsRepository
+) {
+ operator fun invoke(name: String) = analyticsRepository.trackUserEvent(name)
+}
diff --git a/core/common-domain/.gitignore b/core/common-domain/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/core/common-domain/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/common-domain/build.gradle.kts b/core/common-domain/build.gradle.kts
new file mode 100644
index 0000000..b397893
--- /dev/null
+++ b/core/common-domain/build.gradle.kts
@@ -0,0 +1,51 @@
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.android.kotlin)
+ alias(libs.plugins.kapt)
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.hilt.android)
+ alias(libs.plugins.parcelize)
+}
+
+apply {
+ from("$rootDir/base-module.gradle")
+}
+
+android {
+ namespace = "com.aliumujib.common.domain"
+
+ compileSdk = AndroidConfig.compileSDK
+
+ defaultConfig {
+ minSdk = AndroidConfig.minSDK
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = AndroidConfig.javaVersion
+ targetCompatibility = AndroidConfig.javaVersion
+ }
+ kotlinOptions {
+ jvmTarget = AndroidConfig.jvmTarget
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ implementation(libs.bundles.testing)
+}
diff --git a/core/common-domain/consumer-rules.pro b/core/common-domain/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/core/common-domain/proguard-rules.pro b/core/common-domain/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/core/common-domain/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/core/common-domain/src/main/AndroidManifest.xml b/core/common-domain/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..44008a4
--- /dev/null
+++ b/core/common-domain/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/core/common-domain/src/main/java/com/aliumujib/common/domain/usecases/FlowUseCase.kt b/core/common-domain/src/main/java/com/aliumujib/common/domain/usecases/FlowUseCase.kt
new file mode 100644
index 0000000..400b55a
--- /dev/null
+++ b/core/common-domain/src/main/java/com/aliumujib/common/domain/usecases/FlowUseCase.kt
@@ -0,0 +1,22 @@
+package com.aliumujib.common.domain.usecases
+
+import com.aliumujib.common.domain.utils.DispatcherProvider
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOn
+
+abstract class FlowUseCase constructor(
+ private val dispatcherProvider: DispatcherProvider,
+) {
+
+ /**
+ * Function which builds Flow instance based on given arguments
+ * @param params initial use case arguments
+ */
+ abstract fun build(params: Params? = null): Flow
+
+ operator fun invoke(params: Params? = null): Flow {
+ return this.build(params)
+ .flowOn(dispatcherProvider.io)
+
+ }
+}
diff --git a/core/common-domain/src/main/java/com/aliumujib/common/domain/usecases/NoParamsException.kt b/core/common-domain/src/main/java/com/aliumujib/common/domain/usecases/NoParamsException.kt
new file mode 100644
index 0000000..d55df82
--- /dev/null
+++ b/core/common-domain/src/main/java/com/aliumujib/common/domain/usecases/NoParamsException.kt
@@ -0,0 +1,5 @@
+package com.aliumujib.common.domain.usecases
+
+import java.lang.Exception
+
+class NoParamsException : Exception()
diff --git a/core/common-domain/src/main/java/com/aliumujib/common/domain/usecases/SuspendUseCase.kt b/core/common-domain/src/main/java/com/aliumujib/common/domain/usecases/SuspendUseCase.kt
new file mode 100644
index 0000000..de1e212
--- /dev/null
+++ b/core/common-domain/src/main/java/com/aliumujib/common/domain/usecases/SuspendUseCase.kt
@@ -0,0 +1,25 @@
+package com.aliumujib.common.domain.usecases
+
+import com.aliumujib.common.domain.utils.DispatcherProvider
+import kotlinx.coroutines.withContext
+
+abstract class SuspendUseCase(
+ private val dispatcherProvider: DispatcherProvider,
+) {
+
+ suspend operator fun invoke(params: P? = null): Result {
+ return withContext(dispatcherProvider.io) {
+ try {
+ Result.success(execute(params))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+ }
+
+ /**
+ * Override this to set the code to be executed.
+ */
+ @Throws(RuntimeException::class)
+ abstract suspend fun execute(params: P?): R
+}
diff --git a/core/common-domain/src/main/java/com/aliumujib/common/domain/utils/CoroutinesExt.kt b/core/common-domain/src/main/java/com/aliumujib/common/domain/utils/CoroutinesExt.kt
new file mode 100644
index 0000000..f6bd3c7
--- /dev/null
+++ b/core/common-domain/src/main/java/com/aliumujib/common/domain/utils/CoroutinesExt.kt
@@ -0,0 +1,9 @@
+package com.aliumujib.common.domain.utils
+
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+
+suspend fun Iterable .asyncMap(f: suspend (A) -> B?): List = coroutineScope {
+ map { async { f(it) } }.awaitAll()
+}
\ No newline at end of file
diff --git a/core/common-domain/src/main/java/com/aliumujib/common/domain/utils/DispatcherProvider.kt b/core/common-domain/src/main/java/com/aliumujib/common/domain/utils/DispatcherProvider.kt
new file mode 100644
index 0000000..fb209d3
--- /dev/null
+++ b/core/common-domain/src/main/java/com/aliumujib/common/domain/utils/DispatcherProvider.kt
@@ -0,0 +1,12 @@
+package com.aliumujib.common.domain.utils
+
+import kotlinx.coroutines.CoroutineDispatcher
+
+interface DispatcherProvider {
+
+ val ui: CoroutineDispatcher
+
+ val io: CoroutineDispatcher
+
+ val default: CoroutineDispatcher
+}
diff --git a/core/common-test/.gitignore b/core/common-test/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/core/common-test/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/common-test/build.gradle.kts b/core/common-test/build.gradle.kts
new file mode 100644
index 0000000..5fb0c6e
--- /dev/null
+++ b/core/common-test/build.gradle.kts
@@ -0,0 +1,53 @@
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.android.kotlin)
+ alias(libs.plugins.kapt)
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.hilt.android)
+ alias(libs.plugins.parcelize)
+}
+
+apply {
+ from("$rootDir/base-module.gradle")
+}
+
+android {
+ namespace = "com.aliumujib.common.test"
+
+ compileSdk = AndroidConfig.compileSDK
+
+ defaultConfig {
+ minSdk = AndroidConfig.minSDK
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = AndroidConfig.javaVersion
+ targetCompatibility = AndroidConfig.javaVersion
+ }
+ kotlinOptions {
+ jvmTarget = AndroidConfig.jvmTarget
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ implementation(libs.bundles.testing)
+ implementation(projects.core.commonDomain)
+ implementation(projects.core.models)
+}
diff --git a/core/common-test/consumer-rules.pro b/core/common-test/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/core/common-test/proguard-rules.pro b/core/common-test/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/core/common-test/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/core/common-test/src/main/AndroidManifest.xml b/core/common-test/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..44008a4
--- /dev/null
+++ b/core/common-test/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/core/common-test/src/main/java/com/aliumujib/common/test/DummyData.kt b/core/common-test/src/main/java/com/aliumujib/common/test/DummyData.kt
new file mode 100644
index 0000000..7700d0c
--- /dev/null
+++ b/core/common-test/src/main/java/com/aliumujib/common/test/DummyData.kt
@@ -0,0 +1,154 @@
+package com.aliumujib.common.test
+
+import com.aliumujib.model.Attributes
+import com.aliumujib.model.Breed
+import com.aliumujib.model.BreedId
+import com.aliumujib.model.Characteristics
+import com.aliumujib.model.Urls
+import com.aliumujib.model.Weight
+
+object SharedDummyData {
+
+ val breed1 = Breed(
+ id = BreedId("abys"),
+ name = "Abyssinian",
+ weight = Weight(imperial = "7 - 10", metric = "3 - 5"),
+ urls = Urls(
+ cfaUrl = "http://cfa.org/Breeds/BreedsAB/Abyssinian.aspx",
+ vetstreetUrl = "http://www.vetstreet.com/cats/abyssinian",
+ vcahospitalsUrl = "https://vcahospitals.com/know-your-pet/cat-breeds/abyssinian"
+ ),
+ attributes = Attributes(
+ temperament = "Active, Energetic, Independent, Intelligent, Gentle",
+ origin = "Egypt",
+ countryCodes = "EG",
+ countryCode = "EG",
+ description = "The Abyssinian is easy to care for, and a joy to have in your home. Theyβre affectionate cats and love both people and other animals.",
+ lifeSpan = "14 - 15",
+ indoor = 0,
+ lap = 1,
+ altNames = ""
+ ),
+ characteristics = Characteristics(
+ adaptability = 5,
+ affectionLevel = 5,
+ childFriendly = 3,
+ dogFriendly = 4,
+ energyLevel = 5,
+ grooming = 1,
+ healthIssues = 2,
+ intelligence = 5,
+ sheddingLevel = 2,
+ socialNeeds = 5,
+ strangerFriendly = 5,
+ vocalisation = 1,
+ experimental = 0,
+ hairless = 0,
+ natural = 1,
+ rare = 0,
+ rex = 0,
+ suppressedTail = 0,
+ shortLegs = 0
+ ),
+ wikipediaUrl = "https://en.wikipedia.org/wiki/Abyssinian_(cat)",
+ hypoallergenic = 0,
+ referenceImageUrl = "0XYvRd7oD",
+ isFavorite = true
+ )
+
+ val breed2 = Breed(
+ id = BreedId("aege"),
+ name = "Aegean",
+ weight = Weight(imperial = "7 - 10", metric = "3 - 5"),
+ urls = Urls(
+ cfaUrl = "",
+ vetstreetUrl = "http://www.vetstreet.com/cats/aegean",
+ vcahospitalsUrl = ""
+ ),
+ attributes = Attributes(
+ temperament = "Affectionate, Social, Intelligent, Playful, Active",
+ origin = "Greece",
+ countryCodes = "GR",
+ countryCode = "GR",
+ description = "Native to the Greek islands known as the Cyclades, the Aegean cat is considered a national treasure.",
+ lifeSpan = "9 - 12",
+ indoor = 0,
+ lap = 1,
+ altNames = ""
+ ),
+ characteristics = Characteristics(
+ adaptability = 5,
+ affectionLevel = 4,
+ childFriendly = 4,
+ dogFriendly = 4,
+ energyLevel = 3,
+ grooming = 3,
+ healthIssues = 1,
+ intelligence = 5,
+ sheddingLevel = 3,
+ socialNeeds = 4,
+ strangerFriendly = 5,
+ vocalisation = 3,
+ experimental = 0,
+ hairless = 0,
+ natural = 1,
+ rare = 0,
+ rex = 0,
+ suppressedTail = 0,
+ shortLegs = 0
+ ),
+ wikipediaUrl = "https://en.wikipedia.org/wiki/Aegean_cat",
+ hypoallergenic = 0,
+ referenceImageUrl = "ozEvzdVM-",
+ isFavorite = true
+ )
+
+ val breed3 = Breed(
+ id = BreedId("abob"),
+ name = "American Bobtail",
+ weight = Weight(imperial = "7 - 16", metric = "3 - 7"),
+ urls = Urls(
+ cfaUrl = "http://cfa.org/Breeds/BreedsAB/AmericanBobtail.aspx",
+ vetstreetUrl = "http://www.vetstreet.com/cats/american-bobtail",
+ vcahospitalsUrl = "https://vcahospitals.com/know-your-pet/cat-breeds/american-bobtail"
+ ),
+ attributes = Attributes(
+ temperament = "Intelligent, Interactive, Lively, Playful, Sensitive",
+ origin = "United States",
+ countryCodes = "US",
+ countryCode = "US",
+ description = "American Bobtails are loving and intelligent cats, known for their distinctive bobbed tails.",
+ lifeSpan = "11 - 15",
+ indoor = 0,
+ lap = 1,
+ altNames = ""
+ ),
+ characteristics = Characteristics(
+ adaptability = 5,
+ affectionLevel = 5,
+ childFriendly = 4,
+ dogFriendly = 5,
+ energyLevel = 3,
+ grooming = 3,
+ healthIssues = 2,
+ intelligence = 5,
+ sheddingLevel = 3,
+ socialNeeds = 5,
+ strangerFriendly = 3,
+ vocalisation = 3,
+ experimental = 0,
+ hairless = 0,
+ natural = 0,
+ rare = 0,
+ rex = 0,
+ suppressedTail = 1,
+ shortLegs = 0
+ ),
+ wikipediaUrl = "https://en.wikipedia.org/wiki/American_Bobtail",
+ hypoallergenic = 0,
+ referenceImageUrl = "hBXicehMA",
+ isFavorite = true
+ )
+
+ val breedList = listOf(breed1, breed2, breed3)
+}
diff --git a/core/common-test/src/main/java/com/aliumujib/common/test/MainCoroutineRule.kt b/core/common-test/src/main/java/com/aliumujib/common/test/MainCoroutineRule.kt
new file mode 100644
index 0000000..24b3900
--- /dev/null
+++ b/core/common-test/src/main/java/com/aliumujib/common/test/MainCoroutineRule.kt
@@ -0,0 +1,25 @@
+package com.aliumujib.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.rules.TestWatcher
+import org.junit.runner.Description
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MainCoroutineRule : TestWatcher() {
+
+ override fun starting(description: Description?) {
+ super.starting(description)
+ val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ override fun finished(description: Description?) {
+ super.finished(description)
+ Dispatchers.resetMain()
+ }
+}
\ No newline at end of file
diff --git a/core/common-test/src/main/java/com/aliumujib/common/test/TestDispatcherProviderImpl.kt b/core/common-test/src/main/java/com/aliumujib/common/test/TestDispatcherProviderImpl.kt
new file mode 100644
index 0000000..c1c03e3
--- /dev/null
+++ b/core/common-test/src/main/java/com/aliumujib/common/test/TestDispatcherProviderImpl.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2020 Abdul-Mujeeb Aliu
+ *
+ * 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
+ *
+ * http://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.
+ */
+package com.aliumujib.common.test
+
+import com.aliumujib.common.domain.utils.DispatcherProvider
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import javax.inject.Inject
+
+class TestDispatcherProviderImpl @OptIn(ExperimentalCoroutinesApi::class) constructor(
+ coroutineDispatcher: CoroutineDispatcher = UnconfinedTestDispatcher()
+) : DispatcherProvider {
+
+ override val ui: CoroutineDispatcher = coroutineDispatcher
+ override val io: CoroutineDispatcher = coroutineDispatcher
+ override val default: CoroutineDispatcher = coroutineDispatcher
+
+}
diff --git a/core/common/.gitignore b/core/common/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/core/common/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts
new file mode 100644
index 0000000..9e915be
--- /dev/null
+++ b/core/common/build.gradle.kts
@@ -0,0 +1,50 @@
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.android.kotlin)
+ alias(libs.plugins.kapt)
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.hilt.android)
+ alias(libs.plugins.parcelize)
+}
+
+apply {
+ from("$rootDir/base-module.gradle")
+}
+
+android {
+ namespace = "com.aliumujib.common"
+
+ compileSdk = AndroidConfig.compileSDK
+
+ defaultConfig {
+ minSdk = AndroidConfig.minSDK
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = AndroidConfig.javaVersion
+ targetCompatibility = AndroidConfig.javaVersion
+ }
+ kotlinOptions {
+ jvmTarget = AndroidConfig.jvmTarget
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+}
diff --git a/core/common/consumer-rules.pro b/core/common/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/core/common/proguard-rules.pro b/core/common/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/core/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/core/common/src/main/java/com/aliumujib/common/di/AppModule.kt b/core/common/src/main/java/com/aliumujib/common/di/AppModule.kt
new file mode 100644
index 0000000..652e187
--- /dev/null
+++ b/core/common/src/main/java/com/aliumujib/common/di/AppModule.kt
@@ -0,0 +1,18 @@
+package com.aliumujib.common.di
+
+import com.google.gson.Gson
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AppModule {
+
+ @Provides
+ @Singleton
+ fun provideGson() = Gson()
+
+}
diff --git a/core/common/src/main/java/com/aliumujib/common/state/TextFieldState.kt b/core/common/src/main/java/com/aliumujib/common/state/TextFieldState.kt
new file mode 100644
index 0000000..e81767b
--- /dev/null
+++ b/core/common/src/main/java/com/aliumujib/common/state/TextFieldState.kt
@@ -0,0 +1,6 @@
+package com.aliumujib.common.state
+
+data class TextFieldState(
+ val text: String = "",
+ val error: String? = null
+)
diff --git a/core/common/src/main/java/com/aliumujib/common/util/UtilFunctions.kt b/core/common/src/main/java/com/aliumujib/common/util/UtilFunctions.kt
new file mode 100644
index 0000000..39e0dfa
--- /dev/null
+++ b/core/common/src/main/java/com/aliumujib/common/util/UtilFunctions.kt
@@ -0,0 +1,43 @@
+package com.aliumujib.common.util
+
+import android.content.Context
+import android.content.pm.PackageManager
+import timber.log.Timber
+import java.util.Locale
+
+fun getAppVersionName(context: Context): String {
+ var versionName = ""
+ try {
+ val info = context.packageManager?.getPackageInfo(context.packageName, 0)
+ versionName = info?.versionName ?: ""
+ } catch (e: PackageManager.NameNotFoundException) {
+ Timber.e(e.message)
+ }
+ return versionName
+}
+
+fun Long.formatBinarySize(): String {
+ val kiloByteAsByte = 1.0 * 1024.0
+ val megaByteAsByte = 1.0 * 1024.0 * 1024.0
+ val gigaByteAsByte = 1.0 * 1024.0 * 1024.0 * 1024.0
+ return when {
+ this < kiloByteAsByte -> "${this.toDouble()} B"
+ this >= kiloByteAsByte && this < megaByteAsByte -> "${
+ String.format(
+ Locale.getDefault(),
+ "%.2f",
+ (this / kiloByteAsByte)
+ )
+ } KB"
+
+ this >= megaByteAsByte && this < gigaByteAsByte -> "${
+ String.format(
+ Locale.getDefault(),
+ "%.2f",
+ (this / megaByteAsByte)
+ )
+ } MB"
+
+ else -> "Bigger than 1024 TB"
+ }
+}
\ No newline at end of file
diff --git a/core/common/src/main/res/drawable/splash_image.png b/core/common/src/main/res/drawable/splash_image.png
new file mode 100644
index 0000000..1bbe20d
Binary files /dev/null and b/core/common/src/main/res/drawable/splash_image.png differ
diff --git a/core/database/.gitignore b/core/database/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/core/database/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts
new file mode 100644
index 0000000..d0e733b
--- /dev/null
+++ b/core/database/build.gradle.kts
@@ -0,0 +1,64 @@
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.android.kotlin)
+ alias(libs.plugins.parcelize)
+ alias(libs.plugins.ksp)
+}
+
+apply {
+ from("$rootDir/base-module.gradle")
+}
+
+android {
+ namespace = "com.aliumujib.database"
+
+ compileSdk = AndroidConfig.compileSDK
+
+ defaultConfig {
+ minSdk = AndroidConfig.minSDK
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+
+ ksp {
+ arg("room.schemaLocation", "$projectDir/schemas")
+ }
+ }
+
+ sourceSets {
+ getByName("androidTest").assets.srcDirs(files("$projectDir/schemas")) // Room
+ }
+
+ compileOptions {
+ sourceCompatibility = AndroidConfig.javaVersion
+ targetCompatibility = AndroidConfig.javaVersion
+ }
+ kotlinOptions {
+ jvmTarget = AndroidConfig.jvmTarget
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ implementation(projects.core.common)
+
+ // Room
+ implementation(libs.room.runtime)
+ ksp(libs.room.compiler)
+ implementation(libs.room.ktx)
+ testImplementation(libs.room.testing)
+ androidTestImplementation(libs.room.testing)
+}
diff --git a/core/database/consumer-rules.pro b/core/database/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/core/database/proguard-rules.pro b/core/database/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/core/database/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/core/database/schemas/com.aliumujib.database.AppDatabase/1.json b/core/database/schemas/com.aliumujib.database.AppDatabase/1.json
new file mode 100644
index 0000000..d59b92c
--- /dev/null
+++ b/core/database/schemas/com.aliumujib.database.AppDatabase/1.json
@@ -0,0 +1,276 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 1,
+ "identityHash": "2ec3e324293f1c9bc3266a1ffb43c8d0",
+ "entities": [
+ {
+ "tableName": "breeds",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `weightImperial` TEXT NOT NULL, `weightMetric` TEXT NOT NULL, `cfaUrl` TEXT NOT NULL, `vetstreetUrl` TEXT NOT NULL, `vcahospitalsUrl` TEXT NOT NULL, `temperament` TEXT NOT NULL, `origin` TEXT NOT NULL, `countryCodes` TEXT NOT NULL, `countryCode` TEXT NOT NULL, `description` TEXT NOT NULL, `lifeSpan` TEXT NOT NULL, `indoor` INTEGER NOT NULL, `lap` INTEGER NOT NULL, `altNames` TEXT NOT NULL, `adaptability` INTEGER NOT NULL, `affectionLevel` INTEGER NOT NULL, `childFriendly` INTEGER NOT NULL, `dogFriendly` INTEGER NOT NULL, `energyLevel` INTEGER NOT NULL, `grooming` INTEGER NOT NULL, `healthIssues` INTEGER NOT NULL, `intelligence` INTEGER NOT NULL, `sheddingLevel` INTEGER NOT NULL, `socialNeeds` INTEGER NOT NULL, `strangerFriendly` INTEGER NOT NULL, `vocalisation` INTEGER NOT NULL, `experimental` INTEGER NOT NULL, `hairless` INTEGER NOT NULL, `natural` INTEGER NOT NULL, `rare` INTEGER NOT NULL, `rex` INTEGER NOT NULL, `suppressedTail` INTEGER NOT NULL, `shortLegs` INTEGER NOT NULL, `wikipediaUrl` TEXT NOT NULL, `hypoallergenic` INTEGER NOT NULL, `referenceImageUrl` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "weightImperial",
+ "columnName": "weightImperial",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "weightMetric",
+ "columnName": "weightMetric",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "cfaUrl",
+ "columnName": "cfaUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "vetstreetUrl",
+ "columnName": "vetstreetUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "vcahospitalsUrl",
+ "columnName": "vcahospitalsUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "temperament",
+ "columnName": "temperament",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "origin",
+ "columnName": "origin",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "countryCodes",
+ "columnName": "countryCodes",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "countryCode",
+ "columnName": "countryCode",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lifeSpan",
+ "columnName": "lifeSpan",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "indoor",
+ "columnName": "indoor",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lap",
+ "columnName": "lap",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "altNames",
+ "columnName": "altNames",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "adaptability",
+ "columnName": "adaptability",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "affectionLevel",
+ "columnName": "affectionLevel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "childFriendly",
+ "columnName": "childFriendly",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dogFriendly",
+ "columnName": "dogFriendly",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "energyLevel",
+ "columnName": "energyLevel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "grooming",
+ "columnName": "grooming",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "healthIssues",
+ "columnName": "healthIssues",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "intelligence",
+ "columnName": "intelligence",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sheddingLevel",
+ "columnName": "sheddingLevel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "socialNeeds",
+ "columnName": "socialNeeds",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "strangerFriendly",
+ "columnName": "strangerFriendly",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "vocalisation",
+ "columnName": "vocalisation",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "experimental",
+ "columnName": "experimental",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hairless",
+ "columnName": "hairless",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "natural",
+ "columnName": "natural",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rare",
+ "columnName": "rare",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rex",
+ "columnName": "rex",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "suppressedTail",
+ "columnName": "suppressedTail",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shortLegs",
+ "columnName": "shortLegs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "wikipediaUrl",
+ "columnName": "wikipediaUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hypoallergenic",
+ "columnName": "hypoallergenic",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "referenceImageUrl",
+ "columnName": "referenceImageUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "favorites",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`breedId` TEXT NOT NULL, PRIMARY KEY(`breedId`))",
+ "fields": [
+ {
+ "fieldPath": "breedId",
+ "columnName": "breedId",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "breedId"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2ec3e324293f1c9bc3266a1ffb43c8d0')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/core/database/src/main/java/com/aliumujib/database/AppDatabase.kt b/core/database/src/main/java/com/aliumujib/database/AppDatabase.kt
new file mode 100644
index 0000000..15f0c87
--- /dev/null
+++ b/core/database/src/main/java/com/aliumujib/database/AppDatabase.kt
@@ -0,0 +1,23 @@
+package com.aliumujib.database
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import com.aliumujib.database.dao.FavoritesDAO
+import com.aliumujib.database.dao.BreedsDAO
+import com.aliumujib.database.model.BreedDBModel
+import com.aliumujib.database.model.FavoritesDBModel
+
+@Database(
+ entities = [
+ BreedDBModel::class,
+ FavoritesDBModel::class
+ ],
+ version = 1,
+ exportSchema = true
+)
+abstract class AppDatabase : RoomDatabase() {
+ abstract val breedsDao: BreedsDAO
+
+ abstract val favoritesDAO: FavoritesDAO
+
+}
diff --git a/core/database/src/main/java/com/aliumujib/database/Constants.kt b/core/database/src/main/java/com/aliumujib/database/Constants.kt
new file mode 100644
index 0000000..8d96c5c
--- /dev/null
+++ b/core/database/src/main/java/com/aliumujib/database/Constants.kt
@@ -0,0 +1,6 @@
+package com.aliumujib.database
+
+object Constants {
+ const val BREEDS_TABLE = "breeds"
+ const val APP_DATABASE = "APP_DATABASE"
+}
diff --git a/core/database/src/main/java/com/aliumujib/database/dao/BreedsDAO.kt b/core/database/src/main/java/com/aliumujib/database/dao/BreedsDAO.kt
new file mode 100644
index 0000000..a85141c
--- /dev/null
+++ b/core/database/src/main/java/com/aliumujib/database/dao/BreedsDAO.kt
@@ -0,0 +1,27 @@
+package com.aliumujib.database.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import com.aliumujib.database.model.BreedDBModel
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface BreedsDAO {
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun saveBreeds(vararg breeds: BreedDBModel)
+
+ @Query("SELECT * FROM breeds")
+ suspend fun getAllBreeds(): List
+
+ @Query("SELECT * FROM breeds")
+ fun streamBreedsList(): Flow>
+
+ @Query("SELECT * FROM breeds WHERE id = :id")
+ fun streamBreedDetails(id: String): Flow
+
+ @Query("DELETE FROM breeds WHERE id = :id")
+ suspend fun removeBreed(id: String)
+}
+
diff --git a/core/database/src/main/java/com/aliumujib/database/dao/FavoritesDAO.kt b/core/database/src/main/java/com/aliumujib/database/dao/FavoritesDAO.kt
new file mode 100644
index 0000000..0a07348
--- /dev/null
+++ b/core/database/src/main/java/com/aliumujib/database/dao/FavoritesDAO.kt
@@ -0,0 +1,29 @@
+package com.aliumujib.database.dao
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Transaction
+import com.aliumujib.database.model.FavoritesDBModel
+import com.aliumujib.database.model.FavoritesWithBreeds
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface FavoritesDAO {
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun addFavorite(favorite: FavoritesDBModel)
+
+ @Delete
+ suspend fun removeFavorite(favorite: FavoritesDBModel)
+
+ @Query("SELECT EXISTS(SELECT 1 FROM favorites WHERE breedId = :id)")
+ suspend fun isFavorite(id: String): Boolean
+
+ @Transaction
+ @Query("SELECT * FROM favorites")
+ fun streamAllFavoritesWithBreeds(): Flow>
+
+}
diff --git a/core/database/src/main/java/com/aliumujib/database/di/DatabaseModule.kt b/core/database/src/main/java/com/aliumujib/database/di/DatabaseModule.kt
new file mode 100644
index 0000000..ab0690c
--- /dev/null
+++ b/core/database/src/main/java/com/aliumujib/database/di/DatabaseModule.kt
@@ -0,0 +1,38 @@
+package com.aliumujib.database.di
+
+import android.content.Context
+import androidx.room.Room
+import com.aliumujib.database.Constants
+import com.aliumujib.database.AppDatabase
+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)
+object DatabaseModule {
+
+ @Provides
+ @Singleton
+ fun providesDatabase(
+ @ApplicationContext context: Context,
+ ): AppDatabase {
+ return Room.databaseBuilder(
+ context.applicationContext,
+ AppDatabase::class.java,
+ Constants.APP_DATABASE
+ ).fallbackToDestructiveMigration()
+ .build()
+ }
+
+ @Provides
+ @Singleton
+ fun providesBreedsDao(database: AppDatabase) = database.breedsDao
+
+ @Provides
+ @Singleton
+ fun providesFavoritesDAO(database: AppDatabase) = database.favoritesDAO
+}
diff --git a/core/database/src/main/java/com/aliumujib/database/model/BreedsDBModel.kt b/core/database/src/main/java/com/aliumujib/database/model/BreedsDBModel.kt
new file mode 100644
index 0000000..b6b85c2
--- /dev/null
+++ b/core/database/src/main/java/com/aliumujib/database/model/BreedsDBModel.kt
@@ -0,0 +1,48 @@
+package com.aliumujib.database.model
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import com.aliumujib.database.Constants.BREEDS_TABLE
+
+@Entity(tableName = BREEDS_TABLE)
+data class BreedDBModel(
+ @PrimaryKey
+ val id: String,
+ val name: String,
+ val weightImperial: String,
+ val weightMetric: String,
+ val cfaUrl: String,
+ val vetstreetUrl: String,
+ val vcahospitalsUrl: String,
+ val temperament: String,
+ val origin: String,
+ val countryCodes: String,
+ val countryCode: String,
+ val description: String,
+ val lifeSpan: String,
+ val indoor: Int,
+ val lap: Int,
+ val altNames: String,
+ val adaptability: Int,
+ val affectionLevel: Int,
+ val childFriendly: Int,
+ val dogFriendly: Int,
+ val energyLevel: Int,
+ val grooming: Int,
+ val healthIssues: Int,
+ val intelligence: Int,
+ val sheddingLevel: Int,
+ val socialNeeds: Int,
+ val strangerFriendly: Int,
+ val vocalisation: Int,
+ val experimental: Int,
+ val hairless: Int,
+ val natural: Int,
+ val rare: Int,
+ val rex: Int,
+ val suppressedTail: Int,
+ val shortLegs: Int,
+ val wikipediaUrl: String,
+ val hypoallergenic: Int,
+ val referenceImageUrl: String
+)
\ No newline at end of file
diff --git a/core/database/src/main/java/com/aliumujib/database/model/FavoritesDBModel.kt b/core/database/src/main/java/com/aliumujib/database/model/FavoritesDBModel.kt
new file mode 100644
index 0000000..e7c3bc4
--- /dev/null
+++ b/core/database/src/main/java/com/aliumujib/database/model/FavoritesDBModel.kt
@@ -0,0 +1,21 @@
+package com.aliumujib.database.model
+
+import androidx.room.Embedded
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import androidx.room.Relation
+
+@Entity(tableName = "favorites")
+data class FavoritesDBModel(
+ @PrimaryKey val breedId: String
+)
+
+data class FavoritesWithBreeds(
+ @Embedded val favorite: FavoritesDBModel,
+ @Relation(
+ parentColumn = "breedId",
+ entityColumn = "id",
+ entity = BreedDBModel::class
+ )
+ val breed: BreedDBModel?
+)
\ No newline at end of file
diff --git a/core/designsystem/.gitignore b/core/designsystem/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/core/designsystem/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts
new file mode 100644
index 0000000..e4a1313
--- /dev/null
+++ b/core/designsystem/build.gradle.kts
@@ -0,0 +1,55 @@
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.android.kotlin)
+ alias(libs.plugins.kapt)
+ alias(libs.plugins.hilt.android)
+ alias(libs.plugins.parcelize)
+ alias(libs.plugins.ksp)
+}
+
+apply {
+ from("$rootDir/base-module.gradle")
+}
+
+android {
+ namespace = "com.aliumujib.designsystem"
+
+ compileSdk = AndroidConfig.compileSDK
+
+ defaultConfig {
+ minSdk = AndroidConfig.minSDK
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = AndroidConfig.javaVersion
+ targetCompatibility = AndroidConfig.javaVersion
+ }
+ kotlinOptions {
+ jvmTarget = AndroidConfig.jvmTarget
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ implementation(libs.graphics.shapes)
+ implementation(libs.compose.animation)
+
+ implementation(libs.appcompat)
+ implementation(libs.accompanist.system.ui.controller)
+}
diff --git a/core/designsystem/consumer-rules.pro b/core/designsystem/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/core/designsystem/proguard-rules.pro b/core/designsystem/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/core/designsystem/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/core/designsystem/src/main/java/com/aliumujib/designsystem/Constants.kt b/core/designsystem/src/main/java/com/aliumujib/designsystem/Constants.kt
new file mode 100644
index 0000000..4c6adda
--- /dev/null
+++ b/core/designsystem/src/main/java/com/aliumujib/designsystem/Constants.kt
@@ -0,0 +1,5 @@
+package com.aliumujib.designsystem
+
+object Constants {
+ const val DISABLE_ALPHA = 0.5f
+}
\ No newline at end of file
diff --git a/core/designsystem/src/main/java/com/aliumujib/designsystem/animation/ThreeDotsLoading.kt b/core/designsystem/src/main/java/com/aliumujib/designsystem/animation/ThreeDotsLoading.kt
new file mode 100644
index 0000000..acdd124
--- /dev/null
+++ b/core/designsystem/src/main/java/com/aliumujib/designsystem/animation/ThreeDotsLoading.kt
@@ -0,0 +1,132 @@
+package com.aliumujib.designsystem.animation
+
+import com.aliumujib.designsystem.preview.AppPreview
+import com.aliumujib.designsystem.theme.AppTheme
+import androidx.compose.animation.animateColor
+import androidx.compose.animation.core.EaseIn
+import androidx.compose.animation.core.EaseOut
+import androidx.compose.animation.core.InfiniteRepeatableSpec
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.StartOffset
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.keyframes
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.aliumujib.designsystem.Constants.DISABLE_ALPHA
+
+@Composable
+fun ThreeDotsLoading(
+ modifier: Modifier = Modifier,
+ stableColor: Color = LocalContentColor.current,
+ temporaryColor: Color = LocalContentColor.current.copy(alpha = DISABLE_ALPHA),
+ stableScale: Float = 1f,
+ temporaryScale: Float = 0.9f,
+ circleRadius: Dp = 6.dp,
+) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(circleRadius),
+ modifier = modifier,
+ ) {
+ for (index in (0.. dotInfiniteRepeatableSpec(
+ stableValue: T,
+ temporaryValue: T,
+ startDelay: Int,
+): InfiniteRepeatableSpec = infiniteRepeatable(
+ animation = keyframes {
+ durationMillis = animationDurationMilliseconds * numberOfDots
+ stableValue at 0
+ temporaryValue at animationDurationMilliseconds using EaseIn
+ stableValue at animationDurationMilliseconds * 2 using EaseOut
+ stableValue at durationMillis
+ },
+ repeatMode = RepeatMode.Restart,
+ initialStartOffset = StartOffset(startDelay),
+)
+
+// The duration of half of the animation. During this duration, one item does the "out" animation, and at the same
+// time, the next item does the "in" animation, they happen in parallel.
+private const val animationDurationMilliseconds = 500
+private const val numberOfDots = 3
+
+@AppPreview
+@Composable
+private fun PreviewThreeDotsLoading() {
+ AppTheme {
+ Surface(color = MaterialTheme.colorScheme.primary) {
+ Box(Modifier.padding(6.dp)) {
+ ThreeDotsLoading()
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/designsystem/src/main/java/com/aliumujib/designsystem/components/ContainedButton.kt b/core/designsystem/src/main/java/com/aliumujib/designsystem/components/ContainedButton.kt
new file mode 100644
index 0000000..ca79581
--- /dev/null
+++ b/core/designsystem/src/main/java/com/aliumujib/designsystem/components/ContainedButton.kt
@@ -0,0 +1,204 @@
+package com.aliumujib.designsystem.components
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.core.updateTransition
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.aliumujib.designsystem.animation.ThreeDotsLoading
+import com.aliumujib.designsystem.preview.AppPreview
+import com.aliumujib.designsystem.theme.AppTheme
+import com.aliumujib.designsystem.theme.squircleMedium
+
+@Composable
+fun AppContainedButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ contentPadding: PaddingValues = PaddingValues(16.dp),
+ enabled: Boolean = true,
+ colors: ButtonColors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ disabledContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
+ disabledContentColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.38f),
+ ),
+) {
+ AppContainedButton(
+ onClick = onClick,
+ enabled = enabled,
+ modifier = modifier,
+ contentPadding = contentPadding,
+ colors = colors,
+ ) {
+ ButtonText(text)
+ }
+}
+
+@Composable
+fun AppSecondaryContainedButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ contentPadding: PaddingValues = PaddingValues(16.dp),
+ enabled: Boolean = true,
+ isLoading: Boolean = false,
+ colors: ButtonColors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ contentColor = MaterialTheme.colorScheme.secondaryContainer,
+ disabledContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
+ disabledContentColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.38f),
+ ),
+) {
+ AppContainedButton(
+ text = text,
+ onClick = onClick,
+ enabled = enabled,
+ isLoading = isLoading,
+ modifier = modifier,
+ contentPadding = contentPadding,
+ colors = colors,
+ )
+}
+
+@Composable
+fun AppContainedButton(
+ text: String,
+ onClick: () -> Unit,
+ isLoading: Boolean,
+ modifier: Modifier = Modifier,
+ contentPadding: PaddingValues = PaddingValues(16.dp),
+ enabled: Boolean = true,
+ colors: ButtonColors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ disabledContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
+ disabledContentColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.38f),
+ ),
+) {
+ AppContainedButton(
+ onClick = {
+ if (enabled && !isLoading) {
+ onClick()
+ }
+ },
+ enabled = enabled || isLoading,
+ modifier = modifier,
+ contentPadding = contentPadding,
+ colors = colors,
+ ) {
+ LoadingButton(isLoading, text)
+ }
+}
+
+@Composable
+private fun LoadingButton(isLoading: Boolean, text: String) {
+ val loadingTransition = updateTransition(isLoading, label = "loading transition")
+ loadingTransition.AnimatedContent(
+ transitionSpec = {
+ fadeIn(tween(durationMillis = 220, delayMillis = 90)) togetherWith fadeOut(tween(90))
+ },
+ contentAlignment = Alignment.Center,
+ ) { loading ->
+ if (loading) {
+ Box(
+ contentAlignment = Alignment.Center,
+ ) {
+ // render the text too so that the same space is taken in all cases
+ ButtonText(text, Modifier.alpha(0f))
+ ThreeDotsLoading()
+ }
+ } else {
+ ButtonText(text)
+ }
+ }
+}
+
+@Composable
+fun AppContainedButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ colors: ButtonColors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ disabledContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
+ disabledContentColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.38f),
+ ),
+ contentPadding: PaddingValues = PaddingValues(16.dp),
+ enabled: Boolean = true,
+ content: @Composable RowScope.() -> Unit,
+) {
+ Button(
+ onClick = onClick,
+ enabled = enabled,
+ modifier = modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.squircleMedium,
+ contentPadding = contentPadding,
+ colors = colors,
+ ) {
+ content()
+ }
+}
+
+@Composable
+private fun ButtonText(text: String, modifier: Modifier = Modifier) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ modifier = modifier,
+ )
+}
+
+@AppPreview
+@Composable
+private fun PreviewHedvigContainedButton() {
+ AppTheme {
+ Surface(color = MaterialTheme.colorScheme.background) {
+ AppContainedButton("Hello there", {}, Modifier.padding(24.dp))
+ }
+ }
+}
+
+@AppPreview
+@Composable
+private fun PreviewHedvigSecondaryContainedButton() {
+ AppTheme {
+ Surface(color = MaterialTheme.colorScheme.background) {
+ AppSecondaryContainedButton("Hello there", {}, Modifier.padding(24.dp))
+ }
+ }
+}
+
+@AppPreview
+@Composable
+private fun PreviewLoadingHedvigContainedButton() {
+ AppTheme {
+ Surface(color = MaterialTheme.colorScheme.background) {
+ AppContainedButton(
+ text = "Hello there",
+ onClick = {},
+ isLoading = true,
+ modifier = Modifier.padding(24.dp),
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/designsystem/src/main/java/com/aliumujib/designsystem/components/TextField.kt b/core/designsystem/src/main/java/com/aliumujib/designsystem/components/TextField.kt
new file mode 100644
index 0000000..e998ffa
--- /dev/null
+++ b/core/designsystem/src/main/java/com/aliumujib/designsystem/components/TextField.kt
@@ -0,0 +1,101 @@
+package com.aliumujib.designsystem.components
+
+import android.view.KeyEvent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.aliumujib.designsystem.R
+import io.eyram.iconsax.IconSax
+
+@Composable
+fun BoxyTextField(
+ modifier: Modifier = Modifier,
+ label: String,
+ inputString: String,
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ keyboardActions: KeyboardActions = KeyboardActions.Default,
+ error: String?,
+ loading: Boolean,
+ onInputChanged: (String) -> Unit,
+ onClear: () -> Unit,
+) {
+ OutlinedTextField(
+ value = inputString,
+ onValueChange = onInputChanged,
+ modifier = modifier,
+ label = { Text(label) },
+ trailingIcon = {
+ TrailingIcon(error, loading, inputString, onClear)
+ },
+ isError = error != null,
+ keyboardOptions = keyboardOptions,
+ keyboardActions = keyboardActions,
+ singleLine = true,
+ )
+
+ AnimatedVisibility(visible = error != null) {
+ Text(
+ text = error.orEmpty(),
+ style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.error),
+ modifier = Modifier.padding(horizontal = 16.dp),
+ )
+ }
+}
+
+@Composable
+private fun TrailingIcon(
+ error: String?,
+ loading: Boolean,
+ inputString: String,
+ onClear: () -> Unit
+) {
+ if (error != null) {
+ Icon(
+ painter = painterResource(id = IconSax.Outline.InfoCircle),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error,
+ )
+ } else if (loading) {
+ CircularProgressIndicator(modifier = Modifier.size(24.dp))
+ } else if (inputString.isNotBlank()) {
+ IconButton(onClick = onClear) {
+ Icon(
+ painter = painterResource(id = IconSax.Outline.CloseCircle),
+ contentDescription = stringResource(
+ R.string.icon_description_clear_all,
+ inputString
+ ),
+ )
+ }
+ }
+}
+
+fun Modifier.submitOnEnter(action: () -> Unit) = composed {
+ val keyboardController = LocalSoftwareKeyboardController.current
+ onKeyEvent { keyEvent ->
+ if (keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_ENTER) {
+ keyboardController?.hide()
+ action()
+ true
+ } else {
+ false
+ }
+ }
+}
diff --git a/core/designsystem/src/main/java/com/aliumujib/designsystem/preview/AppPreview.kt b/core/designsystem/src/main/java/com/aliumujib/designsystem/preview/AppPreview.kt
new file mode 100644
index 0000000..ca58682
--- /dev/null
+++ b/core/designsystem/src/main/java/com/aliumujib/designsystem/preview/AppPreview.kt
@@ -0,0 +1,58 @@
+package com.aliumujib.designsystem.preview
+
+import android.content.res.Configuration
+import androidx.compose.ui.tooling.preview.Preview
+
+@Preview(
+ name = "lightMode portrait",
+ uiMode = Configuration.UI_MODE_NIGHT_NO or Configuration.UI_MODE_TYPE_NORMAL,
+)
+@Preview(
+ name = "nightMode portrait",
+ uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL,
+)
+annotation class AppPreview
+
+@Preview(
+ name = "lightMode landscape",
+ locale = "en",
+ uiMode = Configuration.UI_MODE_NIGHT_NO or Configuration.UI_MODE_TYPE_NORMAL,
+ device = "spec:parent=pixel_5,orientation=landscape",
+)
+@Preview(
+ name = "darkMode landscape",
+ locale = "en",
+ uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL,
+ device = "spec:parent=pixel_5,orientation=landscape",
+)
+private annotation class AppLandscapePreview
+
+@Preview(
+ name = "lightMode tablet portrait",
+ uiMode = Configuration.UI_MODE_NIGHT_NO or Configuration.UI_MODE_TYPE_NORMAL,
+ device = "spec:width=1280dp,height=800dp,dpi=240,orientation=portrait",
+)
+@Preview(
+ name = "darkMode tablet portrait",
+ uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL,
+ device = "spec:width=1280dp,height=800dp,dpi=240,orientation=portrait",
+)
+private annotation class AppTabletPreview
+
+@Preview(
+ name = "lightMode tablet landscape",
+ uiMode = Configuration.UI_MODE_NIGHT_NO or Configuration.UI_MODE_TYPE_NORMAL,
+ device = "spec:width=1280dp,height=800dp,dpi=240",
+)
+@Preview(
+ name = "darkMode tablet landscape",
+ uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL,
+ device = "spec:width=1280dp,height=800dp,dpi=240",
+)
+private annotation class AppTabletLandscapePreview
+
+@AppPreview
+@AppLandscapePreview
+@AppTabletPreview
+@AppTabletLandscapePreview
+annotation class AppMultiScreenPreview
\ No newline at end of file
diff --git a/core/designsystem/src/main/java/com/aliumujib/designsystem/theme/Color.kt b/core/designsystem/src/main/java/com/aliumujib/designsystem/theme/Color.kt
new file mode 100644
index 0000000..3ff9c84
--- /dev/null
+++ b/core/designsystem/src/main/java/com/aliumujib/designsystem/theme/Color.kt
@@ -0,0 +1,22 @@
+
+package com.aliumujib.designsystem.theme
+
+import androidx.compose.ui.graphics.Color
+
+val PrimaryColor = Color(0xffFF4500)
+val PrimaryLightColor = Color(0xffffe3d5)
+
+val SecondaryColor = Color(0xff6167FF)
+val SecondaryLightColor = Color(0xFF0E1381)
+
+val PrimaryTextColor = Color(0xffffffff)
+val SecondaryTextColor = Color(0xff000000)
+
+val SurfaceDark = Color(0xFF3A3A3A)
+val SurfaceLight = Color(0xFFFFFFFF)
+
+val BackgroundLightColor = Color(0xffF1F0F5)
+val BackgroundDarkColor = Color(0xff121212)
+
+val ErrorColor = Color(0xFFFF8989)
+val OnErrorColor = Color(0xFF000000)
diff --git a/core/designsystem/src/main/java/com/aliumujib/designsystem/theme/Shape.kt b/core/designsystem/src/main/java/com/aliumujib/designsystem/theme/Shape.kt
new file mode 100644
index 0000000..b145057
--- /dev/null
+++ b/core/designsystem/src/main/java/com/aliumujib/designsystem/theme/Shape.kt
@@ -0,0 +1,88 @@
+package com.aliumujib.designsystem.theme
+
+import androidx.annotation.FloatRange
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Shapes
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Outline
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.PathOperation
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.asComposePath
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.graphics.shapes.CornerRounding
+import androidx.graphics.shapes.RoundedPolygon
+import androidx.graphics.shapes.toPath
+
+val Shapes = Shapes(
+ extraSmall = RoundedCornerShape(2.dp),
+ small = RoundedCornerShape(4.dp),
+ medium = RoundedCornerShape(8.dp),
+ large = RoundedCornerShape(12.dp),
+ extraLarge = RoundedCornerShape(15.dp)
+)
+
+private fun RoundedPolygon.Companion.squircle(
+ width: Float,
+ height: Float,
+ cornerRadius: Float,
+ @FloatRange(from = 0.0, to = 1.0) smoothing: Float,
+): android.graphics.Path {
+ if (width == 0f || height == 0f) {
+ return android.graphics.Path()
+ }
+ @Suppress("ktlint:standard:argument-list-wrapping")
+ return RoundedPolygon(
+ vertices = floatArrayOf(
+ 0f, 0f,
+ width, 0f,
+ width, height,
+ 0f, height,
+ ),
+ rounding = CornerRounding(cornerRadius, smoothing),
+ ).toPath()
+}
+
+internal class FigmaShape(
+ private val radius: Dp,
+ @FloatRange(from = 0.0, to = 1.0) private val smoothing: Float = 1f,
+) : Shape {
+ override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
+ val squirclePath = RoundedPolygon.squircle(
+ width = size.width,
+ height = size.height,
+ cornerRadius = with(density) { radius.toPx() },
+ smoothing = smoothing,
+ )
+ return Outline.Generic(squirclePath.asComposePath())
+ }
+}
+
+val Shapes.squircleMedium: Shape get() = SquircleMedium
+private val SquircleMedium = FigmaShape(12.dp)
+
+/**
+ * Turns the shape into one where only the top corners apply, by combining the path with a square path at the bottom.
+ */
+private fun Shape.top(): Shape = object : Shape {
+ override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
+ val existingShapePath = (this@top.createOutline(size, layoutDirection, density) as Outline.Generic).path
+ val flatBottomShape = Path().apply {
+ moveTo(0f, size.height / 2)
+ lineTo(0f, size.height)
+ lineTo(size.width, size.height)
+ lineTo(size.width, size.height / 2)
+ close()
+ }
+ return Outline.Generic(
+ Path.combine(
+ operation = PathOperation.Union,
+ path1 = flatBottomShape,
+ path2 = existingShapePath,
+ ),
+ )
+ }
+}
diff --git a/core/designsystem/src/main/java/com/aliumujib/designsystem/theme/Theme.kt b/core/designsystem/src/main/java/com/aliumujib/designsystem/theme/Theme.kt
new file mode 100644
index 0000000..7d1f2ad
--- /dev/null
+++ b/core/designsystem/src/main/java/com/aliumujib/designsystem/theme/Theme.kt
@@ -0,0 +1,114 @@
+
+package com.aliumujib.designsystem.theme
+
+import android.os.Build
+import androidx.annotation.ChecksSdkIntAtLeast
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import com.google.accompanist.systemuicontroller.rememberSystemUiController
+
+private val LightColors = lightColorScheme(
+ primary = PrimaryColor,
+ onPrimary = PrimaryTextColor,
+ secondary = SecondaryColor,
+ onSecondary = SecondaryTextColor,
+ tertiary = PrimaryLightColor,
+ onTertiary = PrimaryTextColor,
+ background = BackgroundLightColor,
+ onBackground = Color.Black,
+ surface = SurfaceLight,
+ onSurface = Color.Black,
+ surfaceVariant = SurfaceLight,
+ onSurfaceVariant = Color.Black,
+ secondaryContainer = PrimaryColor,
+ onSecondaryContainer = Color.White,
+ error = ErrorColor,
+ onError = OnErrorColor
+)
+
+private val DarkColors = darkColorScheme(
+ primary = PrimaryColor,
+ onPrimary = PrimaryTextColor,
+ secondary = SecondaryLightColor,
+ onSecondary = SecondaryTextColor,
+ tertiary = PrimaryLightColor,
+ onTertiary = PrimaryTextColor,
+ background = BackgroundDarkColor,
+ onBackground = Color.White,
+ surface = SurfaceDark,
+ onSurface = Color.White,
+ surfaceVariant = SurfaceDark,
+ onSurfaceVariant = Color.White,
+ secondaryContainer = PrimaryColor,
+ onSecondaryContainer = Color.White,
+ error = ErrorColor,
+ onError = OnErrorColor
+)
+
+@Composable
+fun AppTheme(theme: Int = Theme.FOLLOW_SYSTEM.themeValue, content: @Composable () -> Unit) {
+ val autoColors = if (isSystemInDarkTheme()) DarkColors else LightColors
+
+ val dynamicColors = if (supportsDynamicTheming()) {
+ val context = LocalContext.current
+ if (isSystemInDarkTheme()) {
+ dynamicDarkColorScheme(context)
+ } else {
+ dynamicLightColorScheme(context)
+ }
+ } else {
+ autoColors
+ }
+
+ val colors = when (theme) {
+ Theme.LIGHT_THEME.themeValue -> LightColors
+ Theme.DARK_THEME.themeValue -> DarkColors
+ Theme.MATERIAL_YOU.themeValue -> dynamicColors
+ else -> autoColors
+ }
+
+ val systemUiController = rememberSystemUiController()
+
+ SideEffect {
+ systemUiController.setSystemBarsColor(
+ color = colors.background
+ )
+ }
+
+ MaterialTheme(
+ colorScheme = colors,
+ typography = Typography,
+ shapes = Shapes,
+ content = content
+ )
+}
+
+@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S)
+private fun supportsDynamicTheming() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
+
+// To be used to set the preferred theme inside settings
+enum class Theme(
+ val themeValue: Int
+) {
+ MATERIAL_YOU(
+ themeValue = 12
+ ),
+ FOLLOW_SYSTEM(
+ themeValue = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
+ ),
+ LIGHT_THEME(
+ themeValue = AppCompatDelegate.MODE_NIGHT_NO
+ ),
+ DARK_THEME(
+ themeValue = AppCompatDelegate.MODE_NIGHT_YES
+ );
+}
diff --git a/core/designsystem/src/main/java/com/aliumujib/designsystem/theme/Type.kt b/core/designsystem/src/main/java/com/aliumujib/designsystem/theme/Type.kt
new file mode 100644
index 0000000..a191d97
--- /dev/null
+++ b/core/designsystem/src/main/java/com/aliumujib/designsystem/theme/Type.kt
@@ -0,0 +1,124 @@
+
+package com.aliumujib.designsystem.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+import com.aliumujib.designsystem.R
+
+// Font family
+val poppins = FontFamily(
+ Font(R.font.poppins_thin, FontWeight.W100),
+ Font(R.font.poppins_extralight, FontWeight.W200),
+ Font(R.font.poppins_light, FontWeight.W300),
+ Font(R.font.poppins_regular, FontWeight.W400),
+ Font(R.font.poppins_medium, FontWeight.W500),
+ Font(R.font.poppins_semibold, FontWeight.W600),
+ Font(R.font.poppins_bold, FontWeight.W700),
+ Font(R.font.poppins_black, FontWeight.W800),
+)
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ displayLarge = TextStyle(
+ fontFamily = poppins,
+ fontWeight = FontWeight.W400,
+ fontSize = 50.sp,
+ lineHeight = 64.sp,
+ letterSpacing = (-0.25).sp
+ ),
+ displayMedium = TextStyle(
+ fontFamily = poppins,
+ fontWeight = FontWeight.W400,
+ fontSize = 40.sp,
+ lineHeight = 52.sp
+ ),
+ displaySmall = TextStyle(
+ fontFamily = poppins,
+ fontWeight = FontWeight.W400,
+ fontSize = 30.sp,
+ lineHeight = 44.sp
+ ),
+ headlineLarge = TextStyle(
+ fontFamily = poppins,
+ fontWeight = FontWeight.W400,
+ fontSize = 28.sp,
+ lineHeight = 40.sp
+ ),
+ headlineMedium = TextStyle(
+ fontFamily = poppins,
+ fontWeight = FontWeight.W400,
+ fontSize = 24.sp,
+ lineHeight = 36.sp
+ ),
+ headlineSmall = TextStyle(
+ fontFamily = poppins,
+ fontWeight = FontWeight.W400,
+ fontSize = 20.sp,
+ lineHeight = 32.sp
+ ),
+ titleLarge = TextStyle(
+ fontFamily = poppins,
+ fontWeight = FontWeight.W700,
+ fontSize = 18.sp,
+ lineHeight = 28.sp
+ ),
+ titleMedium = TextStyle(
+ fontFamily = poppins,
+ fontWeight = FontWeight.W700,
+ fontSize = 14.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.1.sp
+ ),
+ titleSmall = TextStyle(
+ fontFamily = poppins,
+ fontWeight = FontWeight.W500,
+ fontSize = 12.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp
+ ),
+ bodyLarge = TextStyle(
+ fontFamily = poppins,
+ fontWeight = FontWeight.W400,
+ fontSize = 14.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ ),
+ bodyMedium = TextStyle(
+ fontFamily = poppins,
+ fontWeight = FontWeight.W400,
+ fontSize = 12.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.25.sp
+ ),
+ bodySmall = TextStyle(
+ fontFamily = poppins,
+ fontWeight = FontWeight.W400,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.4.sp
+ ),
+ labelLarge = TextStyle(
+ fontFamily = poppins,
+ fontWeight = FontWeight.W400,
+ fontSize = 13.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp
+ ),
+ labelMedium = TextStyle(
+ fontFamily = poppins,
+ fontWeight = FontWeight.W400,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = poppins,
+ fontWeight = FontWeight.W500,
+ fontSize = 9.sp,
+ lineHeight = 16.sp
+ )
+)
diff --git a/core/designsystem/src/main/res/font/poppins_black.ttf b/core/designsystem/src/main/res/font/poppins_black.ttf
new file mode 100644
index 0000000..67bccc8
Binary files /dev/null and b/core/designsystem/src/main/res/font/poppins_black.ttf differ
diff --git a/core/designsystem/src/main/res/font/poppins_bold.ttf b/core/designsystem/src/main/res/font/poppins_bold.ttf
new file mode 100644
index 0000000..89b46e7
Binary files /dev/null and b/core/designsystem/src/main/res/font/poppins_bold.ttf differ
diff --git a/core/designsystem/src/main/res/font/poppins_extrabold.ttf b/core/designsystem/src/main/res/font/poppins_extrabold.ttf
new file mode 100644
index 0000000..320070d
Binary files /dev/null and b/core/designsystem/src/main/res/font/poppins_extrabold.ttf differ
diff --git a/core/designsystem/src/main/res/font/poppins_extralight.ttf b/core/designsystem/src/main/res/font/poppins_extralight.ttf
new file mode 100644
index 0000000..44abe5c
Binary files /dev/null and b/core/designsystem/src/main/res/font/poppins_extralight.ttf differ
diff --git a/core/designsystem/src/main/res/font/poppins_light.ttf b/core/designsystem/src/main/res/font/poppins_light.ttf
new file mode 100644
index 0000000..b8f5b06
Binary files /dev/null and b/core/designsystem/src/main/res/font/poppins_light.ttf differ
diff --git a/core/designsystem/src/main/res/font/poppins_medium.ttf b/core/designsystem/src/main/res/font/poppins_medium.ttf
new file mode 100644
index 0000000..937b1e9
Binary files /dev/null and b/core/designsystem/src/main/res/font/poppins_medium.ttf differ
diff --git a/core/designsystem/src/main/res/font/poppins_regular.ttf b/core/designsystem/src/main/res/font/poppins_regular.ttf
new file mode 100644
index 0000000..e48144e
Binary files /dev/null and b/core/designsystem/src/main/res/font/poppins_regular.ttf differ
diff --git a/core/designsystem/src/main/res/font/poppins_semibold.ttf b/core/designsystem/src/main/res/font/poppins_semibold.ttf
new file mode 100644
index 0000000..8421552
Binary files /dev/null and b/core/designsystem/src/main/res/font/poppins_semibold.ttf differ
diff --git a/core/designsystem/src/main/res/font/poppins_thin.ttf b/core/designsystem/src/main/res/font/poppins_thin.ttf
new file mode 100644
index 0000000..45ddcd5
Binary files /dev/null and b/core/designsystem/src/main/res/font/poppins_thin.ttf differ
diff --git a/core/designsystem/src/main/res/layout/test.xml b/core/designsystem/src/main/res/layout/test.xml
new file mode 100644
index 0000000..d829e29
--- /dev/null
+++ b/core/designsystem/src/main/res/layout/test.xml
@@ -0,0 +1,7 @@
+
+
+
+
\ No newline at end of file
diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml
new file mode 100644
index 0000000..8022b24
--- /dev/null
+++ b/core/designsystem/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ Clear text field with text %1$s\n
+
\ No newline at end of file
diff --git a/core/models/.gitignore b/core/models/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/core/models/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/models/build.gradle.kts b/core/models/build.gradle.kts
new file mode 100644
index 0000000..50b9d42
--- /dev/null
+++ b/core/models/build.gradle.kts
@@ -0,0 +1,40 @@
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.android.kotlin)
+ alias(libs.plugins.kapt)
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.hilt.android)
+ alias(libs.plugins.parcelize)
+}
+
+apply {
+ from("$rootDir/base-module.gradle")
+}
+
+android {
+ namespace = "com.aliumujib.models"
+ compileSdk = AndroidConfig.compileSDK
+
+ defaultConfig {
+ minSdk = AndroidConfig.minSDK
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ compileOptions {
+ sourceCompatibility = AndroidConfig.javaVersion
+ targetCompatibility = AndroidConfig.javaVersion
+ }
+
+ kotlinOptions {
+ jvmTarget = AndroidConfig.jvmTarget
+ }
+
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
diff --git a/core/models/consumer-rules.pro b/core/models/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/core/models/proguard-rules.pro b/core/models/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/core/models/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/core/models/src/main/java/com/aliumujib/model/Breed.kt b/core/models/src/main/java/com/aliumujib/model/Breed.kt
new file mode 100644
index 0000000..d02d2af
--- /dev/null
+++ b/core/models/src/main/java/com/aliumujib/model/Breed.kt
@@ -0,0 +1,70 @@
+package com.aliumujib.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class Breed(
+ val id: BreedId,
+ val name: String,
+ val weight: Weight,
+ val urls: Urls,
+ val attributes: Attributes,
+ val characteristics: Characteristics,
+ val wikipediaUrl: String,
+ val hypoallergenic: Int,
+ val referenceImageUrl: String,
+ val isFavorite: Boolean
+) : Parcelable
+
+@[JvmInline Parcelize]
+value class BreedId(val data: String) : Parcelable
+
+@Parcelize
+data class Weight(
+ val imperial: String,
+ val metric: String
+) : Parcelable
+
+@Parcelize
+data class Urls(
+ val cfaUrl: String,
+ val vetstreetUrl: String,
+ val vcahospitalsUrl: String
+) : Parcelable
+
+@Parcelize
+data class Attributes(
+ val temperament: String,
+ val origin: String,
+ val countryCodes: String,
+ val countryCode: String,
+ val description: String,
+ val lifeSpan: String,
+ val indoor: Int,
+ val lap: Int,
+ val altNames: String
+) : Parcelable
+
+@Parcelize
+data class Characteristics(
+ val adaptability: Int,
+ val affectionLevel: Int,
+ val childFriendly: Int,
+ val dogFriendly: Int,
+ val energyLevel: Int,
+ val grooming: Int,
+ val healthIssues: Int,
+ val intelligence: Int,
+ val sheddingLevel: Int,
+ val socialNeeds: Int,
+ val strangerFriendly: Int,
+ val vocalisation: Int,
+ val experimental: Int,
+ val hairless: Int,
+ val natural: Int,
+ val rare: Int,
+ val rex: Int,
+ val suppressedTail: Int,
+ val shortLegs: Int
+) : Parcelable
diff --git a/core/models/src/main/java/com/aliumujib/model/DownloadEvent.kt b/core/models/src/main/java/com/aliumujib/model/DownloadEvent.kt
new file mode 100644
index 0000000..0733066
--- /dev/null
+++ b/core/models/src/main/java/com/aliumujib/model/DownloadEvent.kt
@@ -0,0 +1,10 @@
+package com.aliumujib.model
+
+import android.net.Uri
+
+sealed class DownloadEvent(open val downloadId: Long) {
+ data class Progress(override val downloadId: Long, val progress: Int) : DownloadEvent(downloadId)
+ data class Complete(override val downloadId: Long, val uri: Uri, val breed: Breed? = null) : DownloadEvent(downloadId)
+ data class Failure(override val downloadId: Long, val reason: String) : DownloadEvent(downloadId)
+ data class Cancellation(override val downloadId: Long) : DownloadEvent(downloadId)
+}
\ No newline at end of file
diff --git a/core/network/.gitignore b/core/network/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/core/network/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts
new file mode 100644
index 0000000..62f73ff
--- /dev/null
+++ b/core/network/build.gradle.kts
@@ -0,0 +1,60 @@
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.android.kotlin)
+ alias(libs.plugins.kapt)
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.parcelize)
+}
+
+apply {
+ from("$rootDir/base-module.gradle")
+}
+
+android {
+ namespace = "com.aliumujib.network"
+
+ compileSdk = AndroidConfig.compileSDK
+
+ defaultConfig {
+ minSdk = AndroidConfig.minSDK
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = AndroidConfig.javaVersion
+ targetCompatibility = AndroidConfig.javaVersion
+ }
+ kotlinOptions {
+ jvmTarget = AndroidConfig.jvmTarget
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ implementation(projects.core.common)
+ implementation(projects.core.preferences)
+
+ // Retrofit
+ implementation(libs.squareup.retrofit)
+ implementation(libs.squareup.okhttp)
+ implementation(libs.squareup.logging.interceptor)
+
+ // Chucker
+ debugImplementation(libs.chucker.debug)
+ releaseImplementation(libs.chucker.release)
+}
diff --git a/core/network/consumer-rules.pro b/core/network/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/core/network/proguard-rules.pro b/core/network/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/core/network/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/core/network/src/main/AndroidManifest.xml b/core/network/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..a5918e6
--- /dev/null
+++ b/core/network/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/core/network/src/main/java/com/aliumujib/network/CatAPIService.kt b/core/network/src/main/java/com/aliumujib/network/CatAPIService.kt
new file mode 100644
index 0000000..40969c3
--- /dev/null
+++ b/core/network/src/main/java/com/aliumujib/network/CatAPIService.kt
@@ -0,0 +1,20 @@
+package com.aliumujib.network
+
+import com.aliumujib.network.model.BreedAPIModel
+import com.aliumujib.network.model.BreedImageAPIModel
+import retrofit2.http.GET
+import retrofit2.http.Path
+import retrofit2.http.Query
+
+interface CatAPIService {
+
+ @GET("breeds")
+ suspend fun getBreeds(): List
+
+ @GET("breeds/{breedId}")
+ suspend fun getBreedById(
+ @Path("breedId") breedId: String,
+ @Query("attach_image") attachImage: Int,
+ ): BreedAPIModel
+
+}
diff --git a/core/network/src/main/java/com/aliumujib/network/auth/AuthInterceptor.kt b/core/network/src/main/java/com/aliumujib/network/auth/AuthInterceptor.kt
new file mode 100644
index 0000000..977cecb
--- /dev/null
+++ b/core/network/src/main/java/com/aliumujib/network/auth/AuthInterceptor.kt
@@ -0,0 +1,22 @@
+package com.aliumujib.network.auth
+
+import okhttp3.Interceptor
+import okhttp3.Response
+import javax.inject.Inject
+
+class AuthInterceptor @Inject constructor(
+ private val apiKey: String
+) : Interceptor {
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val requestBuilder = chain.request().newBuilder()
+
+ if (!apiKey.isNullOrEmpty()) {
+ requestBuilder.addHeader("x-api-key", apiKey)
+ }
+
+ val newRequest = requestBuilder.build()
+ return chain.proceed(newRequest)
+ }
+
+}
diff --git a/core/network/src/main/java/com/aliumujib/network/auth/BuildConfiguration.kt b/core/network/src/main/java/com/aliumujib/network/auth/BuildConfiguration.kt
new file mode 100644
index 0000000..93c34fc
--- /dev/null
+++ b/core/network/src/main/java/com/aliumujib/network/auth/BuildConfiguration.kt
@@ -0,0 +1,11 @@
+package com.aliumujib.network.auth
+
+data class BuildConfiguration(
+ val debug: Boolean,
+ val appId: String,
+ val buildType: String,
+ val versionCode: Int,
+ val versionName: String,
+ val apiKey: String,
+ val baseUrl: String
+)
\ No newline at end of file
diff --git a/core/network/src/main/java/com/aliumujib/network/di/NetworkModule.kt b/core/network/src/main/java/com/aliumujib/network/di/NetworkModule.kt
new file mode 100644
index 0000000..ccc5399
--- /dev/null
+++ b/core/network/src/main/java/com/aliumujib/network/di/NetworkModule.kt
@@ -0,0 +1,71 @@
+package com.aliumujib.network.di
+
+import com.aliumujib.network.CatAPIService
+import com.aliumujib.network.auth.AuthInterceptor
+import com.aliumujib.network.auth.BuildConfiguration
+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 retrofit2.create
+import java.util.concurrent.TimeUnit
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object NetworkModule {
+
+ @Singleton
+ @Provides
+ fun provideLoggingInterceptor(): HttpLoggingInterceptor {
+ return HttpLoggingInterceptor()
+ .setLevel(HttpLoggingInterceptor.Level.BODY)
+ }
+
+ @Provides
+ @Singleton
+ fun provideAuthInterceptor(
+ buildConfiguration: BuildConfiguration
+ ): AuthInterceptor {
+ return AuthInterceptor(buildConfiguration.apiKey)
+ }
+
+ @Provides
+ @Singleton
+ fun provideOkHttpClient(
+ httpLoggingInterceptor: HttpLoggingInterceptor,
+ authInterceptor: AuthInterceptor,
+ ): OkHttpClient {
+ val okHttpClient = OkHttpClient.Builder()
+ .apply {
+ addInterceptor(authInterceptor)
+ addInterceptor(httpLoggingInterceptor)
+ callTimeout(15, TimeUnit.SECONDS)
+ connectTimeout(15, TimeUnit.SECONDS)
+ writeTimeout(15, TimeUnit.SECONDS)
+ readTimeout(15, TimeUnit.SECONDS)
+ }
+
+ return okHttpClient.build()
+ }
+
+ @Provides
+ @Singleton
+ fun provideRetrofit(okHttpClient: OkHttpClient, buildConfiguration: BuildConfiguration): Retrofit {
+ return Retrofit.Builder()
+ .baseUrl(buildConfiguration.baseUrl)
+ .addConverterFactory(GsonConverterFactory.create())
+ .client(okHttpClient)
+ .build()
+ }
+
+ @Provides
+ @Singleton
+ fun provideMealDbApi(retrofit: Retrofit): CatAPIService {
+ return retrofit.create()
+ }
+}
diff --git a/core/network/src/main/java/com/aliumujib/network/model/BreedResponse.kt b/core/network/src/main/java/com/aliumujib/network/model/BreedResponse.kt
new file mode 100644
index 0000000..655b3c6
--- /dev/null
+++ b/core/network/src/main/java/com/aliumujib/network/model/BreedResponse.kt
@@ -0,0 +1,132 @@
+package com.aliumujib.network.model
+
+import com.google.gson.annotations.SerializedName
+
+data class BreedAPIModel(
+ @SerializedName("weight")
+ val weight: WeightAPIModel,
+
+ @SerializedName("id")
+ val id: String,
+
+ @SerializedName("name")
+ val name: String,
+
+ @SerializedName("cfa_url")
+ val cfaUrl: String?,
+
+ @SerializedName("vetstreet_url")
+ val vetstreetUrl: String?,
+
+ @SerializedName("vcahospitals_url")
+ val vcahospitalsUrl: String?,
+
+ @SerializedName("temperament")
+ val temperament: String,
+
+ @SerializedName("origin")
+ val origin: String,
+
+ @SerializedName("country_codes")
+ val countryCodes: String,
+
+ @SerializedName("country_code")
+ val countryCode: String,
+
+ @SerializedName("description")
+ val description: String,
+
+ @SerializedName("life_span")
+ val lifeSpan: String,
+
+ @SerializedName("indoor")
+ val indoor: Int,
+
+ @SerializedName("lap")
+ val lap: Int,
+
+ @SerializedName("alt_names")
+ val altNames: String?,
+
+ @SerializedName("adaptability")
+ val adaptability: Int,
+
+ @SerializedName("affection_level")
+ val affectionLevel: Int,
+
+ @SerializedName("child_friendly")
+ val childFriendly: Int,
+
+ @SerializedName("dog_friendly")
+ val dogFriendly: Int,
+
+ @SerializedName("energy_level")
+ val energyLevel: Int,
+
+ @SerializedName("grooming")
+ val grooming: Int,
+
+ @SerializedName("health_issues")
+ val healthIssues: Int,
+
+ @SerializedName("intelligence")
+ val intelligence: Int,
+
+ @SerializedName("shedding_level")
+ val sheddingLevel: Int,
+
+ @SerializedName("social_needs")
+ val socialNeeds: Int,
+
+ @SerializedName("stranger_friendly")
+ val strangerFriendly: Int,
+
+ @SerializedName("vocalisation")
+ val vocalisation: Int,
+
+ @SerializedName("experimental")
+ val experimental: Int,
+
+ @SerializedName("hairless")
+ val hairless: Int,
+
+ @SerializedName("natural")
+ val natural: Int,
+
+ @SerializedName("rare")
+ val rare: Int,
+
+ @SerializedName("rex")
+ val rex: Int,
+
+ @SerializedName("suppressed_tail")
+ val suppressedTail: Int,
+
+ @SerializedName("short_legs")
+ val shortLegs: Int,
+
+ @SerializedName("wikipedia_url")
+ val wikipediaUrl: String?,
+
+ @SerializedName("hypoallergenic")
+ val hypoallergenic: Int,
+
+ @SerializedName("image")
+ val referenceImage: BreedImageAPIModel?,
+
+ @SerializedName("reference_image_id")
+ val referenceImageId: String?
+)
+
+data class WeightAPIModel(
+ @SerializedName("imperial")
+ val imperial: String,
+
+ @SerializedName("metric")
+ val metric: String
+)
+
+data class BreedImageAPIModel(
+ @SerializedName("url")
+ val imageUrl: String?,
+)
\ No newline at end of file
diff --git a/core/preferences/.gitignore b/core/preferences/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/core/preferences/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/preferences/build.gradle.kts b/core/preferences/build.gradle.kts
new file mode 100644
index 0000000..d9cb3ef
--- /dev/null
+++ b/core/preferences/build.gradle.kts
@@ -0,0 +1,53 @@
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.android.kotlin)
+ alias(libs.plugins.kapt)
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.hilt.android)
+ alias(libs.plugins.parcelize)
+}
+
+apply {
+ from("$rootDir/base-module.gradle")
+}
+
+android {
+ namespace = "com.aliumujib.settings"
+
+ compileSdk = AndroidConfig.compileSDK
+
+ defaultConfig {
+ minSdk = AndroidConfig.minSDK
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = AndroidConfig.javaVersion
+ targetCompatibility = AndroidConfig.javaVersion
+ }
+ kotlinOptions {
+ jvmTarget = AndroidConfig.jvmTarget
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ implementation(libs.appcompat)
+ // Preferences DataStore
+ implementation(libs.datastore.preferences)
+}
diff --git a/core/preferences/consumer-rules.pro b/core/preferences/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/core/preferences/proguard-rules.pro b/core/preferences/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/core/preferences/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/core/preferences/src/main/java/com/aliumujib/preferences/data/AppPreferencesImpl.kt b/core/preferences/src/main/java/com/aliumujib/preferences/data/AppPreferencesImpl.kt
new file mode 100644
index 0000000..fe34016
--- /dev/null
+++ b/core/preferences/src/main/java/com/aliumujib/preferences/data/AppPreferencesImpl.kt
@@ -0,0 +1,46 @@
+package com.aliumujib.preferences.data
+
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import com.aliumujib.preferences.domain.AppPreferences
+import com.aliumujib.preferences.utils.Constants
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlinx.datetime.Instant
+import javax.inject.Inject
+
+class AppPreferencesImpl @Inject constructor(
+ private val dataStore: DataStore,
+) : AppPreferences {
+ override suspend fun saveTheme(themeValue: Int) {
+ dataStore.edit { preferences ->
+ preferences[Constants.THEME_OPTIONS] = themeValue
+ }
+ }
+
+ override fun getTheme(): Flow {
+ return dataStore.data.map { preferences ->
+ preferences[Constants.THEME_OPTIONS] ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
+ }
+ }
+
+ override suspend fun saveLastLoggedIn(lastLoggedIn: Long) {
+ dataStore.edit { preferences ->
+ preferences[Constants.LAST_LOGIN_DATE_KEY] = lastLoggedIn
+ }
+ }
+
+ override fun getLastLoggedIn(): Flow {
+ return dataStore.data.map { preferences ->
+ preferences[Constants.LAST_LOGIN_DATE_KEY] ?: Instant.DISTANT_PAST.epochSeconds
+ }
+ }
+
+ override suspend fun clear() {
+ dataStore.edit { preferences ->
+ preferences.clear()
+ }
+ }
+}
diff --git a/core/preferences/src/main/java/com/aliumujib/preferences/di/PreferencesModule.kt b/core/preferences/src/main/java/com/aliumujib/preferences/di/PreferencesModule.kt
new file mode 100644
index 0000000..5bfb5b4
--- /dev/null
+++ b/core/preferences/src/main/java/com/aliumujib/preferences/di/PreferencesModule.kt
@@ -0,0 +1,35 @@
+
+package com.aliumujib.preferences.di
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.preferencesDataStoreFile
+import com.aliumujib.preferences.data.AppPreferencesImpl
+import com.aliumujib.preferences.domain.AppPreferences
+import com.aliumujib.preferences.utils.Constants
+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)
+object PreferencesModule {
+ @Provides
+ @Singleton
+ fun provideDatastorePreferences(@ApplicationContext context: Context): DataStore =
+ PreferenceDataStoreFactory.create(
+ produceFile = {
+ context.preferencesDataStoreFile(Constants.APP_PREFERENCES)
+ }
+ )
+
+ @Provides
+ @Singleton
+ fun provideMealtimeSettings(dataStore: DataStore): AppPreferences =
+ AppPreferencesImpl(dataStore)
+}
diff --git a/core/preferences/src/main/java/com/aliumujib/preferences/domain/AppPreferences.kt b/core/preferences/src/main/java/com/aliumujib/preferences/domain/AppPreferences.kt
new file mode 100644
index 0000000..205cde8
--- /dev/null
+++ b/core/preferences/src/main/java/com/aliumujib/preferences/domain/AppPreferences.kt
@@ -0,0 +1,11 @@
+package com.aliumujib.preferences.domain
+
+import kotlinx.coroutines.flow.Flow
+
+interface AppPreferences {
+ suspend fun saveTheme(themeValue: Int)
+ fun getTheme(): Flow
+ suspend fun saveLastLoggedIn(lastLoggedIn: Long)
+ fun getLastLoggedIn(): Flow
+ suspend fun clear()
+}
diff --git a/core/preferences/src/main/java/com/aliumujib/preferences/domain/usecase/GetAppThemeUseCase.kt b/core/preferences/src/main/java/com/aliumujib/preferences/domain/usecase/GetAppThemeUseCase.kt
new file mode 100644
index 0000000..1df485c
--- /dev/null
+++ b/core/preferences/src/main/java/com/aliumujib/preferences/domain/usecase/GetAppThemeUseCase.kt
@@ -0,0 +1,10 @@
+package com.aliumujib.preferences.domain.usecase
+
+import com.aliumujib.preferences.domain.AppPreferences
+import javax.inject.Inject
+
+class GetAppThemeUseCase @Inject constructor(
+ private val appPreferences: AppPreferences
+) {
+ operator fun invoke() = appPreferences.getTheme()
+}
diff --git a/core/preferences/src/main/java/com/aliumujib/preferences/utils/Constants.kt b/core/preferences/src/main/java/com/aliumujib/preferences/utils/Constants.kt
new file mode 100644
index 0000000..4f45ac3
--- /dev/null
+++ b/core/preferences/src/main/java/com/aliumujib/preferences/utils/Constants.kt
@@ -0,0 +1,11 @@
+package com.aliumujib.preferences.utils
+
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.longPreferencesKey
+
+object Constants {
+ val THEME_OPTIONS = intPreferencesKey(name = "theme_option")
+ val LAST_LOGIN_DATE_KEY = longPreferencesKey("last_login_date")
+
+ const val APP_PREFERENCES = "APP_PREFERENCES"
+}
diff --git a/docs/architecture.png b/docs/architecture.png
new file mode 100644
index 0000000..9ababaa
Binary files /dev/null and b/docs/architecture.png differ
diff --git a/docs/dataflow.png b/docs/dataflow.png
new file mode 100644
index 0000000..bfd0d0b
Binary files /dev/null and b/docs/dataflow.png differ
diff --git a/feature/breeds/all-breeds/.gitignore b/feature/breeds/all-breeds/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/feature/breeds/all-breeds/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/feature/breeds/all-breeds/build.gradle.kts b/feature/breeds/all-breeds/build.gradle.kts
new file mode 100644
index 0000000..9135787
--- /dev/null
+++ b/feature/breeds/all-breeds/build.gradle.kts
@@ -0,0 +1,82 @@
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.android.kotlin)
+ alias(libs.plugins.kapt)
+ alias(libs.plugins.parcelize)
+ alias(libs.plugins.ksp)
+}
+
+apply {
+ from("$rootDir/base-module.gradle")
+}
+
+android {
+ compileSdk = AndroidConfig.compileSDK
+
+ defaultConfig {
+ minSdk = AndroidConfig.minSDK
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = AndroidConfig.javaVersion
+ targetCompatibility = AndroidConfig.javaVersion
+ }
+ kotlinOptions {
+ jvmTarget = AndroidConfig.jvmTarget
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+ namespace = "com.aliumujib.all.breeds"
+}
+
+ksp {
+ arg("compose-destinations.mode", "destinations")
+ arg("compose-destinations.moduleName", "all.breeds")
+}
+
+kotlin {
+ sourceSets {
+ debug {
+ kotlin.srcDir("build/generated/ksp/debug/kotlin")
+ }
+ release {
+ kotlin.srcDir("build/generated/ksp/release/kotlin")
+ }
+ }
+}
+
+dependencies {
+ implementation(projects.core.designsystem)
+ implementation(projects.core.common)
+ implementation(projects.core.analytics)
+ implementation(projects.core.preferences)
+ implementation(projects.core.models)
+ implementation(projects.feature.breeds.breedsDomain)
+ implementation(projects.feature.breeds.common)
+ implementation(libs.accompanist.permissions)
+ implementation(projects.core.commonDomain)
+ implementation(libs.androidx.lifecycle.compose.android)
+
+ implementation(libs.compose.destinations.animations)
+ ksp(libs.compose.destinations.ksp)
+
+ testImplementation(libs.bundles.testing)
+ testImplementation(projects.core.commonTest)
+ androidTestImplementation(projects.core.commonTest)
+
+}
diff --git a/feature/breeds/all-breeds/consumer-rules.pro b/feature/breeds/all-breeds/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/feature/breeds/all-breeds/proguard-rules.pro b/feature/breeds/all-breeds/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/feature/breeds/all-breeds/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/feature/breeds/all-breeds/src/androidTest/java/com/aliumujib/all/cats/ui/BreedScreenTest.kt b/feature/breeds/all-breeds/src/androidTest/java/com/aliumujib/all/cats/ui/BreedScreenTest.kt
new file mode 100644
index 0000000..b89ed9e
--- /dev/null
+++ b/feature/breeds/all-breeds/src/androidTest/java/com/aliumujib/all/cats/ui/BreedScreenTest.kt
@@ -0,0 +1,99 @@
+package com.aliumujib.all.cats.ui
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.aliumujib.all.breeds.presentation.list.BreedsContract
+import com.aliumujib.all.breeds.ui.BreedsScreenContent
+import com.aliumujib.common.test.SharedDummyData
+import com.aliumujib.designsystem.theme.AppTheme
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class BreedScreenTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Test
+ fun breedsScreenContent_whenLoading_showsLoadingIndicator() {
+ composeTestRule.setContent {
+ AppTheme {
+ BreedsScreenContent(
+ isMoreSheetOpen = false,
+ uiState = BreedsContract.BreedsUiState.Loading,
+ focusedBreed = null,
+ onItemClicked = {},
+ onMoreClick = {},
+ onMoreDismissedRequest = {},
+ onFavoriteClick = {}
+ )
+ }
+ }
+ composeTestRule.onNodeWithTag("Loading").assertIsDisplayed()
+ }
+
+ @Test
+ fun breedsScreenContent_whenErrorState_showsErrorMessage() {
+ val errorMessage = "Network Error"
+ composeTestRule.setContent {
+ AppTheme {
+ BreedsScreenContent(
+ isMoreSheetOpen = false,
+ uiState = BreedsContract.BreedsUiState.Error(errorMessage),
+ focusedBreed = null,
+ onItemClicked = {},
+ onMoreClick = {},
+ onMoreDismissedRequest = {},
+ onFavoriteClick = {}
+ )
+ }
+ }
+ composeTestRule.onNodeWithText(errorMessage).assertIsDisplayed()
+ }
+
+ @Test
+ fun breedsScreenContent_whenSuccessState_breedsDisplayed() {
+ val breeds = SharedDummyData.breedList
+ composeTestRule.setContent {
+ AppTheme {
+ BreedsScreenContent(
+ isMoreSheetOpen = false,
+ uiState = BreedsContract.BreedsUiState.Success(breeds),
+ focusedBreed = null,
+ onItemClicked = {},
+ onMoreClick = {},
+ onMoreDismissedRequest = {},
+ onFavoriteClick = {}
+ )
+ }
+ }
+
+ breeds.forEach { breed ->
+ composeTestRule.onNodeWithText(breed.name).assertIsDisplayed()
+ }
+ }
+
+ @Test
+ fun breedsScreenContent_whenEmptyState_showsNoBreedsFoundMessage() {
+ composeTestRule.setContent {
+ AppTheme {
+ BreedsScreenContent(
+ isMoreSheetOpen = false,
+ uiState = BreedsContract.BreedsUiState.Empty,
+ focusedBreed = null,
+ onItemClicked = {},
+ onMoreClick = {},
+ onMoreDismissedRequest = {},
+ onFavoriteClick = {}
+ )
+ }
+ }
+ composeTestRule.onNodeWithText("No breeds found!").assertIsDisplayed()
+ }
+
+}
\ No newline at end of file
diff --git a/feature/breeds/all-breeds/src/main/AndroidManifest.xml b/feature/breeds/all-breeds/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..69fc412
--- /dev/null
+++ b/feature/breeds/all-breeds/src/main/AndroidManifest.xml
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/feature/breeds/all-breeds/src/main/java/com/aliumujib/all/breeds/navigation/BreedsNavigator.kt b/feature/breeds/all-breeds/src/main/java/com/aliumujib/all/breeds/navigation/BreedsNavigator.kt
new file mode 100644
index 0000000..77c9a2a
--- /dev/null
+++ b/feature/breeds/all-breeds/src/main/java/com/aliumujib/all/breeds/navigation/BreedsNavigator.kt
@@ -0,0 +1,7 @@
+package com.aliumujib.all.breeds.navigation
+
+import com.aliumujib.model.BreedId
+
+interface BreedsNavigator {
+ fun goToDetails(id: BreedId)
+}
diff --git a/feature/breeds/all-breeds/src/main/java/com/aliumujib/all/breeds/presentation/list/BreedsContract.kt b/feature/breeds/all-breeds/src/main/java/com/aliumujib/all/breeds/presentation/list/BreedsContract.kt
new file mode 100644
index 0000000..cfedb05
--- /dev/null
+++ b/feature/breeds/all-breeds/src/main/java/com/aliumujib/all/breeds/presentation/list/BreedsContract.kt
@@ -0,0 +1,40 @@
+package com.aliumujib.all.breeds.presentation.list
+
+import androidx.compose.runtime.Immutable
+import com.aliumujib.model.Breed
+import com.aliumujib.model.BreedId
+
+
+interface BreedsContract {
+
+ @Immutable
+ sealed class BreedsUiState(open val breeds: List) {
+ data class Success(override val breeds: List) : BreedsUiState(emptyList())
+ data object Loading : BreedsUiState(emptyList())
+ data object Empty : BreedsUiState(emptyList())
+ data class Error(val errorMessage: String?) : BreedsUiState(emptyList())
+ data object Initial : BreedsUiState(emptyList())
+ }
+
+ sealed interface BreedsResult {
+ sealed interface FetchCatBreedsResult : BreedsResult {
+ data object Loading : FetchCatBreedsResult
+ data class Error(val throwable: Throwable) : FetchCatBreedsResult
+ data class Success(val data: List) : FetchCatBreedsResult
+ }
+
+ sealed interface ToggleFavouriteStatusResult : BreedsResult {
+ data class Error(val throwable: Throwable) : ToggleFavouriteStatusResult
+ data class Success(val data: BreedId) : ToggleFavouriteStatusResult
+ }
+ }
+
+ sealed interface BreedsUiIntent {
+ data object FetchCatBreeds : BreedsUiIntent
+ data class ToggleFavouriteStatus(val breedId: BreedId) : BreedsUiIntent
+ }
+
+ sealed interface BreedsSideEffect {
+ data class ShowErrorToast(val error: String) : BreedsSideEffect
+ }
+}
\ No newline at end of file
diff --git a/feature/breeds/all-breeds/src/main/java/com/aliumujib/all/breeds/presentation/list/BreedsIntentProcessor.kt b/feature/breeds/all-breeds/src/main/java/com/aliumujib/all/breeds/presentation/list/BreedsIntentProcessor.kt
new file mode 100644
index 0000000..2393961
--- /dev/null
+++ b/feature/breeds/all-breeds/src/main/java/com/aliumujib/all/breeds/presentation/list/BreedsIntentProcessor.kt
@@ -0,0 +1,45 @@
+package com.aliumujib.all.breeds.presentation.list
+
+import com.aliumujib.breed.common.presentation.IntentProcessor
+import com.aliumujib.songs.domain.usecases.StreamBreedsListUseCase
+import com.aliumujib.songs.domain.usecases.ToggleFavoriteUseCase
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emitAll
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+
+class BreedsIntentProcessor @Inject constructor(
+ private val streamBreedsListUseCase: StreamBreedsListUseCase,
+ private val toggleFavoriteUseCase: ToggleFavoriteUseCase
+) : IntentProcessor {
+
+
+ override fun intentToResult(viewIntent: BreedsContract.BreedsUiIntent): Flow {
+ return flow {
+ when (viewIntent) {
+ BreedsContract.BreedsUiIntent.FetchCatBreeds -> {
+ emit(BreedsContract.BreedsResult.FetchCatBreedsResult.Loading)
+ try {
+ emitAll(
+ streamBreedsListUseCase().map {
+ BreedsContract.BreedsResult.FetchCatBreedsResult.Success(it)
+ }
+ )
+ } catch (e: Exception) {
+ emit(BreedsContract.BreedsResult.FetchCatBreedsResult.Error(e))
+ }
+ }
+
+ is BreedsContract.BreedsUiIntent.ToggleFavouriteStatus -> {
+ toggleFavoriteUseCase(viewIntent.breedId).onSuccess {
+ emit(BreedsContract.BreedsResult.ToggleFavouriteStatusResult.Success(it))
+ }.onFailure {
+ emit(BreedsContract.BreedsResult.ToggleFavouriteStatusResult.Error(it))
+ }
+ }
+ }
+ }
+ }
+
+}
diff --git a/feature/breeds/all-breeds/src/main/java/com/aliumujib/all/breeds/presentation/list/BreedsStateReducer.kt b/feature/breeds/all-breeds/src/main/java/com/aliumujib/all/breeds/presentation/list/BreedsStateReducer.kt
new file mode 100644
index 0000000..c89dbfb
--- /dev/null
+++ b/feature/breeds/all-breeds/src/main/java/com/aliumujib/all/breeds/presentation/list/BreedsStateReducer.kt
@@ -0,0 +1,51 @@
+package com.aliumujib.all.breeds.presentation.list
+
+import com.aliumujib.breed.common.presentation.StateReducer
+import javax.inject.Inject
+
+class BreedsStateReducer @Inject constructor() :
+ StateReducer {
+
+ override fun reduce(
+ oldState: BreedsContract.BreedsUiState,
+ result: BreedsContract.BreedsResult
+ ): BreedsContract.BreedsUiState {
+ return when (result) {
+ is BreedsContract.BreedsResult.FetchCatBreedsResult -> {
+ when (result) {
+ is BreedsContract.BreedsResult.FetchCatBreedsResult.Error -> {
+ BreedsContract.BreedsUiState.Error(result.throwable.message)
+ }
+
+ BreedsContract.BreedsResult.FetchCatBreedsResult.Loading -> {
+ BreedsContract.BreedsUiState.Loading
+ }
+
+ is BreedsContract.BreedsResult.FetchCatBreedsResult.Success -> {
+ if (result.data.isEmpty()) {
+ BreedsContract.BreedsUiState.Empty
+ } else {
+ BreedsContract.BreedsUiState.Success(result.data)
+ }
+ }
+ }
+ }
+
+ is BreedsContract.BreedsResult.ToggleFavouriteStatusResult -> {
+ when (result) {
+ is BreedsContract.BreedsResult.ToggleFavouriteStatusResult.Error -> {
+ BreedsContract.BreedsUiState.Error(result.throwable.message)
+ }
+
+ is BreedsContract.BreedsResult.ToggleFavouriteStatusResult.Success -> {
+ val updated = oldState.breeds.map {
+ if (it.id == result.data) it.copy(isFavorite = !it.isFavorite) else it
+ }
+ BreedsContract.BreedsUiState.Success(updated)
+ }
+ }
+ }
+ }
+ }
+
+}
diff --git a/feature/breeds/all-breeds/src/main/java/com/aliumujib/all/breeds/presentation/list/BreedsViewModel.kt b/feature/breeds/all-breeds/src/main/java/com/aliumujib/all/breeds/presentation/list/BreedsViewModel.kt
new file mode 100644
index 0000000..99d2313
--- /dev/null
+++ b/feature/breeds/all-breeds/src/main/java/com/aliumujib/all/breeds/presentation/list/BreedsViewModel.kt
@@ -0,0 +1,25 @@
+package com.aliumujib.all.breeds.presentation.list
+
+import androidx.lifecycle.ViewModel
+import com.aliumujib.breed.common.presentation.MVI
+import com.aliumujib.breed.common.presentation.mvi
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+
+@HiltViewModel
+class BreedsViewModel @Inject constructor(
+ private val intentProcessor: BreedsIntentProcessor,
+ private val stateReducer: BreedsStateReducer,
+) : ViewModel(),
+ MVI by mvi(
+ BreedsContract.BreedsUiState.Initial,
+ intentProcessor,
+ stateReducer
+ ) {
+
+ fun start() {
+ processActions()
+ onAction(BreedsContract.BreedsUiIntent.FetchCatBreeds)
+ }
+
+}
diff --git a/feature/breeds/all-breeds/src/main/java/com/aliumujib/all/breeds/ui/BreedsScreen.kt b/feature/breeds/all-breeds/src/main/java/com/aliumujib/all/breeds/ui/BreedsScreen.kt
new file mode 100644
index 0000000..fb5dbf8
--- /dev/null
+++ b/feature/breeds/all-breeds/src/main/java/com/aliumujib/all/breeds/ui/BreedsScreen.kt
@@ -0,0 +1,189 @@
+package com.aliumujib.all.breeds.ui
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.aliumujib.all.breeds.R
+import com.aliumujib.all.breeds.navigation.BreedsNavigator
+import com.aliumujib.all.breeds.presentation.list.BreedsContract
+import com.aliumujib.all.breeds.presentation.list.BreedsViewModel
+import com.aliumujib.model.Breed
+import com.aliumujib.breed.common.ui.BreedDetailsSummaryBottomSheet
+import com.aliumujib.breed.common.ui.BreedListItem
+import com.ramcosta.composedestinations.annotation.Destination
+
+@Destination
+@Composable
+fun BreedsScreen(
+ navigator: BreedsNavigator,
+ viewModel: BreedsViewModel = hiltViewModel()
+) {
+ var isMoreSheetOpen by remember { mutableStateOf(false) }
+ var focusedBreed by remember { mutableStateOf(null) }
+
+ val uiState by viewModel.states.collectAsStateWithLifecycle()
+
+ LaunchedEffect(Unit) {
+ viewModel.start()
+ }
+
+ BreedsScreenContent(
+ isMoreSheetOpen = isMoreSheetOpen,
+ uiState = uiState,
+ focusedBreed = focusedBreed,
+ onItemClicked = { breed ->
+ navigator.goToDetails(breed.id)
+ },
+ onMoreClick = {
+ focusedBreed = it
+ isMoreSheetOpen = true
+ },
+ onMoreDismissedRequest = {
+ focusedBreed = null
+ isMoreSheetOpen = false
+ },
+ onFavoriteClick = {
+ focusedBreed = it.copy(isFavorite = !it.isFavorite)
+ viewModel.onAction(BreedsContract.BreedsUiIntent.ToggleFavouriteStatus(it.id))
+ }
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BreedsScreenContent(
+ isMoreSheetOpen: Boolean,
+ uiState: BreedsContract.BreedsUiState,
+ focusedBreed: Breed?,
+ onItemClicked: (Breed) -> Unit,
+ onMoreClick: (Breed) -> Unit,
+ onMoreDismissedRequest: () -> Unit,
+ onFavoriteClick: (Breed) -> Unit,
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ ) {
+ val sheetState = rememberModalBottomSheetState()
+ val lazyListState = rememberLazyListState()
+ val breeds = uiState.breeds
+
+ Scaffold(
+ topBar = {
+ Column(Modifier.padding(16.dp)) {
+ Text(
+ text = stringResource(id = R.string.cats_tab_title),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = stringResource(id = R.string.cats_tab_sub_title),
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ },
+ ) { values ->
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(values),
+ state = lazyListState,
+ ) {
+
+ when (uiState) {
+ is BreedsContract.BreedsUiState.Error -> {
+ item {
+ Text(
+ text = uiState.errorMessage ?: stringResource(id = R.string.default_error_message),
+ modifier = Modifier
+ .padding(16.dp)
+ .fillMaxSize(),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+
+ BreedsContract.BreedsUiState.Loading -> {
+ item {
+ Box(
+ modifier = Modifier
+ .fillParentMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator(Modifier.testTag("Loading"))
+ }
+ }
+ }
+
+ is BreedsContract.BreedsUiState.Success -> {
+ itemsIndexed(breeds,
+ key = { _, breed -> breed.id }) { _, item ->
+ BreedListItem(
+ breed = item,
+ onItemClick = onItemClicked,
+ onMoreClick = {
+ onMoreClick(it)
+ }
+ )
+ }
+ }
+
+ BreedsContract.BreedsUiState.Initial -> {
+
+ }
+
+ BreedsContract.BreedsUiState.Empty -> {
+ item {
+ Text(
+ text = "No music found !",
+ modifier = Modifier
+ .padding(16.dp)
+ .fillMaxSize(),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+ }
+ }
+ }
+
+ if (isMoreSheetOpen && focusedBreed != null) {
+ BreedDetailsSummaryBottomSheet(
+ breed = focusedBreed,
+ sheetState = sheetState,
+ onDismissRequest = onMoreDismissedRequest,
+ onFavoriteClick = onFavoriteClick
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/feature/breeds/all-breeds/src/main/res/values/strings.xml b/feature/breeds/all-breeds/src/main/res/values/strings.xml
new file mode 100644
index 0000000..f4d00dc
--- /dev/null
+++ b/feature/breeds/all-breeds/src/main/res/values/strings.xml
@@ -0,0 +1,14 @@
+
+
+ Add song from local storage
+ Download song from internet
+
+ Enter a valid download url
+ Enter URL
+ Download
+ Cancel
+ Cat Breeds
+ "Explore the most beautiful Cat Breeds from allover the world "
+ An error occurred, please retry later
+
+
\ No newline at end of file
diff --git a/feature/breeds/all-breeds/src/test/java/com/aliumujib/all/breeds/presentation/list/BreedsIntentProcessorTest.kt b/feature/breeds/all-breeds/src/test/java/com/aliumujib/all/breeds/presentation/list/BreedsIntentProcessorTest.kt
new file mode 100644
index 0000000..7748d04
--- /dev/null
+++ b/feature/breeds/all-breeds/src/test/java/com/aliumujib/all/breeds/presentation/list/BreedsIntentProcessorTest.kt
@@ -0,0 +1,91 @@
+package com.aliumujib.all.breeds.presentation.list
+
+import org.junit.Assert.*
+
+import app.cash.turbine.test
+import com.aliumujib.common.test.SharedDummyData
+import com.aliumujib.songs.domain.usecases.StreamBreedsListUseCase
+import com.aliumujib.songs.domain.usecases.ToggleFavoriteUseCase
+import com.google.common.truth.Truth.assertThat
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.MockKAnnotations
+import io.mockk.impl.annotations.InjectMockKs
+import io.mockk.impl.annotations.MockK
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+
+class BreedsIntentProcessorTest {
+
+ @MockK
+ private lateinit var streamBreedsListUseCase: StreamBreedsListUseCase
+
+ @MockK
+ private lateinit var toggleFavoriteUseCase: ToggleFavoriteUseCase
+
+ @InjectMockKs
+ private lateinit var processor: BreedsIntentProcessor
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+ }
+
+ @Test
+ fun `when FetchCatBreeds intent is received, then emit Loading and Success`() = runTest {
+ val breeds = SharedDummyData.breedList
+ every { streamBreedsListUseCase() } returns flowOf(breeds)
+
+ val intent = BreedsContract.BreedsUiIntent.FetchCatBreeds
+
+ processor.intentToResult(intent).test {
+ assertThat(awaitItem()).isEqualTo(BreedsContract.BreedsResult.FetchCatBreedsResult.Loading)
+ assertThat(awaitItem()).isEqualTo(BreedsContract.BreedsResult.FetchCatBreedsResult.Success(breeds))
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when FetchCatBreeds intent throws error, then emit Loading and Error`() = runTest {
+ val error = RuntimeException("Error fetching breeds")
+ every { streamBreedsListUseCase() } returns flow { throw error }
+
+ val intent = BreedsContract.BreedsUiIntent.FetchCatBreeds
+
+ processor.intentToResult(intent).test {
+ assertThat(awaitItem()).isEqualTo(BreedsContract.BreedsResult.FetchCatBreedsResult.Loading)
+ assertThat(awaitItem()).isEqualTo(BreedsContract.BreedsResult.FetchCatBreedsResult.Error(error))
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when ToggleFavouriteStatus intent is received, then emit Success`() = runTest {
+ val breedId = SharedDummyData.breed2.id
+ coEvery { toggleFavoriteUseCase(breedId) } returns Result.success(breedId)
+
+ val intent = BreedsContract.BreedsUiIntent.ToggleFavouriteStatus(breedId)
+
+ processor.intentToResult(intent).test {
+ assertThat(awaitItem()).isEqualTo(BreedsContract.BreedsResult.ToggleFavouriteStatusResult.Success(breedId))
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when ToggleFavouriteStatus intent fails, then emit Error`() = runTest {
+ val breedId = SharedDummyData.breed3.id
+ val error = RuntimeException("Error toggling favourite status")
+ coEvery { toggleFavoriteUseCase(breedId) } returns Result.failure(error)
+
+ val intent = BreedsContract.BreedsUiIntent.ToggleFavouriteStatus(breedId)
+
+ processor.intentToResult(intent).test {
+ assertThat(awaitItem()).isEqualTo(BreedsContract.BreedsResult.ToggleFavouriteStatusResult.Error(error))
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+}
diff --git a/feature/breeds/all-breeds/src/test/java/com/aliumujib/all/breeds/presentation/list/BreedsStateReducerTest.kt b/feature/breeds/all-breeds/src/test/java/com/aliumujib/all/breeds/presentation/list/BreedsStateReducerTest.kt
new file mode 100644
index 0000000..a03ab4c
--- /dev/null
+++ b/feature/breeds/all-breeds/src/test/java/com/aliumujib/all/breeds/presentation/list/BreedsStateReducerTest.kt
@@ -0,0 +1,82 @@
+package com.aliumujib.all.breeds.presentation.list
+
+import com.aliumujib.common.test.SharedDummyData
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class BreedsStateReducerTest {
+
+ private val reducer: BreedsStateReducer = BreedsStateReducer()
+
+ @Test
+ fun `given initial state, when FetchCatBreedsResult Loading, then state is Loading`() {
+ val initialState = BreedsContract.BreedsUiState.Initial
+ val result = BreedsContract.BreedsResult.FetchCatBreedsResult.Loading
+
+ val newState = reducer.reduce(initialState, result)
+
+ assertThat(newState).isEqualTo(BreedsContract.BreedsUiState.Loading)
+ }
+
+ @Test
+ fun `given initial state, when FetchCatBreedsResult Error, then state is Error`() {
+ val initialState = BreedsContract.BreedsUiState.Initial
+ val error = Throwable("Error fetching breeds")
+ val result = BreedsContract.BreedsResult.FetchCatBreedsResult.Error(error)
+
+ val newState = reducer.reduce(initialState, result)
+
+ assertThat(newState).isEqualTo(BreedsContract.BreedsUiState.Error(error.message))
+ }
+
+ @Test
+ fun `given initial state, when FetchCatBreedsResult Success with data, then state is Success`() {
+ val initialState = BreedsContract.BreedsUiState.Initial
+ val breeds = SharedDummyData.breedList
+ val result = BreedsContract.BreedsResult.FetchCatBreedsResult.Success(breeds)
+
+ val newState = reducer.reduce(initialState, result)
+
+ assertThat(newState).isEqualTo(BreedsContract.BreedsUiState.Success(breeds))
+ }
+
+ @Test
+ fun `given initial state, when FetchCatBreedsResult Success with empty data, then state is Empty`() {
+ val initialState = BreedsContract.BreedsUiState.Initial
+ val result = BreedsContract.BreedsResult.FetchCatBreedsResult.Success(emptyList())
+
+ val newState = reducer.reduce(initialState, result)
+
+ assertThat(newState).isEqualTo(BreedsContract.BreedsUiState.Empty)
+ }
+
+ @Test
+ fun `given success state, when ToggleFavouriteStatusResult Success, then update breed favorite status`() {
+ val breeds = SharedDummyData.breedList
+ val breedId = breeds.first().id
+ val initialState = BreedsContract.BreedsUiState.Success(breeds)
+ val result = BreedsContract.BreedsResult.ToggleFavouriteStatusResult.Success(breedId)
+
+ val newState = reducer.reduce(initialState, result)
+
+ val updatedBreeds = breeds.map {
+ if (it.id == breedId) it.copy(isFavorite = !it.isFavorite) else it
+ }
+
+ assertThat(newState).isEqualTo(BreedsContract.BreedsUiState.Success(updatedBreeds))
+ }
+
+ @Test
+ fun `given success state, when ToggleFavouriteStatusResult Error, then state is Error`() {
+ val breeds = SharedDummyData.breedList
+
+ val initialState = BreedsContract.BreedsUiState.Success(breeds)
+ val error = Throwable("Error toggling favorite status")
+ val result = BreedsContract.BreedsResult.ToggleFavouriteStatusResult.Error(error)
+
+ val newState = reducer.reduce(initialState, result)
+
+ assertThat(newState).isEqualTo(BreedsContract.BreedsUiState.Error(error.message))
+ }
+}
diff --git a/feature/breeds/breed-details/.gitignore b/feature/breeds/breed-details/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/feature/breeds/breed-details/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/feature/breeds/breed-details/build.gradle.kts b/feature/breeds/breed-details/build.gradle.kts
new file mode 100644
index 0000000..ae73b33
--- /dev/null
+++ b/feature/breeds/breed-details/build.gradle.kts
@@ -0,0 +1,86 @@
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.android.kotlin)
+ alias(libs.plugins.kapt)
+ alias(libs.plugins.parcelize)
+ alias(libs.plugins.ksp)
+}
+
+apply {
+ from("$rootDir/base-module.gradle")
+}
+
+android {
+ compileSdk = AndroidConfig.compileSDK
+
+ defaultConfig {
+ minSdk = AndroidConfig.minSDK
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = AndroidConfig.javaVersion
+ targetCompatibility = AndroidConfig.javaVersion
+ }
+ kotlinOptions {
+ jvmTarget = AndroidConfig.jvmTarget
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+ namespace = "com.aliumujib.songs.now.playing"
+}
+
+ksp {
+ arg("compose-destinations.mode", "destinations")
+ arg("compose-destinations.moduleName", "now.playing")
+}
+
+kotlin {
+ sourceSets {
+ debug {
+ kotlin.srcDir("build/generated/ksp/debug/kotlin")
+ }
+ release {
+ kotlin.srcDir("build/generated/ksp/release/kotlin")
+ }
+ }
+}
+
+dependencies {
+ implementation(projects.core.designsystem)
+ implementation(projects.core.common)
+ implementation(projects.core.analytics)
+ implementation(projects.core.preferences)
+ implementation(projects.core.models)
+
+ implementation(projects.feature.breeds.common)
+ implementation(projects.feature.breeds.breedsDomain)
+ implementation(projects.core.commonDomain)
+ implementation(libs.androidx.lifecycle.compose.android)
+
+ androidTestImplementation(libs.compose.ui.test.junit4)
+ androidTestImplementation(libs.bundles.testing)
+ androidTestImplementation(projects.core.commonTest)
+ androidTestImplementation(libs.mockk.android)
+
+ testImplementation(libs.bundles.testing)
+ testImplementation(projects.core.commonTest)
+
+ implementation(libs.compose.destinations.animations)
+ implementation(libs.androidx.material3.android)
+ ksp(libs.compose.destinations.ksp)
+}
diff --git a/feature/breeds/breed-details/consumer-rules.pro b/feature/breeds/breed-details/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/feature/breeds/breed-details/proguard-rules.pro b/feature/breeds/breed-details/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/feature/breeds/breed-details/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/feature/breeds/breed-details/src/androidTest/AndroidManifest.xml b/feature/breeds/breed-details/src/androidTest/AndroidManifest.xml
new file mode 100644
index 0000000..69fc412
--- /dev/null
+++ b/feature/breeds/breed-details/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/feature/breeds/breed-details/src/androidTest/java/com/aliumujib/breed/details/ui/CatBreedDetailsContentTest.kt b/feature/breeds/breed-details/src/androidTest/java/com/aliumujib/breed/details/ui/CatBreedDetailsContentTest.kt
new file mode 100644
index 0000000..ed5f507
--- /dev/null
+++ b/feature/breeds/breed-details/src/androidTest/java/com/aliumujib/breed/details/ui/CatBreedDetailsContentTest.kt
@@ -0,0 +1,60 @@
+package com.aliumujib.breed.details.ui
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.aliumujib.breed.details.presentation.BreedDetailsContract
+import com.aliumujib.common.test.SharedDummyData
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class CatBreedDetailsContentTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Test
+ fun catBreedDetailsContent_displaysSuccessStateCorrectly() {
+ val breed = SharedDummyData.breed1
+ val state = BreedDetailsContract.BreedDetailsUiState.Success(breed)
+
+ composeTestRule.setContent {
+ CatBreedDetailsContent(
+ state = state,
+ onNavigateUp = {}
+ )
+ }
+
+ composeTestRule.onNodeWithText("Abyssinian").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Very playful and active").assertIsDisplayed()
+ }
+
+ @Test
+ fun catBreedDetailsContent_displaysLoadingState() {
+ composeTestRule.setContent {
+ CatBreedDetailsContent(
+ state = BreedDetailsContract.BreedDetailsUiState.Loading,
+ onNavigateUp = {}
+ )
+ }
+
+ composeTestRule.onNodeWithContentDescription("Progress Indicator").assertIsDisplayed()
+ }
+
+ @Test
+ fun catBreedDetailsContent_displaysErrorState() {
+ val errorMessage = "Network Error"
+ composeTestRule.setContent {
+ CatBreedDetailsContent(
+ state = BreedDetailsContract.BreedDetailsUiState.Error(errorMessage),
+ onNavigateUp = {}
+ )
+ }
+
+ composeTestRule.onNodeWithText(errorMessage).assertIsDisplayed()
+ }
+}
diff --git a/feature/breeds/breed-details/src/main/AndroidManifest.xml b/feature/breeds/breed-details/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..69fc412
--- /dev/null
+++ b/feature/breeds/breed-details/src/main/AndroidManifest.xml
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/feature/breeds/breed-details/src/main/java/com/aliumujib/breed/details/navigator/BreedDetailsNavigator.kt b/feature/breeds/breed-details/src/main/java/com/aliumujib/breed/details/navigator/BreedDetailsNavigator.kt
new file mode 100644
index 0000000..13dbb65
--- /dev/null
+++ b/feature/breeds/breed-details/src/main/java/com/aliumujib/breed/details/navigator/BreedDetailsNavigator.kt
@@ -0,0 +1,5 @@
+package com.aliumujib.breed.details.navigator
+
+interface BreedDetailsNavigator {
+ fun goToBack()
+}
diff --git a/feature/breeds/breed-details/src/main/java/com/aliumujib/breed/details/presentation/BreedDetailsContract.kt b/feature/breeds/breed-details/src/main/java/com/aliumujib/breed/details/presentation/BreedDetailsContract.kt
new file mode 100644
index 0000000..cae4ce6
--- /dev/null
+++ b/feature/breeds/breed-details/src/main/java/com/aliumujib/breed/details/presentation/BreedDetailsContract.kt
@@ -0,0 +1,38 @@
+package com.aliumujib.breed.details.presentation
+
+import androidx.compose.runtime.Immutable
+import com.aliumujib.model.Breed
+import com.aliumujib.model.BreedId
+
+interface BreedDetailsContract {
+
+ @Immutable
+ sealed class BreedDetailsUiState {
+ data class Success(val breed: Breed) : BreedDetailsUiState()
+ data object Loading : BreedDetailsUiState()
+ data class Error(val errorMessage: String?) : BreedDetailsUiState()
+ data object Initial : BreedDetailsUiState()
+ }
+
+ sealed interface BreedDetailsResult {
+ sealed interface FetchCatBreedDetailsResult : BreedDetailsResult {
+ data object Loading : FetchCatBreedDetailsResult
+ data class Error(val throwable: Throwable) : FetchCatBreedDetailsResult
+ data class Success(val data: Breed) : FetchCatBreedDetailsResult
+ }
+
+ sealed interface ToggleFavouriteStatusResult : BreedDetailsResult {
+ data class Error(val throwable: Throwable) : ToggleFavouriteStatusResult
+ data class Success(val data: BreedId) : ToggleFavouriteStatusResult
+ }
+ }
+
+ sealed interface BreedDetailsUiIntent {
+ data class FetchCatBreedDetails(val breedId: BreedId) : BreedDetailsUiIntent
+ data class ToggleFavouriteStatus(val breedId: BreedId) : BreedDetailsUiIntent
+ }
+
+ sealed interface BreedDetailsSideEffect {
+ data class ShowErrorToast(val error: String) : BreedDetailsSideEffect
+ }
+}
\ No newline at end of file
diff --git a/feature/breeds/breed-details/src/main/java/com/aliumujib/breed/details/presentation/BreedDetailsIntentProcessor.kt b/feature/breeds/breed-details/src/main/java/com/aliumujib/breed/details/presentation/BreedDetailsIntentProcessor.kt
new file mode 100644
index 0000000..700c559
--- /dev/null
+++ b/feature/breeds/breed-details/src/main/java/com/aliumujib/breed/details/presentation/BreedDetailsIntentProcessor.kt
@@ -0,0 +1,45 @@
+package com.aliumujib.breed.details.presentation
+
+import com.aliumujib.breed.common.presentation.IntentProcessor
+import com.aliumujib.songs.domain.usecases.GetCatBreedDetailsUseCase
+import com.aliumujib.songs.domain.usecases.ToggleFavoriteUseCase
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emitAll
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+
+
+class BreedDetailsIntentProcessor @Inject constructor(
+ private val streamCatBreedDetailsUseCase: GetCatBreedDetailsUseCase,
+ private val toggleFavoriteUseCase: ToggleFavoriteUseCase
+) : IntentProcessor {
+
+ override fun intentToResult(viewIntent: BreedDetailsContract.BreedDetailsUiIntent): Flow {
+ return flow {
+ when (viewIntent) {
+ is BreedDetailsContract.BreedDetailsUiIntent.FetchCatBreedDetails -> {
+ emit(BreedDetailsContract.BreedDetailsResult.FetchCatBreedDetailsResult.Loading)
+ try {
+ emitAll(
+ streamCatBreedDetailsUseCase(viewIntent.breedId).map {
+ BreedDetailsContract.BreedDetailsResult.FetchCatBreedDetailsResult.Success(it)
+ }
+ )
+ } catch (e: Exception) {
+ emit(BreedDetailsContract.BreedDetailsResult.FetchCatBreedDetailsResult.Error(e))
+ }
+ }
+
+ is BreedDetailsContract.BreedDetailsUiIntent.ToggleFavouriteStatus -> {
+ toggleFavoriteUseCase(viewIntent.breedId).onSuccess {
+ emit(BreedDetailsContract.BreedDetailsResult.ToggleFavouriteStatusResult.Success(it))
+ }.onFailure {
+ emit(BreedDetailsContract.BreedDetailsResult.ToggleFavouriteStatusResult.Error(it))
+ }
+ }
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/feature/breeds/breed-details/src/main/java/com/aliumujib/breed/details/presentation/BreedDetailsStateReducer.kt b/feature/breeds/breed-details/src/main/java/com/aliumujib/breed/details/presentation/BreedDetailsStateReducer.kt
new file mode 100644
index 0000000..af18b56
--- /dev/null
+++ b/feature/breeds/breed-details/src/main/java/com/aliumujib/breed/details/presentation/BreedDetailsStateReducer.kt
@@ -0,0 +1,44 @@
+package com.aliumujib.breed.details.presentation
+
+import com.aliumujib.breed.common.presentation.StateReducer
+import javax.inject.Inject
+
+class BreedDetailsStateReducer @Inject constructor() :
+ StateReducer {
+
+ override fun reduce(
+ oldState: BreedDetailsContract.BreedDetailsUiState,
+ result: BreedDetailsContract.BreedDetailsResult
+ ): BreedDetailsContract.BreedDetailsUiState {
+ return when (result) {
+ is BreedDetailsContract.BreedDetailsResult.FetchCatBreedDetailsResult -> {
+ when (result) {
+ is BreedDetailsContract.BreedDetailsResult.FetchCatBreedDetailsResult.Error -> {
+ BreedDetailsContract.BreedDetailsUiState.Error(result.throwable.message)
+ }
+
+ BreedDetailsContract.BreedDetailsResult.FetchCatBreedDetailsResult.Loading -> {
+ BreedDetailsContract.BreedDetailsUiState.Loading
+ }
+
+ is BreedDetailsContract.BreedDetailsResult.FetchCatBreedDetailsResult.Success -> {
+ BreedDetailsContract.BreedDetailsUiState.Success(result.data)
+ }
+ }
+ }
+
+ is BreedDetailsContract.BreedDetailsResult.ToggleFavouriteStatusResult -> {
+ when (result) {
+ is BreedDetailsContract.BreedDetailsResult.ToggleFavouriteStatusResult.Error -> {
+ BreedDetailsContract.BreedDetailsUiState.Error(result.throwable.message)
+ }
+
+ is BreedDetailsContract.BreedDetailsResult.ToggleFavouriteStatusResult.Success -> {
+ BreedDetailsContract.BreedDetailsUiState.Initial
+ }
+ }
+ }
+ }
+ }
+
+}
diff --git a/feature/breeds/breed-details/src/main/java/com/aliumujib/breed/details/presentation/BreedDetailsViewModel.kt b/feature/breeds/breed-details/src/main/java/com/aliumujib/breed/details/presentation/BreedDetailsViewModel.kt
new file mode 100644
index 0000000..340830a
--- /dev/null
+++ b/feature/breeds/breed-details/src/main/java/com/aliumujib/breed/details/presentation/BreedDetailsViewModel.kt
@@ -0,0 +1,27 @@
+package com.aliumujib.breed.details.presentation
+
+import androidx.lifecycle.ViewModel
+import com.aliumujib.model.BreedId
+import com.aliumujib.breed.common.presentation.MVI
+import com.aliumujib.breed.common.presentation.mvi
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+
+@HiltViewModel
+class BreedDetailsViewModel @Inject constructor(
+ private val intentProcessor: BreedDetailsIntentProcessor,
+ private val stateReducer: BreedDetailsStateReducer,
+) : ViewModel(),
+ MVI by mvi(
+ BreedDetailsContract.BreedDetailsUiState.Initial,
+ intentProcessor,
+ stateReducer
+ ) {
+
+ fun start(breedId: BreedId) {
+ processActions()
+ onAction(BreedDetailsContract.BreedDetailsUiIntent.FetchCatBreedDetails(breedId))
+ }
+
+}
diff --git a/feature/breeds/breed-details/src/main/java/com/aliumujib/breed/details/ui/CatBreedDetailsScreen.kt b/feature/breeds/breed-details/src/main/java/com/aliumujib/breed/details/ui/CatBreedDetailsScreen.kt
new file mode 100644
index 0000000..8925449
--- /dev/null
+++ b/feature/breeds/breed-details/src/main/java/com/aliumujib/breed/details/ui/CatBreedDetailsScreen.kt
@@ -0,0 +1,296 @@
+package com.aliumujib.breed.details.ui
+
+import androidx.annotation.OptIn
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import coil.compose.AsyncImage
+import coil.request.ImageRequest
+import com.aliumujib.model.BreedId
+import com.aliumujib.breed.details.navigator.BreedDetailsNavigator
+import com.aliumujib.breed.details.presentation.BreedDetailsContract
+import com.aliumujib.breed.details.presentation.BreedDetailsViewModel
+import com.aliumujib.songs.commons.R
+import com.ramcosta.composedestinations.annotation.Destination
+import dagger.hilt.android.UnstableApi
+import io.eyram.iconsax.IconSax
+
+@OptIn(UnstableApi::class)
+@Composable
+@Destination
+fun CatBreedDetailsScreen(
+ breedId: BreedId,
+ viewModel: BreedDetailsViewModel = hiltViewModel(),
+ navigator: BreedDetailsNavigator,
+) {
+ val uiState by viewModel.states.collectAsStateWithLifecycle()
+
+ LaunchedEffect(Unit) {
+ viewModel.start(breedId)
+ }
+
+ CatBreedDetailsContent(
+ state = uiState,
+ onNavigateUp = navigator::goToBack,
+ )
+}
+
+@Composable
+fun CatBreedDetailsContent(
+ state: BreedDetailsContract.BreedDetailsUiState,
+ onNavigateUp: () -> Unit,
+) {
+ val context = LocalContext.current
+
+ Scaffold(
+ topBar = {
+ Row(
+ Modifier
+ .padding(16.dp)
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ modifier = Modifier.clickable(onClick = onNavigateUp),
+ painter = painterResource(id = IconSax.Outline.ArrowLeft),
+ contentDescription = "Close"
+ )
+ }
+ }
+ ) { padding ->
+ Box(
+ modifier = Modifier
+ .padding(
+ top = padding.calculateTopPadding() + 15.dp,
+ start = 16.dp,
+ end = 16.dp
+ )
+ .fillMaxSize()
+ ) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth(),
+ horizontalAlignment = Alignment.Start,
+ verticalArrangement = Arrangement.Center
+ ) {
+ when (state) {
+ is BreedDetailsContract.BreedDetailsUiState.Error -> {
+ item {
+ Text(
+ text = state.errorMessage ?: "An error occurred!",
+ modifier = Modifier
+ .padding(16.dp)
+ .fillMaxSize(),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+
+ BreedDetailsContract.BreedDetailsUiState.Initial -> {
+
+ }
+
+ BreedDetailsContract.BreedDetailsUiState.Loading -> {
+ item {
+ Box(
+ modifier = Modifier
+ .fillParentMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ }
+ }
+
+ is BreedDetailsContract.BreedDetailsUiState.Success -> {
+ item {
+ AsyncImage(
+ model = ImageRequest.Builder(context)
+ .placeholder(R.drawable.cat_default)
+ .error(R.drawable.cat_default)
+ .data(state.breed.referenceImageUrl)
+ .crossfade(true)
+ .build(),
+ contentDescription = null,
+ modifier = Modifier
+ .size(340.dp)
+ .clip(RoundedCornerShape(15)),
+ contentScale = ContentScale.Crop
+ )
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = state.breed.name,
+ style = MaterialTheme.typography.headlineSmall
+ )
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = state.breed.name,
+ style = MaterialTheme.typography.bodyLarge
+ )
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(16.dp))
+ BreedDetailRowItem(
+ icon = painterResource(id = IconSax.Linear.Clipboard),
+ title = stringResource(id = R.string.description),
+ content = state.breed.attributes.description,
+ )
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(16.dp))
+ BreedAttributeRowItem(
+ icon = painterResource(id = IconSax.Linear.Speedometer),
+ content = stringResource(
+ id = R.string.temperament,
+ state.breed.attributes.temperament
+ ),
+ )
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(16.dp))
+ BreedAttributeRowItem(
+ icon = painterResource(id = IconSax.Linear.Flag),
+ content = stringResource(
+ id = R.string.origin,
+ state.breed.attributes.origin
+ ),
+ )
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(16.dp))
+ BreedAttributeRowItem(
+ icon = painterResource(id = IconSax.Linear.Cake),
+ content = stringResource(
+ id = R.string.life_span,
+ state.breed.attributes.lifeSpan
+ ),
+ )
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(16.dp))
+ BreedAttributeRowItem(
+ icon = painterResource(id = IconSax.Linear.Courthouse),
+ content = stringResource(
+ id = R.string.lap,
+ state.breed.attributes.lap
+ ),
+ )
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(16.dp))
+ BreedAttributeRowItem(
+ icon = painterResource(id = IconSax.Linear.Tag),
+ content = stringResource(
+ id = R.string.alt_names,
+ state.breed.attributes.altNames
+ ),
+ )
+ }
+ }
+ }
+
+ }
+ }
+
+ }
+}
+
+@Composable
+private fun BreedDetailRowItem(
+ icon: Painter,
+ title: String,
+ content: String,
+ modifier: Modifier = Modifier,
+) {
+
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .animateContentSize()
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(painter = icon, contentDescription = null, modifier = Modifier.size(24.dp))
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(text = title, style = MaterialTheme.typography.bodyLarge)
+ }
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+ Text(
+ text = content,
+ style = MaterialTheme.typography.bodyLarge,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+}
+
+@Composable
+private fun BreedAttributeRowItem(
+ icon: Painter,
+ content: String,
+ modifier: Modifier = Modifier,
+) {
+
+ Row(modifier = modifier, verticalAlignment = Alignment.Top) {
+ Icon(painter = icon, contentDescription = null, modifier = Modifier.size(24.dp))
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = content,
+ style = MaterialTheme.typography.bodyLarge,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+}
+
+
+
+
+
+
+
diff --git a/feature/breeds/breed-details/src/test/java/com/aliumujib/breed/details/presentation/BreedDetailsIntentProcessorTest.kt b/feature/breeds/breed-details/src/test/java/com/aliumujib/breed/details/presentation/BreedDetailsIntentProcessorTest.kt
new file mode 100644
index 0000000..3dca7e5
--- /dev/null
+++ b/feature/breeds/breed-details/src/test/java/com/aliumujib/breed/details/presentation/BreedDetailsIntentProcessorTest.kt
@@ -0,0 +1,95 @@
+package com.aliumujib.breed.details.presentation
+
+import app.cash.turbine.test
+import com.aliumujib.common.test.SharedDummyData
+import com.aliumujib.songs.domain.usecases.GetCatBreedDetailsUseCase
+import com.aliumujib.songs.domain.usecases.ToggleFavoriteUseCase
+import com.google.common.truth.Truth.assertThat
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.MockKAnnotations
+import io.mockk.impl.annotations.InjectMockKs
+import io.mockk.impl.annotations.MockK
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+
+class BreedDetailsIntentProcessorTest {
+
+ @MockK
+ private lateinit var streamCatBreedDetailsUseCase: GetCatBreedDetailsUseCase
+
+ @MockK
+ private lateinit var toggleFavoriteUseCase: ToggleFavoriteUseCase
+
+ @InjectMockKs
+ private lateinit var processor: BreedDetailsIntentProcessor
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+ }
+
+ @Test
+ fun `when FetchCatBreedDetails intent is received, then emit Loading and Success`() = runTest {
+ val breed = SharedDummyData.breed1
+ val breedId = breed.id
+
+ every { streamCatBreedDetailsUseCase(breedId) } returns flowOf(breed)
+
+ val intent = BreedDetailsContract.BreedDetailsUiIntent.FetchCatBreedDetails(breedId)
+
+ processor.intentToResult(intent).test {
+ assertThat(awaitItem()).isEqualTo(BreedDetailsContract.BreedDetailsResult.FetchCatBreedDetailsResult.Loading)
+ assertThat(awaitItem()).isEqualTo(BreedDetailsContract.BreedDetailsResult.FetchCatBreedDetailsResult.Success(breed))
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when FetchCatBreedDetails intent throws error, then emit Loading and Error`() = runTest {
+ val breedId = SharedDummyData.breed1.id
+
+ val error = RuntimeException("Error fetching breed details")
+ every { streamCatBreedDetailsUseCase(breedId) } returns flow { throw error }
+
+ val intent = BreedDetailsContract.BreedDetailsUiIntent.FetchCatBreedDetails(breedId)
+
+ processor.intentToResult(intent).test {
+ assertThat(awaitItem()).isEqualTo(BreedDetailsContract.BreedDetailsResult.FetchCatBreedDetailsResult.Loading)
+ assertThat(awaitItem()).isEqualTo(BreedDetailsContract.BreedDetailsResult.FetchCatBreedDetailsResult.Error(error))
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when ToggleFavouriteStatus intent is received, then emit Success`() = runTest {
+ val breedId = SharedDummyData.breed1.id
+ coEvery { toggleFavoriteUseCase(breedId) } returns Result.success(breedId)
+
+ val intent = BreedDetailsContract.BreedDetailsUiIntent.ToggleFavouriteStatus(breedId)
+
+ processor.intentToResult(intent).test {
+ assertThat(awaitItem()).isEqualTo(BreedDetailsContract.BreedDetailsResult.ToggleFavouriteStatusResult.Success(breedId))
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when ToggleFavouriteStatus intent fails, then emit Error`() = runTest {
+ val breed = SharedDummyData.breed1
+ val breedId = breed.id
+
+ val error = RuntimeException("Error toggling favourite status")
+ coEvery { toggleFavoriteUseCase(breedId) } returns Result.failure(error)
+
+ val intent = BreedDetailsContract.BreedDetailsUiIntent.ToggleFavouriteStatus(breedId)
+
+ processor.intentToResult(intent).test {
+ assertThat(awaitItem()).isEqualTo(BreedDetailsContract.BreedDetailsResult.ToggleFavouriteStatusResult.Error(error))
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+}
diff --git a/feature/breeds/breed-details/src/test/java/com/aliumujib/breed/details/presentation/BreedDetailsStateReducerTest.kt b/feature/breeds/breed-details/src/test/java/com/aliumujib/breed/details/presentation/BreedDetailsStateReducerTest.kt
new file mode 100644
index 0000000..7f7dee1
--- /dev/null
+++ b/feature/breeds/breed-details/src/test/java/com/aliumujib/breed/details/presentation/BreedDetailsStateReducerTest.kt
@@ -0,0 +1,64 @@
+package com.aliumujib.breed.details.presentation
+
+import com.aliumujib.common.test.SharedDummyData
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class BreedDetailsStateReducerTest {
+
+ private val reducer: BreedDetailsStateReducer = BreedDetailsStateReducer()
+
+ @Test
+ fun `given initial state, when FetchCatBreedDetailsResult Loading, then state is Loading`() {
+ val initialState = BreedDetailsContract.BreedDetailsUiState.Initial
+ val result = BreedDetailsContract.BreedDetailsResult.FetchCatBreedDetailsResult.Loading
+
+ val newState = reducer.reduce(initialState, result)
+
+ assertThat(newState).isEqualTo(BreedDetailsContract.BreedDetailsUiState.Loading)
+ }
+
+ @Test
+ fun `given initial state, when FetchCatBreedDetailsResult Success, then state is Success`() {
+ val initialState = BreedDetailsContract.BreedDetailsUiState.Initial
+ val breed = SharedDummyData.breed1
+ val result = BreedDetailsContract.BreedDetailsResult.FetchCatBreedDetailsResult.Success(breed)
+
+ val newState = reducer.reduce(initialState, result)
+
+ assertThat(newState).isEqualTo(BreedDetailsContract.BreedDetailsUiState.Success(breed))
+ }
+
+ @Test
+ fun `given initial state, when FetchCatBreedDetailsResult Error, then state is Error`() {
+ val initialState = BreedDetailsContract.BreedDetailsUiState.Initial
+ val error = Throwable("Error fetching breed details")
+ val result = BreedDetailsContract.BreedDetailsResult.FetchCatBreedDetailsResult.Error(error)
+
+ val newState = reducer.reduce(initialState, result)
+
+ assertThat(newState).isEqualTo(BreedDetailsContract.BreedDetailsUiState.Error(error.message))
+ }
+
+ @Test
+ fun `given initial state, when ToggleFavouriteStatusResult Error, then state is Error`() {
+ val initialState = BreedDetailsContract.BreedDetailsUiState.Initial
+ val error = Throwable("Error toggling favourite status")
+ val result = BreedDetailsContract.BreedDetailsResult.ToggleFavouriteStatusResult.Error(error)
+
+ val newState = reducer.reduce(initialState, result)
+
+ assertThat(newState).isEqualTo(BreedDetailsContract.BreedDetailsUiState.Error(error.message))
+ }
+
+ @Test
+ fun `given initial state, when ToggleFavouriteStatusResult Success, then state is Initial`() {
+ val initialState = BreedDetailsContract.BreedDetailsUiState.Initial
+ val breedId = SharedDummyData.breed1.id
+ val result = BreedDetailsContract.BreedDetailsResult.ToggleFavouriteStatusResult.Success(breedId)
+
+ val newState = reducer.reduce(initialState, result)
+
+ assertThat(newState).isEqualTo(BreedDetailsContract.BreedDetailsUiState.Initial)
+ }
+}
diff --git a/feature/breeds/breeds-data/.gitignore b/feature/breeds/breeds-data/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/feature/breeds/breeds-data/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/feature/breeds/breeds-data/build.gradle.kts b/feature/breeds/breeds-data/build.gradle.kts
new file mode 100644
index 0000000..8dd38c1
--- /dev/null
+++ b/feature/breeds/breeds-data/build.gradle.kts
@@ -0,0 +1,56 @@
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.android.kotlin)
+ alias(libs.plugins.kapt)
+ alias(libs.plugins.parcelize)
+ alias(libs.plugins.ksp)
+}
+
+apply {
+ from("$rootDir/base-module.gradle")
+}
+
+android {
+ compileSdk = AndroidConfig.compileSDK
+
+ defaultConfig {
+ minSdk = AndroidConfig.minSDK
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = AndroidConfig.javaVersion
+ targetCompatibility = AndroidConfig.javaVersion
+ }
+ kotlinOptions {
+ jvmTarget = AndroidConfig.jvmTarget
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+ namespace = "com.aliumujib.songs.data"
+}
+
+dependencies {
+ implementation(projects.core.preferences)
+ implementation(projects.core.database)
+ implementation(projects.core.models)
+ implementation(projects.feature.breeds.breedsDomain)
+ implementation(projects.core.network)
+
+ testImplementation(libs.bundles.testing)
+}
diff --git a/feature/breeds/breeds-data/consumer-rules.pro b/feature/breeds/breeds-data/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/feature/breeds/breeds-data/proguard-rules.pro b/feature/breeds/breeds-data/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/feature/breeds/breeds-data/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/feature/breeds/breeds-data/src/main/AndroidManifest.xml b/feature/breeds/breeds-data/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..44008a4
--- /dev/null
+++ b/feature/breeds/breeds-data/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/feature/breeds/breeds-data/src/main/java/com/aliumujib/songs/data/di/BreedsDataModule.kt b/feature/breeds/breeds-data/src/main/java/com/aliumujib/songs/data/di/BreedsDataModule.kt
new file mode 100644
index 0000000..2c24f99
--- /dev/null
+++ b/feature/breeds/breeds-data/src/main/java/com/aliumujib/songs/data/di/BreedsDataModule.kt
@@ -0,0 +1,38 @@
+package com.aliumujib.songs.data.di
+
+import android.content.Context
+import com.aliumujib.database.dao.BreedsDAO
+import com.aliumujib.database.dao.FavoritesDAO
+import com.aliumujib.network.CatAPIService
+import com.aliumujib.songs.data.mapper.BreedMapper
+import com.aliumujib.songs.data.repo.CatBreedsRepositoryImpl
+import com.aliumujib.songs.domain.repo.CatBreedsRepository
+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)
+object BreedsDataModule {
+
+ @Provides
+ @Singleton
+ fun provideSongsRepository(
+ @ApplicationContext context: Context,
+ songDao: BreedsDAO,
+ favoritesDao: FavoritesDAO,
+ catAPIService: CatAPIService,
+ mapper: BreedMapper
+ ): CatBreedsRepository {
+
+ return CatBreedsRepositoryImpl(
+ songDao,
+ favoritesDao,
+ catAPIService,
+ mapper
+ )
+ }
+}
diff --git a/feature/breeds/breeds-data/src/main/java/com/aliumujib/songs/data/mapper/BreedMapper.kt b/feature/breeds/breeds-data/src/main/java/com/aliumujib/songs/data/mapper/BreedMapper.kt
new file mode 100644
index 0000000..672daad
--- /dev/null
+++ b/feature/breeds/breeds-data/src/main/java/com/aliumujib/songs/data/mapper/BreedMapper.kt
@@ -0,0 +1,106 @@
+package com.aliumujib.songs.data.mapper
+
+import com.aliumujib.database.model.BreedDBModel
+import com.aliumujib.model.Attributes
+import com.aliumujib.model.Breed
+import com.aliumujib.model.BreedId
+import com.aliumujib.model.Characteristics
+import com.aliumujib.model.Urls
+import com.aliumujib.model.Weight
+import com.aliumujib.network.model.BreedAPIModel
+import javax.inject.Inject
+
+class BreedMapper @Inject constructor() {
+
+ fun mapToUIModel(dbModel: BreedDBModel, isFavorite: Boolean): Breed {
+ return with(dbModel) {
+ Breed(
+ id = BreedId(id),
+ name = name,
+ weight = Weight(imperial = weightImperial, metric = weightMetric),
+ urls = Urls(cfaUrl = cfaUrl, vetstreetUrl = vetstreetUrl, vcahospitalsUrl = vcahospitalsUrl),
+ attributes = Attributes(
+ temperament = temperament,
+ origin = origin,
+ countryCodes = countryCodes,
+ countryCode = countryCode,
+ description = description,
+ lifeSpan = lifeSpan,
+ indoor = indoor,
+ lap = lap,
+ altNames = altNames
+ ),
+ characteristics = Characteristics(
+ adaptability = adaptability,
+ affectionLevel = affectionLevel,
+ childFriendly = childFriendly,
+ dogFriendly = dogFriendly,
+ energyLevel = energyLevel,
+ grooming = grooming,
+ healthIssues = healthIssues,
+ intelligence = intelligence,
+ sheddingLevel = sheddingLevel,
+ socialNeeds = socialNeeds,
+ strangerFriendly = strangerFriendly,
+ vocalisation = vocalisation,
+ experimental = experimental,
+ hairless = hairless,
+ natural = natural,
+ rare = rare,
+ rex = rex,
+ suppressedTail = suppressedTail,
+ shortLegs = shortLegs
+ ),
+ wikipediaUrl = wikipediaUrl,
+ hypoallergenic = hypoallergenic,
+ referenceImageUrl = referenceImageUrl,
+ isFavorite = isFavorite
+ )
+ }
+ }
+
+ fun mapToDBModel(breedAPIModel: BreedAPIModel): BreedDBModel {
+ return with(breedAPIModel) {
+ BreedDBModel(
+ id = id,
+ name = name,
+ weightImperial = weight.imperial,
+ weightMetric = weight.metric,
+ cfaUrl = cfaUrl.orEmpty(),
+ vetstreetUrl = vetstreetUrl.orEmpty(),
+ vcahospitalsUrl = vcahospitalsUrl.orEmpty(),
+ temperament = temperament,
+ origin = origin,
+ countryCodes = countryCodes,
+ countryCode = countryCode,
+ description = description,
+ lifeSpan = lifeSpan,
+ indoor = indoor,
+ lap = lap,
+ altNames = altNames.orEmpty(),
+ adaptability = adaptability,
+ affectionLevel = affectionLevel,
+ childFriendly = childFriendly,
+ dogFriendly = dogFriendly,
+ energyLevel = energyLevel,
+ grooming = grooming,
+ healthIssues = healthIssues,
+ intelligence = intelligence,
+ sheddingLevel = sheddingLevel,
+ socialNeeds = socialNeeds,
+ strangerFriendly = strangerFriendly,
+ vocalisation = vocalisation,
+ experimental = experimental,
+ hairless = hairless,
+ natural = natural,
+ rare = rare,
+ rex = rex,
+ suppressedTail = suppressedTail,
+ shortLegs = shortLegs,
+ wikipediaUrl = wikipediaUrl.orEmpty(),
+ hypoallergenic = hypoallergenic,
+ referenceImageUrl = referenceImage?.imageUrl.orEmpty()
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/feature/breeds/breeds-data/src/main/java/com/aliumujib/songs/data/repo/CatBreedsRepositoryImpl.kt b/feature/breeds/breeds-data/src/main/java/com/aliumujib/songs/data/repo/CatBreedsRepositoryImpl.kt
new file mode 100644
index 0000000..4bc7b00
--- /dev/null
+++ b/feature/breeds/breeds-data/src/main/java/com/aliumujib/songs/data/repo/CatBreedsRepositoryImpl.kt
@@ -0,0 +1,71 @@
+package com.aliumujib.songs.data.repo
+
+import com.aliumujib.database.dao.FavoritesDAO
+import com.aliumujib.database.dao.BreedsDAO
+import com.aliumujib.database.model.FavoritesDBModel
+import com.aliumujib.model.Breed
+import com.aliumujib.model.BreedId
+import com.aliumujib.network.CatAPIService
+import com.aliumujib.network.model.BreedImageAPIModel
+import com.aliumujib.songs.data.mapper.BreedMapper
+import com.aliumujib.songs.domain.repo.CatBreedsRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class CatBreedsRepositoryImpl @Inject constructor(
+ private val breedsDao: BreedsDAO,
+ private val favoritesDao: FavoritesDAO,
+ private val catsAPIService: CatAPIService,
+ private val mapper: BreedMapper
+) : CatBreedsRepository {
+
+ override fun streamBreedsList(): Flow> {
+ return breedsDao.streamBreedsList()
+ .onStart {
+ catsAPIService.getBreeds().also { apiModels ->
+ breedsDao.saveBreeds(*apiModels.map(mapper::mapToDBModel).toTypedArray())
+ }
+ }
+ .map { breeds ->
+ breeds.map {
+ mapper.mapToUIModel(it, isFavorite(BreedId(it.id)))
+ }
+ }
+ }
+
+ override suspend fun addFavorite(id: BreedId) {
+ favoritesDao.addFavorite(FavoritesDBModel(id.data))
+ }
+
+ override suspend fun removeFavorite(id: BreedId) {
+ favoritesDao.removeFavorite(FavoritesDBModel(id.data))
+ }
+
+ override suspend fun isFavorite(id: BreedId): Boolean = favoritesDao.isFavorite(id.data)
+
+ override fun streamFavoritesList(): Flow> {
+ return favoritesDao.streamAllFavoritesWithBreeds()
+ .map { favorites ->
+ favorites.map { mapper.mapToUIModel(it.breed!!, true) }
+ }
+ }
+
+ override fun getBreedDetails(id: BreedId): Flow {
+ return breedsDao.streamBreedDetails(id.data)
+ .onStart {
+ catsAPIService.getBreedById(id.data, 1).also { item ->
+ val data =
+ item.copy(referenceImage = BreedImageAPIModel("https://cdn2.thecatapi.com/images/${item.referenceImageId}.jpg"))
+ breedsDao.saveBreeds(mapper.mapToDBModel(data))
+ }
+ }
+ .map { item ->
+ mapper.mapToUIModel(item, isFavorite(BreedId(item.id)))
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/feature/breeds/breeds-data/src/test/java/com/aliumujib/songs/data/DummyData.kt b/feature/breeds/breeds-data/src/test/java/com/aliumujib/songs/data/DummyData.kt
new file mode 100644
index 0000000..51128fe
--- /dev/null
+++ b/feature/breeds/breeds-data/src/test/java/com/aliumujib/songs/data/DummyData.kt
@@ -0,0 +1,406 @@
+package com.aliumujib.songs.data
+
+import com.aliumujib.database.model.BreedDBModel
+import com.aliumujib.model.Attributes
+import com.aliumujib.model.Breed
+import com.aliumujib.model.BreedId
+import com.aliumujib.model.Characteristics
+import com.aliumujib.model.Urls
+import com.aliumujib.model.Weight
+import com.aliumujib.network.model.BreedAPIModel
+import com.aliumujib.network.model.BreedImageAPIModel
+import com.aliumujib.network.model.WeightAPIModel
+
+object TestDummyData {
+
+ val breedAPIModel1 = BreedAPIModel(
+ id = "abys",
+ name = "Abyssinian",
+ weight = WeightAPIModel(imperial = "7 - 10", metric = "3 - 5"),
+ cfaUrl = "http://cfa.org/Breeds/BreedsAB/Abyssinian.aspx",
+ vetstreetUrl = "http://www.vetstreet.com/cats/abyssinian",
+ vcahospitalsUrl = "https://vcahospitals.com/know-your-pet/cat-breeds/abyssinian",
+ temperament = "Active, Energetic, Independent, Intelligent, Gentle",
+ origin = "Egypt",
+ countryCodes = "EG",
+ countryCode = "EG",
+ description = "The Abyssinian is easy to care for, and a joy to have in your home. Theyβre affectionate cats and love both people and other animals.",
+ lifeSpan = "14 - 15",
+ indoor = 0,
+ lap = 1,
+ altNames = "",
+ adaptability = 5,
+ affectionLevel = 5,
+ childFriendly = 3,
+ dogFriendly = 4,
+ energyLevel = 5,
+ grooming = 1,
+ healthIssues = 2,
+ intelligence = 5,
+ sheddingLevel = 2,
+ socialNeeds = 5,
+ strangerFriendly = 5,
+ vocalisation = 1,
+ experimental = 0,
+ hairless = 0,
+ natural = 1,
+ rare = 0,
+ rex = 0,
+ suppressedTail = 0,
+ shortLegs = 0,
+ wikipediaUrl = "https://en.wikipedia.org/wiki/Abyssinian_(cat)",
+ hypoallergenic = 0,
+ referenceImageId = "0XYvRd7oD",
+ referenceImage = BreedImageAPIModel("https://en.wikipedia.org/wiki/0XYvRd7oD")
+ )
+
+ val breedAPIModel2 = BreedAPIModel(
+ id = "aege",
+ name = "Aegean",
+ weight = WeightAPIModel(imperial = "7 - 10", metric = "3 - 5"),
+ cfaUrl = "",
+ vetstreetUrl = "http://www.vetstreet.com/cats/aegean",
+ vcahospitalsUrl = "",
+ temperament = "Affectionate, Social, Intelligent, Playful, Active",
+ origin = "Greece",
+ countryCodes = "GR",
+ countryCode = "GR",
+ description = "Native to the Greek islands known as the Cyclades, the Aegean cat is considered a national treasure.",
+ lifeSpan = "9 - 12",
+ indoor = 0,
+ lap = 1,
+ altNames = "",
+ adaptability = 5,
+ affectionLevel = 4,
+ childFriendly = 4,
+ dogFriendly = 4,
+ energyLevel = 3,
+ grooming = 3,
+ healthIssues = 1,
+ intelligence = 5,
+ sheddingLevel = 3,
+ socialNeeds = 4,
+ strangerFriendly = 5,
+ vocalisation = 3,
+ experimental = 0,
+ hairless = 0,
+ natural = 1,
+ rare = 0,
+ rex = 0,
+ suppressedTail = 0,
+ shortLegs = 0,
+ wikipediaUrl = "https://en.wikipedia.org/wiki/Aegean_cat",
+ hypoallergenic = 0,
+ referenceImageId = "ozEvzdVM-",
+ referenceImage = BreedImageAPIModel("https://en.wikipedia.org/wiki/0XYvRd7oD")
+ )
+
+ val breedAPIModel3 = BreedAPIModel(
+ id = "abob",
+ name = "American Bobtail",
+ weight = WeightAPIModel(imperial = "7 - 16", metric = "3 - 7"),
+ cfaUrl = "http://cfa.org/Breeds/BreedsAB/AmericanBobtail.aspx",
+ vetstreetUrl = "http://www.vetstreet.com/cats/american-bobtail",
+ vcahospitalsUrl = "https://vcahospitals.com/know-your-pet/cat-breeds/american-bobtail",
+ temperament = "Intelligent, Interactive, Lively, Playful, Sensitive",
+ origin = "United States",
+ countryCodes = "US",
+ countryCode = "US",
+ description = "American Bobtails are loving and intelligent cats, known for their distinctive bobbed tails.",
+ lifeSpan = "11 - 15",
+ indoor = 0,
+ lap = 1,
+ altNames = "",
+ adaptability = 5,
+ affectionLevel = 5,
+ childFriendly = 4,
+ dogFriendly = 5,
+ energyLevel = 3,
+ grooming = 3,
+ healthIssues = 2,
+ intelligence = 5,
+ sheddingLevel = 3,
+ socialNeeds = 5,
+ strangerFriendly = 3,
+ vocalisation = 3,
+ experimental = 0,
+ hairless = 0,
+ natural = 0,
+ rare = 0,
+ rex = 0,
+ suppressedTail = 1,
+ shortLegs = 0,
+ wikipediaUrl = "https://en.wikipedia.org/wiki/American_Bobtail",
+ hypoallergenic = 0,
+ referenceImageId = "ozEvzdVM-",
+ referenceImage = BreedImageAPIModel("https://en.wikipedia.org/wiki/0XYvRd7oD")
+ )
+
+ val breedDBModel1 = BreedDBModel(
+ id = "abys",
+ name = "Abyssinian",
+ weightImperial = "7 - 10",
+ weightMetric = "3 - 5",
+ cfaUrl = "http://cfa.org/Breeds/BreedsAB/Abyssinian.aspx",
+ vetstreetUrl = "http://www.vetstreet.com/cats/abyssinian",
+ vcahospitalsUrl = "https://vcahospitals.com/know-your-pet/cat-breeds/abyssinian",
+ temperament = "Active, Energetic, Independent, Intelligent, Gentle",
+ origin = "Egypt",
+ countryCodes = "EG",
+ countryCode = "EG",
+ description = "The Abyssinian is easy to care for, and a joy to have in your home. Theyβre affectionate cats and love both people and other animals.",
+ lifeSpan = "14 - 15",
+ indoor = 0,
+ lap = 1,
+ altNames = "",
+ adaptability = 5,
+ affectionLevel = 5,
+ childFriendly = 3,
+ dogFriendly = 4,
+ energyLevel = 5,
+ grooming = 1,
+ healthIssues = 2,
+ intelligence = 5,
+ sheddingLevel = 2,
+ socialNeeds = 5,
+ strangerFriendly = 5,
+ vocalisation = 1,
+ experimental = 0,
+ hairless = 0,
+ natural = 1,
+ rare = 0,
+ rex = 0,
+ suppressedTail = 0,
+ shortLegs = 0,
+ wikipediaUrl = "https://en.wikipedia.org/wiki/Abyssinian_(cat)",
+ hypoallergenic = 0,
+ referenceImageUrl = "0XYvRd7oD"
+ )
+
+ val breedDBModel2 = BreedDBModel(
+ id = "aege",
+ name = "Aegean",
+ weightImperial = "7 - 10",
+ weightMetric = "3 - 5",
+ cfaUrl = "",
+ vetstreetUrl = "http://www.vetstreet.com/cats/aegean",
+ vcahospitalsUrl = "",
+ temperament = "Affectionate, Social, Intelligent, Playful, Active",
+ origin = "Greece",
+ countryCodes = "GR",
+ countryCode = "GR",
+ description = "Native to the Greek islands known as the Cyclades, the Aegean cat is considered a national treasure.",
+ lifeSpan = "9 - 12",
+ indoor = 0,
+ lap = 1,
+ altNames = "",
+ adaptability = 5,
+ affectionLevel = 4,
+ childFriendly = 4,
+ dogFriendly = 4,
+ energyLevel = 3,
+ grooming = 3,
+ healthIssues = 1,
+ intelligence = 5,
+ sheddingLevel = 3,
+ socialNeeds = 4,
+ strangerFriendly = 5,
+ vocalisation = 3,
+ experimental = 0,
+ hairless = 0,
+ natural = 1,
+ rare = 0,
+ rex = 0,
+ suppressedTail = 0,
+ shortLegs = 0,
+ wikipediaUrl = "https://en.wikipedia.org/wiki/Aegean_cat",
+ hypoallergenic = 0,
+ referenceImageUrl = "ozEvzdVM-"
+ )
+
+ val breedDBModel3 = BreedDBModel(
+ id = "abob",
+ name = "American Bobtail",
+ weightImperial = "7 - 16",
+ weightMetric = "3 - 7",
+ cfaUrl = "http://cfa.org/Breeds/BreedsAB/AmericanBobtail.aspx",
+ vetstreetUrl = "http://www.vetstreet.com/cats/american-bobtail",
+ vcahospitalsUrl = "https://vcahospitals.com/know-your-pet/cat-breeds/american-bobtail",
+ temperament = "Intelligent, Interactive, Lively, Playful, Sensitive",
+ origin = "United States",
+ countryCodes = "US",
+ countryCode = "US",
+ description = "American Bobtails are loving and intelligent cats, known for their distinctive bobbed tails.",
+ lifeSpan = "11 - 15",
+ indoor = 0,
+ lap = 1,
+ altNames = "",
+ adaptability = 5,
+ affectionLevel = 5,
+ childFriendly = 4,
+ dogFriendly = 5,
+ energyLevel = 3,
+ grooming = 3,
+ healthIssues = 2,
+ intelligence = 5,
+ sheddingLevel = 3,
+ socialNeeds = 5,
+ strangerFriendly = 3,
+ vocalisation = 3,
+ experimental = 0,
+ hairless = 0,
+ natural = 0,
+ rare = 0,
+ rex = 0,
+ suppressedTail = 1,
+ shortLegs = 0,
+ wikipediaUrl = "https://en.wikipedia.org/wiki/American_Bobtail",
+ hypoallergenic = 0,
+ referenceImageUrl = "hBXicehMA"
+ )
+
+ val breed1 = Breed(
+ id = BreedId("abys"),
+ name = "Abyssinian",
+ weight = Weight(imperial = "7 - 10", metric = "3 - 5"),
+ urls = Urls(
+ cfaUrl = "http://cfa.org/Breeds/BreedsAB/Abyssinian.aspx",
+ vetstreetUrl = "http://www.vetstreet.com/cats/abyssinian",
+ vcahospitalsUrl = "https://vcahospitals.com/know-your-pet/cat-breeds/abyssinian"
+ ),
+ attributes = Attributes(
+ temperament = "Active, Energetic, Independent, Intelligent, Gentle",
+ origin = "Egypt",
+ countryCodes = "EG",
+ countryCode = "EG",
+ description = "The Abyssinian is easy to care for, and a joy to have in your home. Theyβre affectionate cats and love both people and other animals.",
+ lifeSpan = "14 - 15",
+ indoor = 0,
+ lap = 1,
+ altNames = ""
+ ),
+ characteristics = Characteristics(
+ adaptability = 5,
+ affectionLevel = 5,
+ childFriendly = 3,
+ dogFriendly = 4,
+ energyLevel = 5,
+ grooming = 1,
+ healthIssues = 2,
+ intelligence = 5,
+ sheddingLevel = 2,
+ socialNeeds = 5,
+ strangerFriendly = 5,
+ vocalisation = 1,
+ experimental = 0,
+ hairless = 0,
+ natural = 1,
+ rare = 0,
+ rex = 0,
+ suppressedTail = 0,
+ shortLegs = 0
+ ),
+ wikipediaUrl = "https://en.wikipedia.org/wiki/Abyssinian_(cat)",
+ hypoallergenic = 0,
+ referenceImageUrl = "0XYvRd7oD",
+ isFavorite = true
+ )
+
+ val breed2 = Breed(
+ id = BreedId("aege"),
+ name = "Aegean",
+ weight = Weight(imperial = "7 - 10", metric = "3 - 5"),
+ urls = Urls(
+ cfaUrl = "",
+ vetstreetUrl = "http://www.vetstreet.com/cats/aegean",
+ vcahospitalsUrl = ""
+ ),
+ attributes = Attributes(
+ temperament = "Affectionate, Social, Intelligent, Playful, Active",
+ origin = "Greece",
+ countryCodes = "GR",
+ countryCode = "GR",
+ description = "Native to the Greek islands known as the Cyclades, the Aegean cat is considered a national treasure.",
+ lifeSpan = "9 - 12",
+ indoor = 0,
+ lap = 1,
+ altNames = ""
+ ),
+ characteristics = Characteristics(
+ adaptability = 5,
+ affectionLevel = 4,
+ childFriendly = 4,
+ dogFriendly = 4,
+ energyLevel = 3,
+ grooming = 3,
+ healthIssues = 1,
+ intelligence = 5,
+ sheddingLevel = 3,
+ socialNeeds = 4,
+ strangerFriendly = 5,
+ vocalisation = 3,
+ experimental = 0,
+ hairless = 0,
+ natural = 1,
+ rare = 0,
+ rex = 0,
+ suppressedTail = 0,
+ shortLegs = 0
+ ),
+ wikipediaUrl = "https://en.wikipedia.org/wiki/Aegean_cat",
+ hypoallergenic = 0,
+ referenceImageUrl = "ozEvzdVM-",
+ isFavorite = true
+ )
+
+ val breed3 = Breed(
+ id = BreedId("abob"),
+ name = "American Bobtail",
+ weight = Weight(imperial = "7 - 16", metric = "3 - 7"),
+ urls = Urls(
+ cfaUrl = "http://cfa.org/Breeds/BreedsAB/AmericanBobtail.aspx",
+ vetstreetUrl = "http://www.vetstreet.com/cats/american-bobtail",
+ vcahospitalsUrl = "https://vcahospitals.com/know-your-pet/cat-breeds/american-bobtail"
+ ),
+ attributes = Attributes(
+ temperament = "Intelligent, Interactive, Lively, Playful, Sensitive",
+ origin = "United States",
+ countryCodes = "US",
+ countryCode = "US",
+ description = "American Bobtails are loving and intelligent cats, known for their distinctive bobbed tails.",
+ lifeSpan = "11 - 15",
+ indoor = 0,
+ lap = 1,
+ altNames = ""
+ ),
+ characteristics = Characteristics(
+ adaptability = 5,
+ affectionLevel = 5,
+ childFriendly = 4,
+ dogFriendly = 5,
+ energyLevel = 3,
+ grooming = 3,
+ healthIssues = 2,
+ intelligence = 5,
+ sheddingLevel = 3,
+ socialNeeds = 5,
+ strangerFriendly = 3,
+ vocalisation = 3,
+ experimental = 0,
+ hairless = 0,
+ natural = 0,
+ rare = 0,
+ rex = 0,
+ suppressedTail = 1,
+ shortLegs = 0
+ ),
+ wikipediaUrl = "https://en.wikipedia.org/wiki/American_Bobtail",
+ hypoallergenic = 0,
+ referenceImageUrl = "hBXicehMA",
+ isFavorite = true
+ )
+
+ val breedAPIModelList = listOf(breedAPIModel1, breedAPIModel2, breedAPIModel3)
+ val breedDBModelList = listOf(breedDBModel1, breedDBModel2, breedDBModel3)
+ val breedList = listOf(breed1, breed2, breed3)
+}
diff --git a/feature/breeds/breeds-data/src/test/java/com/aliumujib/songs/data/mapper/BreedMapperTest.kt b/feature/breeds/breeds-data/src/test/java/com/aliumujib/songs/data/mapper/BreedMapperTest.kt
new file mode 100644
index 0000000..06056bd
--- /dev/null
+++ b/feature/breeds/breeds-data/src/test/java/com/aliumujib/songs/data/mapper/BreedMapperTest.kt
@@ -0,0 +1,52 @@
+package com.aliumujib.songs.data.mapper
+
+import com.aliumujib.songs.data.TestDummyData
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class BreedMapperTest {
+
+ private val mapper: BreedMapper = BreedMapper()
+
+
+ @Test
+ fun `given DBModel when mapToUIModel is called, then it should return valid Breed`() {
+ val dbModel = TestDummyData.breedDBModel1
+ val isFavorite = true
+
+ val result = mapper.mapToUIModel(dbModel, isFavorite)
+
+ with(result) {
+ assertThat(id.data).isEqualTo(dbModel.id)
+ assertThat(name).isEqualTo(dbModel.name)
+ assertThat(weight.imperial).isEqualTo(dbModel.weightImperial)
+ assertThat(weight.metric).isEqualTo(dbModel.weightMetric)
+ assertThat(urls.cfaUrl).isEqualTo(dbModel.cfaUrl)
+ assertThat(urls.vetstreetUrl).isEqualTo(dbModel.vetstreetUrl)
+ assertThat(urls.vcahospitalsUrl).isEqualTo(dbModel.vcahospitalsUrl)
+ assertThat(attributes.temperament).isEqualTo(dbModel.temperament)
+ assertThat(characteristics.adaptability).isEqualTo(dbModel.adaptability)
+ assertThat(isFavorite).isTrue()
+ }
+ }
+
+ @Test
+ fun `given APIModel when mapToDBModel is called, then it should return valid BreedDBModel`() {
+ val apiModel = TestDummyData.breedAPIModel1
+
+ val result = mapper.mapToDBModel(apiModel)
+
+ with(result) {
+ assertThat(id).isEqualTo(apiModel.id)
+ assertThat(name).isEqualTo(apiModel.name)
+ assertThat(weightImperial).isEqualTo(apiModel.weight.imperial)
+ assertThat(weightMetric).isEqualTo(apiModel.weight.metric)
+ assertThat(cfaUrl).isEqualTo(apiModel.cfaUrl)
+ assertThat(vetstreetUrl).isEqualTo(apiModel.vetstreetUrl)
+ assertThat(vcahospitalsUrl).isEqualTo(apiModel.vcahospitalsUrl)
+ assertThat(temperament).isEqualTo(apiModel.temperament)
+ assertThat(apiModel.adaptability).isEqualTo(apiModel.adaptability)
+ assertThat(referenceImageUrl).isEqualTo(apiModel.referenceImage?.imageUrl)
+ }
+ }
+}
\ No newline at end of file
diff --git a/feature/breeds/breeds-data/src/test/java/com/aliumujib/songs/data/repo/CatBreedsRepositoryImplTest.kt b/feature/breeds/breeds-data/src/test/java/com/aliumujib/songs/data/repo/CatBreedsRepositoryImplTest.kt
new file mode 100644
index 0000000..4bad0b5
--- /dev/null
+++ b/feature/breeds/breeds-data/src/test/java/com/aliumujib/songs/data/repo/CatBreedsRepositoryImplTest.kt
@@ -0,0 +1,115 @@
+package com.aliumujib.songs.data.repo
+
+import app.cash.turbine.test
+import com.aliumujib.database.dao.BreedsDAO
+import com.aliumujib.database.dao.FavoritesDAO
+import com.aliumujib.database.model.FavoritesDBModel
+import com.aliumujib.database.model.FavoritesWithBreeds
+import com.aliumujib.model.BreedId
+import com.aliumujib.network.CatAPIService
+import com.aliumujib.songs.data.TestDummyData
+import com.aliumujib.songs.data.mapper.BreedMapper
+import com.aliumujib.songs.domain.repo.CatBreedsRepository
+import com.google.common.truth.Truth.assertThat
+import io.mockk.MockKAnnotations
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+
+class CatBreedsRepositoryImplTest {
+
+ @MockK(relaxed = true)
+ private lateinit var breedsDao: BreedsDAO
+
+ @MockK
+ private lateinit var favoritesDao: FavoritesDAO
+
+ @MockK
+ private lateinit var catsAPIService: CatAPIService
+
+ private val mapper: BreedMapper = BreedMapper()
+
+ private lateinit var repository: CatBreedsRepository
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+ repository = CatBreedsRepositoryImpl(breedsDao, favoritesDao, catsAPIService, mapper)
+ }
+
+ @Test
+ fun `given breeds are present in the database when streamBreedsList is called, then breeds are streamed`() = runTest {
+ val dbBreeds = TestDummyData.breedDBModelList
+ val uiBreeds = TestDummyData.breedList
+
+ every { breedsDao.streamBreedsList() } returns flowOf(dbBreeds)
+ coEvery { catsAPIService.getBreeds() } returns TestDummyData.breedAPIModelList
+ coEvery { breedsDao.saveBreeds(any()) } returns Unit
+ coEvery { favoritesDao.isFavorite(any()) } returns true
+
+ repository.streamBreedsList().test {
+ val emitted = awaitItem()
+ assertThat(emitted).isEqualTo(uiBreeds)
+ awaitComplete()
+ }
+
+ coVerify(exactly = 1) { catsAPIService.getBreeds() }
+ coVerify { breedsDao.saveBreeds(*anyVararg()) }
+ }
+
+ @Test
+ fun `given breed ID when addFavorite is called, then the favorite is added`() = runTest {
+ val breedId = BreedId("abys")
+
+ coEvery { favoritesDao.addFavorite(any()) } returns Unit
+
+ repository.addFavorite(breedId)
+
+ coVerify { favoritesDao.addFavorite(FavoritesDBModel(breedId.data)) }
+ }
+
+ @Test
+ fun `given breed ID when removeFavorite is called, then the favorite is removed`() = runTest {
+ val breedId = BreedId("aege")
+
+ coEvery { favoritesDao.removeFavorite(any()) } returns Unit
+
+ repository.removeFavorite(breedId)
+
+ coVerify { favoritesDao.removeFavorite(FavoritesDBModel(breedId.data)) }
+ }
+
+ @Test
+ fun `given breed ID when isFavorite is called, then return true if the breed is favorite`() =
+ runTest {
+ val breedId = BreedId("abob")
+
+ coEvery { favoritesDao.isFavorite(breedId.data) } returns true
+
+ val result = repository.isFavorite(breedId)
+
+ assertThat(result).isTrue()
+ }
+
+ @Test
+ fun `given favorite breeds when streamFavoritesList is called, then return list of favorite breeds`() =
+ runTest {
+ val favoriteBreeds = listOf(TestDummyData.breed1, TestDummyData.breed3)
+ val dbFavorites = listOf(
+ FavoritesWithBreeds(FavoritesDBModel("abys"), TestDummyData.breedDBModel1),
+ FavoritesWithBreeds(FavoritesDBModel("abob"), TestDummyData.breedDBModel3)
+ )
+
+ every { favoritesDao.streamAllFavoritesWithBreeds() } returns flowOf(dbFavorites)
+ repository.streamFavoritesList().test {
+ val emitted = awaitItem()
+ assertThat(emitted).isEqualTo(favoriteBreeds)
+ awaitComplete()
+ }
+ }
+}
\ No newline at end of file
diff --git a/feature/breeds/breeds-domain/.gitignore b/feature/breeds/breeds-domain/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/feature/breeds/breeds-domain/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/feature/breeds/breeds-domain/build.gradle.kts b/feature/breeds/breeds-domain/build.gradle.kts
new file mode 100644
index 0000000..a0d0626
--- /dev/null
+++ b/feature/breeds/breeds-domain/build.gradle.kts
@@ -0,0 +1,53 @@
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.android.kotlin)
+ alias(libs.plugins.kapt)
+ alias(libs.plugins.parcelize)
+ alias(libs.plugins.ksp)
+}
+
+apply {
+ from("$rootDir/base-module.gradle")
+}
+
+android {
+ compileSdk = AndroidConfig.compileSDK
+
+ defaultConfig {
+ minSdk = AndroidConfig.minSDK
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = AndroidConfig.javaVersion
+ targetCompatibility = AndroidConfig.javaVersion
+ }
+ kotlinOptions {
+ jvmTarget = AndroidConfig.jvmTarget
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+ namespace = "com.aliumujib.songs.domain"
+}
+
+dependencies {
+ implementation(projects.core.models)
+ implementation(projects.core.commonDomain)
+
+ testImplementation(libs.bundles.testing)
+ testImplementation(projects.core.commonTest)
+}
diff --git a/feature/breeds/breeds-domain/consumer-rules.pro b/feature/breeds/breeds-domain/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/feature/breeds/breeds-domain/proguard-rules.pro b/feature/breeds/breeds-domain/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/feature/breeds/breeds-domain/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/feature/breeds/breeds-domain/src/main/AndroidManifest.xml b/feature/breeds/breeds-domain/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..44008a4
--- /dev/null
+++ b/feature/breeds/breeds-domain/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/feature/breeds/breeds-domain/src/main/java/com/aliumujib/songs/domain/repo/CatBreedsRepository.kt b/feature/breeds/breeds-domain/src/main/java/com/aliumujib/songs/domain/repo/CatBreedsRepository.kt
new file mode 100644
index 0000000..5f78ce8
--- /dev/null
+++ b/feature/breeds/breeds-domain/src/main/java/com/aliumujib/songs/domain/repo/CatBreedsRepository.kt
@@ -0,0 +1,14 @@
+package com.aliumujib.songs.domain.repo
+
+import com.aliumujib.model.Breed
+import com.aliumujib.model.BreedId
+import kotlinx.coroutines.flow.Flow
+
+interface CatBreedsRepository {
+ fun streamBreedsList(): Flow>
+ suspend fun addFavorite(id: BreedId)
+ suspend fun removeFavorite(id: BreedId)
+ suspend fun isFavorite(id: BreedId): Boolean
+ fun streamFavoritesList(): Flow>
+ fun getBreedDetails(id: BreedId) : Flow
+}
\ No newline at end of file
diff --git a/feature/breeds/breeds-domain/src/main/java/com/aliumujib/songs/domain/usecases/GetCatBreedDetailsUseCase.kt b/feature/breeds/breeds-domain/src/main/java/com/aliumujib/songs/domain/usecases/GetCatBreedDetailsUseCase.kt
new file mode 100644
index 0000000..33be3c3
--- /dev/null
+++ b/feature/breeds/breeds-domain/src/main/java/com/aliumujib/songs/domain/usecases/GetCatBreedDetailsUseCase.kt
@@ -0,0 +1,22 @@
+package com.aliumujib.songs.domain.usecases
+
+import com.aliumujib.common.domain.usecases.FlowUseCase
+import com.aliumujib.common.domain.usecases.NoParamsException
+import com.aliumujib.common.domain.utils.DispatcherProvider
+import com.aliumujib.model.Breed
+import com.aliumujib.model.BreedId
+import com.aliumujib.songs.domain.repo.CatBreedsRepository
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+
+class GetCatBreedDetailsUseCase @Inject constructor(
+ private val catBreedsRepository: CatBreedsRepository,
+ dispatcherProvider: DispatcherProvider,
+) : FlowUseCase(dispatcherProvider) {
+
+ override fun build(params: BreedId?): Flow {
+ params ?: throw NoParamsException()
+ return catBreedsRepository.getBreedDetails(params)
+ }
+
+}
diff --git a/feature/breeds/breeds-domain/src/main/java/com/aliumujib/songs/domain/usecases/StreamBreedsListUseCase.kt b/feature/breeds/breeds-domain/src/main/java/com/aliumujib/songs/domain/usecases/StreamBreedsListUseCase.kt
new file mode 100644
index 0000000..d251cd3
--- /dev/null
+++ b/feature/breeds/breeds-domain/src/main/java/com/aliumujib/songs/domain/usecases/StreamBreedsListUseCase.kt
@@ -0,0 +1,19 @@
+package com.aliumujib.songs.domain.usecases
+
+import com.aliumujib.common.domain.usecases.FlowUseCase
+import com.aliumujib.common.domain.utils.DispatcherProvider
+import com.aliumujib.model.Breed
+import com.aliumujib.songs.domain.repo.CatBreedsRepository
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+
+class StreamBreedsListUseCase @Inject constructor(
+ private val catBreedsRepository: CatBreedsRepository,
+ dispatcherProvider: DispatcherProvider,
+) : FlowUseCase>(dispatcherProvider) {
+
+ override fun build(params: Unit?): Flow> {
+ return catBreedsRepository.streamBreedsList()
+ }
+
+}
diff --git a/feature/breeds/breeds-domain/src/main/java/com/aliumujib/songs/domain/usecases/StreamFavoritesListUseCase.kt b/feature/breeds/breeds-domain/src/main/java/com/aliumujib/songs/domain/usecases/StreamFavoritesListUseCase.kt
new file mode 100644
index 0000000..ea60ae1
--- /dev/null
+++ b/feature/breeds/breeds-domain/src/main/java/com/aliumujib/songs/domain/usecases/StreamFavoritesListUseCase.kt
@@ -0,0 +1,20 @@
+package com.aliumujib.songs.domain.usecases
+
+import com.aliumujib.common.domain.usecases.FlowUseCase
+import com.aliumujib.common.domain.usecases.SuspendUseCase
+import com.aliumujib.common.domain.utils.DispatcherProvider
+import com.aliumujib.model.Breed
+import com.aliumujib.songs.domain.repo.CatBreedsRepository
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+
+class StreamFavoritesListUseCase @Inject constructor(
+ private val catBreedsRepository: CatBreedsRepository,
+ dispatcherProvider: DispatcherProvider,
+) : FlowUseCase>(dispatcherProvider) {
+
+ override fun build(params: Unit?): Flow> {
+ return catBreedsRepository.streamFavoritesList()
+ }
+
+}
diff --git a/feature/breeds/breeds-domain/src/main/java/com/aliumujib/songs/domain/usecases/ToggleFavoriteUseCase.kt b/feature/breeds/breeds-domain/src/main/java/com/aliumujib/songs/domain/usecases/ToggleFavoriteUseCase.kt
new file mode 100644
index 0000000..5a5f631
--- /dev/null
+++ b/feature/breeds/breeds-domain/src/main/java/com/aliumujib/songs/domain/usecases/ToggleFavoriteUseCase.kt
@@ -0,0 +1,26 @@
+package com.aliumujib.songs.domain.usecases
+
+import com.aliumujib.common.domain.usecases.NoParamsException
+import com.aliumujib.common.domain.usecases.SuspendUseCase
+import com.aliumujib.common.domain.utils.DispatcherProvider
+import com.aliumujib.model.Breed
+import com.aliumujib.model.BreedId
+import com.aliumujib.songs.domain.repo.CatBreedsRepository
+import javax.inject.Inject
+
+class ToggleFavoriteUseCase @Inject constructor(
+ private val catBreedsRepository: CatBreedsRepository,
+ dispatcherProvider: DispatcherProvider,
+) : SuspendUseCase(dispatcherProvider) {
+
+ override suspend fun execute(params: BreedId?) : BreedId {
+ params ?: throw NoParamsException()
+ if (catBreedsRepository.isFavorite(params)) {
+ catBreedsRepository.removeFavorite(params)
+ } else {
+ catBreedsRepository.addFavorite(params)
+ }
+ return params
+ }
+
+}
diff --git a/feature/breeds/breeds-domain/src/test/java/com/aliumujib/songs/domain/usecases/GetCatBreedDetailsUseCaseTest.kt b/feature/breeds/breeds-domain/src/test/java/com/aliumujib/songs/domain/usecases/GetCatBreedDetailsUseCaseTest.kt
new file mode 100644
index 0000000..b81b008
--- /dev/null
+++ b/feature/breeds/breeds-domain/src/test/java/com/aliumujib/songs/domain/usecases/GetCatBreedDetailsUseCaseTest.kt
@@ -0,0 +1,71 @@
+package com.aliumujib.songs.domain.usecases
+
+import app.cash.turbine.test
+import com.aliumujib.common.domain.usecases.NoParamsException
+import com.aliumujib.common.domain.utils.DispatcherProvider
+import com.aliumujib.common.test.SharedDummyData
+import com.aliumujib.common.test.TestDispatcherProviderImpl
+import com.aliumujib.songs.domain.repo.CatBreedsRepository
+import com.google.common.truth.Truth.assertThat
+import io.mockk.MockKAnnotations
+import io.mockk.coEvery
+import io.mockk.impl.annotations.MockK
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+
+class GetCatBreedDetailsUseCaseTest {
+
+ @MockK
+ private lateinit var catBreedsRepository: CatBreedsRepository
+
+ private val dispatcherProvider: DispatcherProvider = TestDispatcherProviderImpl()
+
+ private lateinit var getCatBreedDetailsUseCase: GetCatBreedDetailsUseCase
+
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+ getCatBreedDetailsUseCase = GetCatBreedDetailsUseCase(catBreedsRepository, dispatcherProvider)
+ }
+
+ @Test
+ fun `given valid BreedId when GetCatBreedDetailsUseCase is invoked, then it should emit the corresponding Breed`() = runTest {
+ val breedId = SharedDummyData.breed1.id
+ val expectedBreed = SharedDummyData.breed1
+
+ coEvery { catBreedsRepository.getBreedDetails(breedId) } returns flowOf(expectedBreed)
+
+ getCatBreedDetailsUseCase(breedId).test {
+ assertThat(awaitItem()).isEqualTo(expectedBreed)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `given repository failure when StreamBreedsListUseCase is invoked, then it should emit an error`() = runTest {
+ val exception = Exception("Failed to stream breed data due to network issue")
+ val breedId = SharedDummyData.breed1.id
+
+ coEvery { catBreedsRepository.getBreedDetails(breedId) } returns flow { throw exception }
+
+ getCatBreedDetailsUseCase(breedId).test {
+ val error = awaitError()
+ assertThat(error).isInstanceOf(Exception::class.java)
+ }
+ }
+
+ @Test
+ fun `given null BreedId when GetCatBreedDetailsUseCase is invoked, then it should throw NoParamsException`() = runTest {
+ try {
+ getCatBreedDetailsUseCase(null).test { }
+ assert(false) { "Expected NoParamsException to be thrown" }
+ } catch (e: NoParamsException) {
+ assert(true)
+ }
+ }
+
+}
diff --git a/feature/breeds/breeds-domain/src/test/java/com/aliumujib/songs/domain/usecases/StreamBreedsListUseCaseTest.kt b/feature/breeds/breeds-domain/src/test/java/com/aliumujib/songs/domain/usecases/StreamBreedsListUseCaseTest.kt
new file mode 100644
index 0000000..c12049b
--- /dev/null
+++ b/feature/breeds/breeds-domain/src/test/java/com/aliumujib/songs/domain/usecases/StreamBreedsListUseCaseTest.kt
@@ -0,0 +1,55 @@
+package com.aliumujib.songs.domain.usecases
+
+import app.cash.turbine.test
+import com.aliumujib.common.domain.utils.DispatcherProvider
+import com.aliumujib.common.test.SharedDummyData
+import com.aliumujib.common.test.TestDispatcherProviderImpl
+import com.aliumujib.songs.domain.repo.CatBreedsRepository
+import com.google.common.truth.Truth.assertThat
+import io.mockk.MockKAnnotations
+import io.mockk.coEvery
+import io.mockk.impl.annotations.MockK
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+
+class StreamBreedsListUseCaseTest {
+
+ @MockK
+ private lateinit var catBreedsRepository: CatBreedsRepository
+
+ private val dispatcherProvider: DispatcherProvider = TestDispatcherProviderImpl()
+
+ private lateinit var streamBreedsListUseCase: StreamBreedsListUseCase
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+ streamBreedsListUseCase = StreamBreedsListUseCase(catBreedsRepository, dispatcherProvider)
+ }
+
+ @Test
+ fun `when streamBreedsListUseCase is invoked and repository contains breed data, then it should emit correct breed list`() = runTest {
+ val breedList = SharedDummyData.breedList
+ coEvery { catBreedsRepository.streamBreedsList() } returns flowOf(breedList)
+
+ streamBreedsListUseCase(Unit).test {
+ assertThat(awaitItem()).isEqualTo(breedList)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `when streamBreedsListUseCase is invoked and repository fails to fetch breed data, then it should emit an error`() = runTest {
+ val exception = IllegalStateException("Failed to fetch breed data")
+ coEvery { catBreedsRepository.streamBreedsList() } returns flow { throw exception }
+
+ streamBreedsListUseCase(Unit).test {
+ val error = awaitError()
+ assertThat(error).isInstanceOf(IllegalStateException::class.java)
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/feature/breeds/breeds-domain/src/test/java/com/aliumujib/songs/domain/usecases/StreamFavoritesListUseCaseTest.kt b/feature/breeds/breeds-domain/src/test/java/com/aliumujib/songs/domain/usecases/StreamFavoritesListUseCaseTest.kt
new file mode 100644
index 0000000..91a2fa9
--- /dev/null
+++ b/feature/breeds/breeds-domain/src/test/java/com/aliumujib/songs/domain/usecases/StreamFavoritesListUseCaseTest.kt
@@ -0,0 +1,57 @@
+package com.aliumujib.songs.domain.usecases
+
+import app.cash.turbine.test
+import com.aliumujib.common.domain.utils.DispatcherProvider
+import com.aliumujib.common.test.SharedDummyData
+import com.aliumujib.common.test.TestDispatcherProviderImpl
+import com.aliumujib.songs.domain.repo.CatBreedsRepository
+import io.mockk.coEvery
+import io.mockk.impl.annotations.MockK
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import com.google.common.truth.Truth.assertThat
+import io.mockk.MockKAnnotations
+import kotlinx.coroutines.flow.flow
+
+class StreamFavoritesListUseCaseTest {
+
+ @MockK
+ private lateinit var catBreedsRepository: CatBreedsRepository
+
+ private val dispatcherProvider: DispatcherProvider = TestDispatcherProviderImpl()
+
+ lateinit var streamFavoritesListUseCase: StreamFavoritesListUseCase
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+ streamFavoritesListUseCase = StreamFavoritesListUseCase(catBreedsRepository, dispatcherProvider)
+ }
+
+ @ExperimentalCoroutinesApi
+ @Test
+ fun `given repository returns favorite breeds when StreamFavoritesListUseCase is invoked, then it should emit the favorite breeds list`() = runTest {
+ coEvery { catBreedsRepository.streamFavoritesList() } returns flowOf(SharedDummyData.breedList)
+
+ streamFavoritesListUseCase(Unit).test {
+ assertThat(awaitItem()).isEqualTo(SharedDummyData.breedList)
+ awaitComplete()
+ }
+ }
+
+ @ExperimentalCoroutinesApi
+ @Test
+ fun `given repository fails to fetch data when StreamFavoritesListUseCase is invoked, then it should emit an error`() = runTest {
+ val exception = Exception("Error fetching favorite breeds")
+ coEvery { catBreedsRepository.streamFavoritesList() } returns flow { throw exception }
+
+ streamFavoritesListUseCase(Unit).test {
+ val error = awaitError()
+ assertThat(error).isInstanceOf(Exception::class.java)
+ assertThat(error.message).isEqualTo("Error fetching favorite breeds")
+ }
+ }
+}
diff --git a/feature/breeds/breeds-domain/src/test/java/com/aliumujib/songs/domain/usecases/ToggleFavoriteUseCaseTest.kt b/feature/breeds/breeds-domain/src/test/java/com/aliumujib/songs/domain/usecases/ToggleFavoriteUseCaseTest.kt
new file mode 100644
index 0000000..97c3c6d
--- /dev/null
+++ b/feature/breeds/breeds-domain/src/test/java/com/aliumujib/songs/domain/usecases/ToggleFavoriteUseCaseTest.kt
@@ -0,0 +1,61 @@
+package com.aliumujib.songs.domain.usecases
+
+import com.aliumujib.common.domain.usecases.NoParamsException
+import com.aliumujib.common.domain.utils.DispatcherProvider
+import com.aliumujib.common.test.TestDispatcherProviderImpl
+import com.aliumujib.model.BreedId
+import com.aliumujib.songs.domain.repo.CatBreedsRepository
+import com.google.common.truth.Truth.assertThat
+import io.mockk.MockKAnnotations
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.impl.annotations.MockK
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+
+class ToggleFavoriteUseCaseTest {
+
+ @MockK
+ private lateinit var catBreedsRepository: CatBreedsRepository
+
+ private val dispatcherProvider: DispatcherProvider = TestDispatcherProviderImpl()
+
+ private lateinit var toggleFavoriteUseCase: ToggleFavoriteUseCase
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+ toggleFavoriteUseCase = ToggleFavoriteUseCase(catBreedsRepository, dispatcherProvider)
+ }
+
+ @Test
+ fun `given params is null when execute is called, then NoParamsException should be thrown`() = runTest {
+ val result = toggleFavoriteUseCase(null)
+ assertThat(result.isFailure).isTrue()
+ assertThat(result.exceptionOrNull()).isInstanceOf(NoParamsException::class.java)
+ }
+
+ @Test
+ fun `given params in state not favorite when ToggleFavoriteUseCase is invoked, then addFavorite should be called`() = runTest {
+ val params = BreedId("breedId")
+ coEvery { catBreedsRepository.isFavorite(params) } returns false
+ coEvery { catBreedsRepository.addFavorite(params) } returns Unit
+
+ toggleFavoriteUseCase(params) // Use the invoke function
+
+ coVerify { catBreedsRepository.addFavorite(params) }
+ }
+
+ @Test
+ fun `given params in state favorite when ToggleFavoriteUseCase is invoked, then removeFavorite should be called`() = runTest {
+ val params = BreedId("breedId")
+ coEvery { catBreedsRepository.isFavorite(params) } returns true
+ coEvery { catBreedsRepository.removeFavorite(params) } returns Unit
+
+ toggleFavoriteUseCase(params) // Use the invoke function
+
+ coVerify { catBreedsRepository.removeFavorite(params) }
+ }
+
+}
\ No newline at end of file
diff --git a/feature/breeds/common/.gitignore b/feature/breeds/common/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/feature/breeds/common/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/feature/breeds/common/build.gradle.kts b/feature/breeds/common/build.gradle.kts
new file mode 100644
index 0000000..48e14d2
--- /dev/null
+++ b/feature/breeds/common/build.gradle.kts
@@ -0,0 +1,81 @@
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.android.kotlin)
+ alias(libs.plugins.kapt)
+ alias(libs.plugins.parcelize)
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.hilt.android)
+}
+
+apply {
+ from("$rootDir/base-module.gradle")
+}
+
+android {
+ compileSdk = AndroidConfig.compileSDK
+
+ defaultConfig {
+ minSdk = AndroidConfig.minSDK
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = AndroidConfig.javaVersion
+ targetCompatibility = AndroidConfig.javaVersion
+ }
+ kotlinOptions {
+ jvmTarget = AndroidConfig.jvmTarget
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+ namespace = "com.aliumujib.songs.commons"
+}
+
+ksp {
+ arg("compose-destinations.moduleName", "commons")
+}
+
+kotlin {
+ sourceSets {
+ debug {
+ kotlin.srcDir("build/generated/ksp/debug/kotlin")
+ }
+ release {
+ kotlin.srcDir("build/generated/ksp/release/kotlin")
+ }
+ }
+}
+
+dependencies {
+ implementation(projects.core.designsystem)
+ implementation(projects.core.common)
+ implementation(projects.core.analytics)
+ implementation(projects.core.models)
+ implementation(projects.feature.breeds.breedsDomain)
+
+ implementation(libs.accompanist.permissions)
+ implementation(libs.androidx.media3.session)
+ implementation(libs.androidx.media3.exoplayer)
+
+ androidTestImplementation(libs.compose.ui.test.junit4)
+ androidTestImplementation(libs.bundles.testing)
+ androidTestImplementation(projects.core.commonTest)
+ androidTestImplementation(libs.mockk.android)
+
+ testImplementation(libs.bundles.testing)
+ testImplementation(projects.core.commonTest)
+}
diff --git a/feature/breeds/common/consumer-rules.pro b/feature/breeds/common/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/feature/breeds/common/proguard-rules.pro b/feature/breeds/common/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/feature/breeds/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/feature/breeds/common/src/androidTest/java/com/aliumujib/breed/common/ui/BreedDetailsSummaryBottomSheetTest.kt b/feature/breeds/common/src/androidTest/java/com/aliumujib/breed/common/ui/BreedDetailsSummaryBottomSheetTest.kt
new file mode 100644
index 0000000..fd9e128
--- /dev/null
+++ b/feature/breeds/common/src/androidTest/java/com/aliumujib/breed/common/ui/BreedDetailsSummaryBottomSheetTest.kt
@@ -0,0 +1,51 @@
+package com.aliumujib.breed.common.ui
+
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.SheetState
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.aliumujib.common.test.SharedDummyData
+import com.aliumujib.model.Breed
+import io.mockk.coVerify
+import io.mockk.mockk
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class)
+class BreedDetailsSummaryBottomSheetTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private val onDismissRequest = mockk<() -> Unit>(relaxed = true)
+ private val onFavoriteClick = mockk<(Breed) -> Unit>(relaxed = true)
+
+ @Test
+ fun breedDetailsSummaryBottomSheet_displaysDetailsAndHandlesInteractions() {
+ val sheetState = mockk(relaxed = true)
+ val breed = SharedDummyData.breed1
+
+ composeTestRule.setContent {
+ BreedDetailsSummaryBottomSheet(
+ breed = breed,
+ sheetState = sheetState,
+ onDismissRequest = onDismissRequest,
+ onFavoriteClick = onFavoriteClick
+ )
+ }
+
+ composeTestRule.onNodeWithText(breed.name).assertIsDisplayed()
+ composeTestRule.onNodeWithText(breed.attributes.description).assertIsDisplayed()
+ composeTestRule.onNodeWithText("Active, Energetic").assertIsDisplayed()
+
+ composeTestRule.onNodeWithContentDescription("Heart").performClick()
+ coVerify { onFavoriteClick(breed) }
+ }
+
+}
\ No newline at end of file
diff --git a/feature/breeds/common/src/androidTest/java/com/aliumujib/breed/common/ui/BreedListItemTest.kt b/feature/breeds/common/src/androidTest/java/com/aliumujib/breed/common/ui/BreedListItemTest.kt
new file mode 100644
index 0000000..f4de625
--- /dev/null
+++ b/feature/breeds/common/src/androidTest/java/com/aliumujib/breed/common/ui/BreedListItemTest.kt
@@ -0,0 +1,70 @@
+package com.aliumujib.breed.common.ui
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.aliumujib.common.test.SharedDummyData
+import com.aliumujib.model.Breed
+import io.mockk.coVerify
+import io.mockk.mockk
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class BreedListItemTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private val onItemClick = mockk<(Breed) -> Unit>(relaxed = true)
+ private val onMoreClick = mockk<(Breed) -> Unit>(relaxed = true)
+
+ @Test
+ fun breedListItem_displaysCorrectData() {
+ val breed = SharedDummyData.breed1
+ composeTestRule.setContent {
+ BreedListItem(
+ breed = breed,
+ onItemClick = {},
+ onMoreClick = {}
+ )
+ }
+
+ composeTestRule.onNodeWithText(breed.name).assertIsDisplayed()
+ composeTestRule.onNodeWithText(breed.attributes.description).assertIsDisplayed()
+ }
+
+ @Test
+ fun breedListItem_whenClicked_invokesOnItemClick() {
+ val breed = SharedDummyData.breed1
+ composeTestRule.setContent {
+ BreedListItem(
+ breed = breed,
+ onItemClick = onItemClick,
+ onMoreClick = onMoreClick
+ )
+ }
+
+ composeTestRule.onNodeWithText("Abyssinian").performClick()
+ coVerify { onItemClick(breed) }
+ }
+
+ @Test
+ fun breedListItem_whenMoreClicked_invokesOnMoreClick() {
+ val breed = SharedDummyData.breed1
+ composeTestRule.setContent {
+ BreedListItem(
+ breed = breed,
+ onItemClick = onItemClick,
+ onMoreClick = onMoreClick
+ )
+ }
+
+ composeTestRule.onNodeWithContentDescription("More").performClick()
+ coVerify { onMoreClick(breed) }
+ }
+}
\ No newline at end of file
diff --git a/feature/breeds/common/src/main/AndroidManifest.xml b/feature/breeds/common/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..69fc412
--- /dev/null
+++ b/feature/breeds/common/src/main/AndroidManifest.xml
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/feature/breeds/common/src/main/java/com/aliumujib/breed/common/presentation/IntentProcessor.kt b/feature/breeds/common/src/main/java/com/aliumujib/breed/common/presentation/IntentProcessor.kt
new file mode 100644
index 0000000..5e34bd8
--- /dev/null
+++ b/feature/breeds/common/src/main/java/com/aliumujib/breed/common/presentation/IntentProcessor.kt
@@ -0,0 +1,14 @@
+package com.aliumujib.breed.common.presentation
+
+import kotlinx.coroutines.flow.Flow
+
+interface IntentProcessor {
+ fun intentToResult(viewIntent: Intent): Flow
+}
+
+class InvalidViewIntentException(
+ private val intent: Any
+) : IllegalArgumentException() {
+ override val message: String
+ get() = "Invalid intent $intent"
+}
\ No newline at end of file
diff --git a/feature/breeds/common/src/main/java/com/aliumujib/breed/common/presentation/MVI.kt b/feature/breeds/common/src/main/java/com/aliumujib/breed/common/presentation/MVI.kt
new file mode 100644
index 0000000..082d530
--- /dev/null
+++ b/feature/breeds/common/src/main/java/com/aliumujib/breed/common/presentation/MVI.kt
@@ -0,0 +1,16 @@
+package com.aliumujib.breed.common.presentation
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.StateFlow
+
+interface MVI {
+ val states: StateFlow
+ val intents: Flow
+ val sideEffect: Flow
+
+ fun onAction(action: Intent)
+
+ fun emitSideEffect(effect: SideEffect)
+
+ fun processActions()
+}
\ No newline at end of file
diff --git a/feature/breeds/common/src/main/java/com/aliumujib/breed/common/presentation/MVIDelegate.kt b/feature/breeds/common/src/main/java/com/aliumujib/breed/common/presentation/MVIDelegate.kt
new file mode 100644
index 0000000..1cb5858
--- /dev/null
+++ b/feature/breeds/common/src/main/java/com/aliumujib/breed/common/presentation/MVIDelegate.kt
@@ -0,0 +1,68 @@
+package com.aliumujib.breed.common.presentation
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapMerge
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.scan
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MVIDelegate internal constructor(
+ private val initialUiState: State,
+ private val intentProcessor: IntentProcessor,
+ private val stateReducer: StateReducer,
+) : ViewModel(), MVI {
+
+ private val _states = MutableStateFlow(initialUiState)
+ override val states = _states.asStateFlow()
+
+ private val _intents = MutableSharedFlow(1)
+ override val intents = _intents.asSharedFlow()
+
+ private val _sideEffect by lazy { Channel() }
+ override val sideEffect: Flow by lazy { _sideEffect.receiveAsFlow() }
+
+ override fun processActions() {
+ _intents.flatMapMerge {
+ intentProcessor.intentToResult(it)
+ }
+ .scan(initial = initialUiState) { old: State, result: Result ->
+ stateReducer.reduce(old, result)
+ }
+ .distinctUntilChanged()
+ .onEach {
+ _states.emit(it)
+ }.launchIn(viewModelScope)
+ }
+
+ override fun onAction(action: Intent) {
+ viewModelScope.launch {
+ _intents.emit(action)
+ }
+ }
+
+ override fun emitSideEffect(effect: SideEffect) {
+ viewModelScope.launch { _sideEffect.send(effect) }
+ }
+}
+
+fun mvi(
+ initialUiState: State,
+ intentProcessor: IntentProcessor,
+ stateReducer: StateReducer,
+): MVI =
+ MVIDelegate(
+ initialUiState, intentProcessor, stateReducer
+ )
\ No newline at end of file
diff --git a/feature/breeds/common/src/main/java/com/aliumujib/breed/common/presentation/SideEffectHelpers.kt b/feature/breeds/common/src/main/java/com/aliumujib/breed/common/presentation/SideEffectHelpers.kt
new file mode 100644
index 0000000..9452b72
--- /dev/null
+++ b/feature/breeds/common/src/main/java/com/aliumujib/breed/common/presentation/SideEffectHelpers.kt
@@ -0,0 +1,35 @@
+package com.aliumujib.breed.common.presentation
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.repeatOnLifecycle
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.withContext
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+
+@Composable
+fun CollectSideEffect(
+ sideEffect: Flow,
+ lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
+ minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
+ context: CoroutineContext = Dispatchers.Main.immediate,
+ onSideEffect: suspend CoroutineScope.(effect: SideEffect) -> Unit,
+) {
+ LaunchedEffect(sideEffect, lifecycleOwner) {
+ lifecycleOwner.lifecycle.repeatOnLifecycle(minActiveState) {
+ if (context == EmptyCoroutineContext) {
+ sideEffect.collect { onSideEffect(it) }
+ } else {
+ withContext(context) {
+ sideEffect.collect { onSideEffect(it) }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/feature/breeds/common/src/main/java/com/aliumujib/breed/common/presentation/StateReducer.kt b/feature/breeds/common/src/main/java/com/aliumujib/breed/common/presentation/StateReducer.kt
new file mode 100644
index 0000000..a65b75b
--- /dev/null
+++ b/feature/breeds/common/src/main/java/com/aliumujib/breed/common/presentation/StateReducer.kt
@@ -0,0 +1,5 @@
+package com.aliumujib.breed.common.presentation
+
+public interface StateReducer {
+ public fun reduce(oldState: State, result: Result): State
+}
diff --git a/feature/breeds/common/src/main/java/com/aliumujib/breed/common/ui/BreedDetailsSummaryBottomSheet.kt b/feature/breeds/common/src/main/java/com/aliumujib/breed/common/ui/BreedDetailsSummaryBottomSheet.kt
new file mode 100644
index 0000000..d9036c6
--- /dev/null
+++ b/feature/breeds/common/src/main/java/com/aliumujib/breed/common/ui/BreedDetailsSummaryBottomSheet.kt
@@ -0,0 +1,166 @@
+package com.aliumujib.breed.common.ui
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.SheetState
+import androidx.compose.material3.Text
+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.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import coil.request.ImageRequest
+import com.aliumujib.model.Breed
+import com.aliumujib.songs.commons.R
+import io.eyram.iconsax.IconSax
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+fun BreedDetailsSummaryBottomSheet(
+ breed: Breed,
+ sheetState: SheetState,
+ onDismissRequest: () -> Unit,
+ onFavoriteClick: (Breed) -> Unit,
+) {
+ ModalBottomSheet(
+ modifier = Modifier.wrapContentHeight(),
+ sheetState = sheetState,
+ onDismissRequest = onDismissRequest
+ ) {
+ BreedDetailsSummaryBottomSheetContent(
+ breed = breed,
+ onFavoriteClick = onFavoriteClick
+ )
+ }
+}
+
+@Composable
+private fun BreedDetailsSummaryBottomSheetContent(
+ breed: Breed,
+ onFavoriteClick: (Breed) -> Unit,
+) {
+ val context = LocalContext.current
+ val description = breed.attributes.description
+ val weight = breed.weight.metric
+ val temperament = breed.attributes.temperament
+
+ Column(
+ modifier = Modifier
+ .navigationBarsPadding()
+ .fillMaxWidth(), horizontalAlignment = Alignment.Start
+ ) {
+ Card(
+ modifier = Modifier
+ .padding(horizontal = 15.dp)
+ .fillMaxWidth(),
+ shape = RoundedCornerShape(
+ topStart = 24.dp,
+ topEnd = 24.dp,
+ bottomStart = 4.dp,
+ bottomEnd = 4.dp
+ ),
+ colors = CardDefaults.cardColors(MaterialTheme.colorScheme.tertiaryContainer),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ AsyncImage(
+ model = ImageRequest.Builder(context)
+ .data(breed.referenceImageUrl)
+ .placeholder(R.drawable.cat_default)
+ .error(R.drawable.cat_default)
+ .crossfade(true)
+ .build(),
+ contentDescription = null,
+ modifier = Modifier
+ .size(80.dp)
+ .padding(15.dp)
+ .clip(RoundedCornerShape(15)),
+ contentScale = ContentScale.Crop
+ )
+
+ Column(Modifier.weight(1f)) {
+ Text(
+ text = breed.name,
+ style = MaterialTheme.typography.titleLarge,
+ maxLines = 2
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Text(
+ text = breed.attributes.origin,
+ style = MaterialTheme.typography.titleSmall
+ )
+ }
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ IconButton(
+ modifier = Modifier,
+ onClick = {
+ onFavoriteClick(breed)
+ }) {
+ Icon(
+ painter = painterResource(
+ id = if (breed.isFavorite) {
+ IconSax.Bold.Heart
+ } else {
+ IconSax.Outline.Heart
+ }
+ ),
+ contentDescription = null
+ )
+ }
+ }
+ }
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 2.dp),
+ shape = RoundedCornerShape(
+ topStart = 4.dp,
+ topEnd = 4.dp,
+ bottomStart = 24.dp,
+ bottomEnd = 24.dp
+ ),
+ colors = CardDefaults.cardColors(MaterialTheme.colorScheme.tertiaryContainer),
+ ) {
+ Column(modifier = Modifier.padding(15.dp)) {
+ Text(
+ text = "${stringResource(id = R.string.description)}: $description",
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(bottom = 5.dp)
+ )
+ Text(
+ text = "${stringResource(id = R.string.weight)}: $weight",
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(bottom = 5.dp)
+ )
+ Text(
+ text = "${stringResource(id = R.string.temperament)}: $temperament",
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(bottom = 5.dp)
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/feature/breeds/common/src/main/java/com/aliumujib/breed/common/ui/BreedListItem.kt b/feature/breeds/common/src/main/java/com/aliumujib/breed/common/ui/BreedListItem.kt
new file mode 100644
index 0000000..1535223
--- /dev/null
+++ b/feature/breeds/common/src/main/java/com/aliumujib/breed/common/ui/BreedListItem.kt
@@ -0,0 +1,114 @@
+package com.aliumujib.breed.common.ui
+
+import android.annotation.SuppressLint
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Card
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+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.draw.clip
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import coil.imageLoader
+import coil.request.ImageRequest
+import com.aliumujib.model.Breed
+import com.aliumujib.songs.commons.R
+import io.eyram.iconsax.IconSax
+
+@OptIn(ExperimentalFoundationApi::class)
+@SuppressLint("UnsafeOptInUsageError")
+@Composable
+fun BreedListItem(
+ modifier: Modifier = Modifier,
+ breed: Breed,
+ onItemClick: (Breed) -> Unit,
+ onMoreClick: (Breed) -> Unit,
+) {
+
+ Card(
+ modifier = Modifier
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ .combinedClickable(
+ onClick = { onItemClick(breed) },
+ )
+ ) {
+
+ Column(modifier = Modifier) {
+
+ val imageLoader = LocalContext.current.imageLoader
+
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current)
+ .data(breed.referenceImageUrl)
+ .placeholder(R.drawable.cat_default)
+ .error(R.drawable.cat_default)
+ .crossfade(true)
+ .build(),
+ contentDescription = "Artwork",
+ imageLoader = imageLoader,
+ modifier = Modifier
+ .height(150.dp)
+ .clip(RoundedCornerShape(topEnd = 5.dp, topStart = 5.dp)),
+ contentScale = ContentScale.Crop,
+ )
+
+ Row(
+ modifier = modifier
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ .weight(1f)
+ ) {
+ Text(
+ text = breed.name,
+ style = MaterialTheme.typography.bodyLarge,
+ softWrap = false,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1
+ )
+ Text(
+ text = breed.attributes.description,
+ style = MaterialTheme.typography.bodySmall,
+ softWrap = false,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 3
+ )
+ }
+
+ IconButton(
+ modifier = Modifier.graphicsLayer(rotationZ = 90f),
+ onClick = {
+ onMoreClick(breed)
+ }) {
+ Icon(
+ painter = painterResource(
+ id = IconSax.Linear.More
+ ),
+ contentDescription = null
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/feature/breeds/common/src/main/res/drawable/cat_default.webp b/feature/breeds/common/src/main/res/drawable/cat_default.webp
new file mode 100644
index 0000000..29adc0a
Binary files /dev/null and b/feature/breeds/common/src/main/res/drawable/cat_default.webp differ
diff --git a/feature/breeds/common/src/main/res/values/strings.xml b/feature/breeds/common/src/main/res/values/strings.xml
new file mode 100644
index 0000000..cbb27ad
--- /dev/null
+++ b/feature/breeds/common/src/main/res/values/strings.xml
@@ -0,0 +1,14 @@
+
+
+ Weight
+ Unknown Song
+ Unknown Song
+ Search %1$s
+
+ Temperament: %1$s
+ Origin: %1$s
+ Description
+ Life Span: %1$s
+ Lap: %1$d
+ Alternate Names: %1$s
+
\ No newline at end of file
diff --git a/feature/breeds/favorite-breeds/.gitignore b/feature/breeds/favorite-breeds/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/feature/breeds/favorite-breeds/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/feature/breeds/favorite-breeds/build.gradle.kts b/feature/breeds/favorite-breeds/build.gradle.kts
new file mode 100644
index 0000000..b1410ec
--- /dev/null
+++ b/feature/breeds/favorite-breeds/build.gradle.kts
@@ -0,0 +1,79 @@
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.android.kotlin)
+ alias(libs.plugins.kapt)
+ alias(libs.plugins.parcelize)
+ alias(libs.plugins.ksp)
+}
+
+apply {
+ from("$rootDir/base-module.gradle")
+}
+
+android {
+ compileSdk = AndroidConfig.compileSDK
+
+ defaultConfig {
+ minSdk = AndroidConfig.minSDK
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = AndroidConfig.javaVersion
+ targetCompatibility = AndroidConfig.javaVersion
+ }
+ kotlinOptions {
+ jvmTarget = AndroidConfig.jvmTarget
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+ namespace = "com.aliumujib.favorite.songs"
+}
+
+ksp {
+ arg("compose-destinations.mode", "destinations")
+ arg("compose-destinations.moduleName", "favorite.breeds")
+}
+
+kotlin {
+ sourceSets {
+ debug {
+ kotlin.srcDir("build/generated/ksp/debug/kotlin")
+ }
+ release {
+ kotlin.srcDir("build/generated/ksp/release/kotlin")
+ }
+ }
+}
+
+dependencies {
+ implementation(projects.core.designsystem)
+ implementation(projects.core.common)
+ implementation(projects.core.analytics)
+ implementation(projects.core.preferences)
+ implementation(projects.core.models)
+ implementation(projects.feature.breeds.common)
+ implementation(projects.feature.breeds.breedsDomain)
+ implementation(projects.core.commonDomain)
+ implementation(libs.androidx.lifecycle.compose.android)
+
+ implementation(libs.compose.destinations.animations)
+ ksp(libs.compose.destinations.ksp)
+
+ testImplementation(libs.bundles.testing)
+ testImplementation(projects.core.commonTest)
+}
diff --git a/feature/breeds/favorite-breeds/consumer-rules.pro b/feature/breeds/favorite-breeds/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/feature/breeds/favorite-breeds/proguard-rules.pro b/feature/breeds/favorite-breeds/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/feature/breeds/favorite-breeds/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/feature/breeds/favorite-breeds/src/main/AndroidManifest.xml b/feature/breeds/favorite-breeds/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..69fc412
--- /dev/null
+++ b/feature/breeds/favorite-breeds/src/main/AndroidManifest.xml
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/feature/breeds/favorite-breeds/src/main/java/com/aliumujib/favorite/breeds/navigation/FavoritesNavigator.kt b/feature/breeds/favorite-breeds/src/main/java/com/aliumujib/favorite/breeds/navigation/FavoritesNavigator.kt
new file mode 100644
index 0000000..bfed4fd
--- /dev/null
+++ b/feature/breeds/favorite-breeds/src/main/java/com/aliumujib/favorite/breeds/navigation/FavoritesNavigator.kt
@@ -0,0 +1,7 @@
+package com.aliumujib.favorite.breeds.navigation
+
+import com.aliumujib.model.BreedId
+
+interface FavoritesNavigator {
+ fun goToDetails(id: BreedId)
+}
diff --git a/feature/breeds/favorite-breeds/src/main/java/com/aliumujib/favorite/breeds/presentation/FavoritesUiEvents.kt b/feature/breeds/favorite-breeds/src/main/java/com/aliumujib/favorite/breeds/presentation/FavoritesUiEvents.kt
new file mode 100644
index 0000000..be69b8e
--- /dev/null
+++ b/feature/breeds/favorite-breeds/src/main/java/com/aliumujib/favorite/breeds/presentation/FavoritesUiEvents.kt
@@ -0,0 +1,6 @@
+package com.aliumujib.favorite.breeds.presentation
+
+
+sealed interface FavoritesUiEvents {
+ data class ShowError(val message: String) : FavoritesUiEvents
+}
\ No newline at end of file
diff --git a/feature/breeds/favorite-breeds/src/main/java/com/aliumujib/favorite/breeds/presentation/FavoritesUiState.kt b/feature/breeds/favorite-breeds/src/main/java/com/aliumujib/favorite/breeds/presentation/FavoritesUiState.kt
new file mode 100644
index 0000000..16e833b
--- /dev/null
+++ b/feature/breeds/favorite-breeds/src/main/java/com/aliumujib/favorite/breeds/presentation/FavoritesUiState.kt
@@ -0,0 +1,12 @@
+package com.aliumujib.favorite.breeds.presentation
+
+import androidx.compose.runtime.Immutable
+import com.aliumujib.model.Breed
+
+@Immutable
+sealed class FavoritesUiState {
+ data class Success(val breeds: List) : FavoritesUiState()
+ data object Loading : FavoritesUiState()
+ data class Error(val error: String?) : FavoritesUiState()
+ data object Initial : FavoritesUiState()
+}
diff --git a/feature/breeds/favorite-breeds/src/main/java/com/aliumujib/favorite/breeds/presentation/FavoritesViewModel.kt b/feature/breeds/favorite-breeds/src/main/java/com/aliumujib/favorite/breeds/presentation/FavoritesViewModel.kt
new file mode 100644
index 0000000..c43dc87
--- /dev/null
+++ b/feature/breeds/favorite-breeds/src/main/java/com/aliumujib/favorite/breeds/presentation/FavoritesViewModel.kt
@@ -0,0 +1,50 @@
+package com.aliumujib.favorite.breeds.presentation
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.aliumujib.model.Breed
+import com.aliumujib.songs.domain.usecases.StreamFavoritesListUseCase
+import com.aliumujib.songs.domain.usecases.ToggleFavoriteUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class FavoritesViewModel @Inject constructor(
+ streamFavoritesListUseCase: StreamFavoritesListUseCase,
+ private val toggleFavoriteUseCase: ToggleFavoriteUseCase
+) : ViewModel() {
+
+ val states: StateFlow =
+ streamFavoritesListUseCase()
+ .distinctUntilChanged()
+ .map, FavoritesUiState> { data -> FavoritesUiState.Success(data) }
+ .catch { emit(FavoritesUiState.Error(it.message)) }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.Eagerly,
+ initialValue = FavoritesUiState.Initial,
+ )
+
+ private val _events = MutableSharedFlow()
+ val events: SharedFlow = _events.asSharedFlow()
+
+ fun toggleFavorite(breed: Breed) {
+ viewModelScope.launch {
+ toggleFavoriteUseCase(breed.id)
+ .onFailure {
+ _events.emit(FavoritesUiEvents.ShowError(it.message ?: "Unknown error"))
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/feature/breeds/favorite-breeds/src/main/java/com/aliumujib/favorite/breeds/ui/FavoritesScreen.kt b/feature/breeds/favorite-breeds/src/main/java/com/aliumujib/favorite/breeds/ui/FavoritesScreen.kt
new file mode 100644
index 0000000..4e6937f
--- /dev/null
+++ b/feature/breeds/favorite-breeds/src/main/java/com/aliumujib/favorite/breeds/ui/FavoritesScreen.kt
@@ -0,0 +1,194 @@
+package com.aliumujib.favorite.breeds.ui
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.aliumujib.breed.common.ui.BreedDetailsSummaryBottomSheet
+import com.aliumujib.breed.common.ui.BreedListItem
+import com.aliumujib.favorite.breeds.navigation.FavoritesNavigator
+import com.aliumujib.favorite.breeds.presentation.FavoritesUiEvents
+import com.aliumujib.favorite.breeds.presentation.FavoritesUiState
+import com.aliumujib.favorite.breeds.presentation.FavoritesViewModel
+import com.aliumujib.favorite.songs.R
+import com.aliumujib.model.Breed
+import com.ramcosta.composedestinations.annotation.Destination
+import kotlinx.coroutines.flow.collectLatest
+
+@Destination
+@Composable
+fun FavoritesScreen(
+ navigator: FavoritesNavigator,
+ viewModel: FavoritesViewModel = hiltViewModel()
+) {
+ var isMoreSheetOpen by remember { mutableStateOf(false) }
+ var focusedBreed by remember { mutableStateOf(null) }
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ val uiState by viewModel.states.collectAsStateWithLifecycle()
+
+ LaunchedEffect(key1 = Unit) {
+ viewModel.events.collectLatest { event ->
+ when (event) {
+ is FavoritesUiEvents.ShowError -> {
+ snackbarHostState.showSnackbar(message = event.message)
+ }
+ }
+ }
+ }
+
+ FavoritesScreenContent(
+ isMoreSheetOpen = isMoreSheetOpen,
+ uiState = uiState,
+ focusedBreed = focusedBreed,
+ onSongPlaybackRequested = { breed ->
+ navigator.goToDetails(breed.id)
+ }, onMoreClick = {
+ focusedBreed = it
+ isMoreSheetOpen = true
+ }, onMoreDismissedRequest = {
+ focusedBreed = null
+ isMoreSheetOpen = false
+ }, onFavoriteClick = {
+ focusedBreed = it.copy(isFavorite = !it.isFavorite)
+ viewModel.toggleFavorite(breed = it)
+ })
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun FavoritesScreenContent(
+ isMoreSheetOpen: Boolean,
+ uiState: FavoritesUiState,
+ focusedBreed: Breed?,
+ onSongPlaybackRequested: (Breed) -> Unit,
+ onMoreClick: (Breed) -> Unit,
+ onMoreDismissedRequest: () -> Unit,
+ onFavoriteClick: (Breed) -> Unit,
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ ) {
+ val sheetState = rememberModalBottomSheetState()
+ val lazyListState = rememberLazyListState()
+ //val songs = uiState.breeds
+
+ Scaffold(
+ topBar = {
+ Column(Modifier.padding(16.dp)) {
+ Text(
+ text = stringResource(id = R.string.favorites_tab_title),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = stringResource(id = R.string.favorites_tab_sub_title),
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ },
+ ) { values ->
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(values),
+ state = lazyListState
+ ) {
+ when (uiState) {
+ is FavoritesUiState.Error -> {
+ item {
+ Text(
+ text = "No music found !",
+ modifier = Modifier
+ .padding(16.dp)
+ .fillMaxSize(),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+
+ FavoritesUiState.Initial -> {
+
+ }
+
+ FavoritesUiState.Loading -> {
+ item {
+ Box(
+ modifier = Modifier
+ .fillParentMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ }
+ }
+
+ is FavoritesUiState.Success -> {
+ if (uiState.breeds.isEmpty()) {
+ item {
+ Text(
+ text = "No favorites found !",
+ modifier = Modifier
+ .padding(16.dp)
+ .fillMaxSize(),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ } else {
+ itemsIndexed(uiState.breeds,
+ key = { _, song -> song.id }) { _, item ->
+ BreedListItem(
+ breed = item,
+ onItemClick = { onSongPlaybackRequested(it) },
+ onMoreClick = {
+ onMoreClick(it)
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (isMoreSheetOpen && focusedBreed != null) {
+ BreedDetailsSummaryBottomSheet(
+ breed = focusedBreed,
+ sheetState = sheetState,
+ onDismissRequest = onMoreDismissedRequest,
+ onFavoriteClick = onFavoriteClick
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/feature/breeds/favorite-breeds/src/main/res/values/strings.xml b/feature/breeds/favorite-breeds/src/main/res/values/strings.xml
new file mode 100644
index 0000000..82d0370
--- /dev/null
+++ b/feature/breeds/favorite-breeds/src/main/res/values/strings.xml
@@ -0,0 +1,5 @@
+
+
+ Favorites
+ Songs you\'ve liked any where in the app will show up here
+
\ No newline at end of file
diff --git a/feature/breeds/favorite-breeds/src/test/java/com/aliumujib/favorite/songs/presentation/FavoritesViewModelTest.kt b/feature/breeds/favorite-breeds/src/test/java/com/aliumujib/favorite/songs/presentation/FavoritesViewModelTest.kt
new file mode 100644
index 0000000..da4d9bf
--- /dev/null
+++ b/feature/breeds/favorite-breeds/src/test/java/com/aliumujib/favorite/songs/presentation/FavoritesViewModelTest.kt
@@ -0,0 +1,68 @@
+package com.aliumujib.favorite.songs.presentation
+
+import app.cash.turbine.test
+import com.aliumujib.common.test.MainCoroutineRule
+import com.aliumujib.common.test.SharedDummyData
+import com.aliumujib.favorite.breeds.presentation.FavoritesUiEvents
+import com.aliumujib.favorite.breeds.presentation.FavoritesUiState
+import com.aliumujib.favorite.breeds.presentation.FavoritesViewModel
+import com.aliumujib.songs.domain.usecases.StreamFavoritesListUseCase
+import com.aliumujib.songs.domain.usecases.ToggleFavoriteUseCase
+import com.google.common.truth.Truth.assertThat
+import io.mockk.MockKAnnotations
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class FavoritesViewModelTest {
+
+ @get:Rule
+ val mainCoroutineRule = MainCoroutineRule()
+
+ @MockK
+ private lateinit var toggleFavoriteUseCase: ToggleFavoriteUseCase
+
+ @MockK
+ private lateinit var streamFavoritesListUseCase: StreamFavoritesListUseCase
+
+ private lateinit var viewModel: FavoritesViewModel
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+ every { streamFavoritesListUseCase() } returns flowOf(SharedDummyData.breedList)
+ viewModel = FavoritesViewModel(streamFavoritesListUseCase, toggleFavoriteUseCase)
+ }
+
+ @Test
+ fun `given breeds, when ViewModel is initialized, then update state with breeds`() = runTest {
+ every { streamFavoritesListUseCase() } returns flowOf(SharedDummyData.breedList)
+
+ viewModel.states.test {
+ val item = awaitItem()
+ assertThat(item).isInstanceOf(FavoritesUiState.Success::class.java)
+ val successState = item as FavoritesUiState.Success
+ assertThat(successState.breeds).isEqualTo(SharedDummyData.breedList)
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when toggleFavorite is called, then update breed favorite status`() = runTest {
+ val breed = SharedDummyData.breed1
+ coEvery { toggleFavoriteUseCase(breed.id) } returns Result.failure(IllegalStateException())
+
+ viewModel.events.test {
+ viewModel.toggleFavorite(breed)
+ val event = awaitItem()
+ assertThat(event).isInstanceOf(FavoritesUiEvents.ShowError::class.java)
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+}
diff --git a/feature/settings/.gitignore b/feature/settings/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/feature/settings/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts
new file mode 100644
index 0000000..05c223e
--- /dev/null
+++ b/feature/settings/build.gradle.kts
@@ -0,0 +1,72 @@
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.android.kotlin)
+ alias(libs.plugins.kapt)
+ alias(libs.plugins.parcelize)
+ alias(libs.plugins.ksp)
+}
+
+apply {
+ from("$rootDir/base-module.gradle")
+}
+
+android {
+ compileSdk = AndroidConfig.compileSDK
+
+ defaultConfig {
+ minSdk = AndroidConfig.minSDK
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = AndroidConfig.javaVersion
+ targetCompatibility = AndroidConfig.javaVersion
+ }
+ kotlinOptions {
+ jvmTarget = AndroidConfig.jvmTarget
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+ namespace = "com.aliumujib.catbrowser.settings"
+}
+
+ksp {
+ arg("compose-destinations.mode", "destinations")
+ arg("compose-destinations.moduleName", "settings")
+}
+
+kotlin {
+ sourceSets {
+ debug {
+ kotlin.srcDir("build/generated/ksp/debug/kotlin")
+ }
+ release {
+ kotlin.srcDir("build/generated/ksp/release/kotlin")
+ }
+ }
+}
+
+dependencies {
+ implementation(projects.core.designsystem)
+ implementation(projects.core.common)
+ implementation(projects.core.analytics)
+ implementation(projects.core.preferences)
+ implementation(libs.androidx.lifecycle.compose.android)
+
+ implementation(libs.compose.destinations.animations)
+ ksp(libs.compose.destinations.ksp)
+}
diff --git a/feature/settings/consumer-rules.pro b/feature/settings/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/feature/settings/proguard-rules.pro b/feature/settings/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/feature/settings/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/feature/settings/src/main/java/com/aliumujib/settings/domain/model/Setting.kt b/feature/settings/src/main/java/com/aliumujib/settings/domain/model/Setting.kt
new file mode 100644
index 0000000..4b46da0
--- /dev/null
+++ b/feature/settings/src/main/java/com/aliumujib/settings/domain/model/Setting.kt
@@ -0,0 +1,6 @@
+package com.aliumujib.settings.domain.model
+
+data class Setting(
+ val title: String,
+ val icon: Int
+)
\ No newline at end of file
diff --git a/feature/settings/src/main/java/com/aliumujib/settings/domain/usecase/SetCurrentThemeUseCase.kt b/feature/settings/src/main/java/com/aliumujib/settings/domain/usecase/SetCurrentThemeUseCase.kt
new file mode 100644
index 0000000..266af3c
--- /dev/null
+++ b/feature/settings/src/main/java/com/aliumujib/settings/domain/usecase/SetCurrentThemeUseCase.kt
@@ -0,0 +1,12 @@
+package com.aliumujib.settings.domain.usecase
+
+import com.aliumujib.preferences.domain.AppPreferences
+import javax.inject.Inject
+
+class SetCurrentThemeUseCase @Inject constructor(
+ private val appPreferences: AppPreferences
+) {
+ suspend operator fun invoke(theme: Int) {
+ appPreferences.saveTheme(theme)
+ }
+}
diff --git a/feature/settings/src/main/java/com/aliumujib/settings/presentation/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/aliumujib/settings/presentation/settings/SettingsScreen.kt
new file mode 100644
index 0000000..8f7393d
--- /dev/null
+++ b/feature/settings/src/main/java/com/aliumujib/settings/presentation/settings/SettingsScreen.kt
@@ -0,0 +1,258 @@
+package com.aliumujib.settings.presentation.settings
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.widget.Toast
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.aliumujib.common.util.getAppVersionName
+import com.aliumujib.catbrowser.settings.R
+import com.aliumujib.settings.domain.model.Setting
+import com.aliumujib.settings.presentation.settings.components.FeedbackDialog
+import com.aliumujib.settings.presentation.settings.components.SettingCard
+import com.aliumujib.settings.presentation.settings.components.ThemesDialog
+import com.ramcosta.composedestinations.annotation.Destination
+import io.eyram.iconsax.IconSax
+
+@Destination
+@Composable
+fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel()) {
+ val context = LocalContext.current
+ val keyboardController = LocalSoftwareKeyboardController.current
+ val snackbarHostState = remember { SnackbarHostState() }
+ val settingsUiState by viewModel.states.collectAsStateWithLifecycle()
+
+ LaunchedEffect(key1 = Unit) {
+ viewModel.events.collect { event ->
+ when (event) {
+ is SettingsUiEvents.ShowErrorMessage -> {
+ snackbarHostState.showSnackbar(message = event.message)
+ }
+ }
+ }
+ }
+
+
+ SettingsScreenContent(
+ state = settingsUiState,
+ onEvent = { event ->
+ when (event) {
+ SettingsUiActions.ChangeThemeClicked -> {
+ viewModel.trackUserEvent("Themes Dialog Opened")
+ viewModel.setShowThemesDialogState()
+ }
+
+ SettingsUiActions.ReportOrSuggestClicked -> {
+ viewModel.trackUserEvent("Feedback Dialog Opened")
+ viewModel.setShowFeedbackDialogState()
+ }
+
+ is SettingsUiActions.SendFeedbackClicked -> {
+ keyboardController?.hide()
+ viewModel.setFeedbackState(
+ value = event.feedback,
+ error = if (event.feedback.isEmpty()) {
+ "Feedback cannot be empty"
+ } else {
+ null
+ }
+ )
+
+ if (event.feedback.isEmpty()) {
+ return@SettingsScreenContent
+ }
+
+ sendFeedbackIntent(event, viewModel, context)
+ }
+
+ is SettingsUiActions.OnFeedbackChanged -> {
+ viewModel.setFeedbackState(event.feedback)
+ }
+
+ SettingsUiActions.OnDismissThemesDialog -> {
+ viewModel.trackUserEvent("Themes Dialog Closed")
+ viewModel.setShowThemesDialogState()
+ }
+
+ is SettingsUiActions.OnSelectTheme -> {
+ viewModel.trackUserEvent("Theme Selected: ${event.themeValue}")
+ viewModel.updateTheme(event.themeValue)
+ }
+
+ SettingsUiActions.OnDismissFeedbackDialog -> {
+ viewModel.setShowFeedbackDialogState()
+ }
+ }
+ },
+ snackbarHost = {
+ SnackbarHost(snackbarHostState)
+ }
+ )
+
+}
+
+@Composable
+private fun SettingsScreenContent(
+ state: SettingsUiState,
+ onEvent: (SettingsUiActions) -> Unit,
+ snackbarHost: @Composable () -> Unit,
+) {
+ Scaffold(
+ snackbarHost = { snackbarHost() },
+ modifier = Modifier.fillMaxSize(),
+ topBar = {
+ Text(
+ modifier = Modifier.padding(16.dp),
+ text = stringResource(id = R.string.settings_tab_title),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ ) { paddingValues ->
+ val context = LocalContext.current
+
+ Box(
+ modifier = Modifier
+ .padding(paddingValues)
+ .fillMaxSize()
+ ) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .align(Alignment.TopCenter),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ items(context.settingsOptions()) { setting ->
+ SettingCard(
+ title = setting.title,
+ icon = setting.icon,
+ onClick = { settingsOption ->
+ when (settingsOption) {
+ context.getString(R.string.change_your_theme) -> {
+ onEvent(SettingsUiActions.ChangeThemeClicked)
+ }
+
+ context.getString(R.string.suggest_or_report_anything) -> {
+ onEvent(SettingsUiActions.ReportOrSuggestClicked)
+ }
+ }
+ }
+ )
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .padding(16.dp)
+ .align(Alignment.BottomCenter),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.app_version, getAppVersionName(context)),
+ modifier = Modifier,
+ style = MaterialTheme.typography.titleSmall,
+ fontSize = 11.sp
+ )
+
+ Text(
+ text = stringResource(R.string.made_with_by_aliu_mujib),
+ modifier = Modifier,
+ style = MaterialTheme.typography.titleSmall,
+ fontSize = 12.sp
+ )
+ }
+ }
+
+ if (state.shouldShowThemesDialog) {
+ ThemesDialog(
+ onDismiss = {
+ onEvent(SettingsUiActions.OnDismissThemesDialog)
+ },
+ onSelectTheme = {
+ onEvent(SettingsUiActions.OnSelectTheme(it))
+ }
+ )
+ }
+
+ if (state.shouldShowFeedbackDialog) {
+ FeedbackDialog(
+ currentFeedbackString = state.feedbackState.text,
+ isError = state.feedbackState.error != null,
+ error = state.feedbackState.error,
+ onDismiss = {
+ onEvent(SettingsUiActions.OnDismissFeedbackDialog)
+ },
+ onFeedbackChange = { newValue ->
+ onEvent(SettingsUiActions.OnFeedbackChanged(newValue))
+ },
+ onClickSend = { feedback ->
+ onEvent(SettingsUiActions.SendFeedbackClicked(feedback))
+ }
+ )
+ }
+ }
+}
+
+private fun Context.settingsOptions() = listOf(
+ Setting(
+ title = getString(R.string.change_your_theme),
+ icon = IconSax.Linear.Moon
+ ),
+ Setting(
+ title = getString(R.string.suggest_or_report_anything),
+ icon = IconSax.Linear.Text
+ ),
+)
+
+private fun sendFeedbackIntent(
+ event: SettingsUiActions.SendFeedbackClicked,
+ viewModel: SettingsViewModel,
+ context: Context
+) {
+ try {
+ val intent = Intent(Intent.ACTION_SENDTO).apply {
+ data = Uri.parse("mailto:")
+ putExtra(Intent.EXTRA_EMAIL, arrayOf("aliuabdulmujib@gmail.com"))
+ putExtra(Intent.EXTRA_SUBJECT, "APP FEEDBACK")
+ putExtra(Intent.EXTRA_TEXT, event.feedback)
+ viewModel.trackUserEvent("Feedback Sent: $event.feedback")
+ }
+ context.startActivity(intent)
+
+ viewModel.setShowFeedbackDialogState()
+ viewModel.setFeedbackState("")
+ } catch (e: Exception) {
+ Toast.makeText(
+ context,
+ "No Email Application Found",
+ Toast.LENGTH_SHORT
+ )
+ .show()
+ }
+}
diff --git a/feature/settings/src/main/java/com/aliumujib/settings/presentation/settings/SettingsUiActions.kt b/feature/settings/src/main/java/com/aliumujib/settings/presentation/settings/SettingsUiActions.kt
new file mode 100644
index 0000000..f377a22
--- /dev/null
+++ b/feature/settings/src/main/java/com/aliumujib/settings/presentation/settings/SettingsUiActions.kt
@@ -0,0 +1,25 @@
+package com.aliumujib.settings.presentation.settings
+
+sealed interface SettingsUiEvents {
+ class ShowErrorMessage(val message: String) : SettingsUiEvents
+}
+
+sealed interface SettingsUiActions {
+ class SendFeedbackClicked(val feedback: String) :
+ SettingsUiActions
+
+ data object ChangeThemeClicked : SettingsUiActions
+
+ data object ReportOrSuggestClicked : SettingsUiActions
+
+ data object OnDismissThemesDialog :
+ SettingsUiActions
+
+ data object OnDismissFeedbackDialog :
+ SettingsUiActions
+
+ data class OnFeedbackChanged(val feedback: String) : SettingsUiActions
+
+ data class OnSelectTheme(val themeValue: Int) : SettingsUiActions
+
+}
\ No newline at end of file
diff --git a/feature/settings/src/main/java/com/aliumujib/settings/presentation/settings/SettingsUiState.kt b/feature/settings/src/main/java/com/aliumujib/settings/presentation/settings/SettingsUiState.kt
new file mode 100644
index 0000000..d4aadc0
--- /dev/null
+++ b/feature/settings/src/main/java/com/aliumujib/settings/presentation/settings/SettingsUiState.kt
@@ -0,0 +1,11 @@
+package com.aliumujib.settings.presentation.settings
+
+import androidx.compose.runtime.Immutable
+import com.aliumujib.common.state.TextFieldState
+
+@Immutable
+data class SettingsUiState(
+ val shouldShowThemesDialog: Boolean = false,
+ val shouldShowFeedbackDialog: Boolean = false,
+ val feedbackState: TextFieldState = TextFieldState(),
+)
diff --git a/feature/settings/src/main/java/com/aliumujib/settings/presentation/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/aliumujib/settings/presentation/settings/SettingsViewModel.kt
new file mode 100644
index 0000000..aa7db02
--- /dev/null
+++ b/feature/settings/src/main/java/com/aliumujib/settings/presentation/settings/SettingsViewModel.kt
@@ -0,0 +1,67 @@
+package com.aliumujib.settings.presentation.settings
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.aliumujib.analytics.domain.usecase.TrackUserEventUseCase
+import com.aliumujib.settings.domain.usecase.SetCurrentThemeUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class SettingsViewModel @Inject constructor(
+ private val trackUserEventUseCase: TrackUserEventUseCase,
+ private val setCurrentThemeUseCase: SetCurrentThemeUseCase,
+) : ViewModel() {
+ fun trackUserEvent(eventName: String) {
+ trackUserEventUseCase.invoke(eventName)
+ }
+
+ private val _states = MutableStateFlow(SettingsUiState())
+ val states = _states.asStateFlow()
+
+ private val _events = MutableSharedFlow()
+ val events = _events
+
+ fun setShowThemesDialogState() {
+ _states.update {
+ it.copy(
+ shouldShowThemesDialog = !it.shouldShowThemesDialog
+ )
+ }
+ }
+
+ fun setShowFeedbackDialogState() {
+ _states.update {
+ it.copy(
+ shouldShowFeedbackDialog = !it.shouldShowFeedbackDialog
+ )
+ }
+ }
+
+
+ fun setFeedbackState(
+ value: String,
+ error: String? = null
+ ) {
+ _states.update {
+ it.copy(
+ feedbackState = it.feedbackState.copy(
+ text = value,
+ error = error
+ )
+ )
+ }
+ }
+
+ fun updateTheme(themeValue: Int) {
+ viewModelScope.launch {
+ setCurrentThemeUseCase(themeValue)
+ setShowThemesDialogState()
+ }
+ }
+}
diff --git a/feature/settings/src/main/java/com/aliumujib/settings/presentation/settings/components/FeedbackDialog.kt b/feature/settings/src/main/java/com/aliumujib/settings/presentation/settings/components/FeedbackDialog.kt
new file mode 100644
index 0000000..1af67a9
--- /dev/null
+++ b/feature/settings/src/main/java/com/aliumujib/settings/presentation/settings/components/FeedbackDialog.kt
@@ -0,0 +1,141 @@
+package com.aliumujib.settings.presentation.settings.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import io.eyram.iconsax.IconSax
+
+@Composable
+fun FeedbackDialog(
+ onDismiss: () -> Unit,
+ onClickSend: (String) -> Unit,
+ currentFeedbackString: String,
+ onFeedbackChange: (String) -> Unit,
+ isError: Boolean,
+ error: String?
+) {
+ AlertDialog(
+ containerColor = MaterialTheme.colorScheme.background,
+ onDismissRequest = { },
+ title = {
+ Text(
+ text = "Send Feedback to MealTime",
+ style = MaterialTheme.typography.titleMedium
+ )
+ },
+ text = {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Text(
+ text = "Describe your issue or suggestion",
+ style = MaterialTheme.typography.labelMedium
+ )
+ OutlinedTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight(),
+ value = currentFeedbackString,
+ onValueChange = {
+ onFeedbackChange(it)
+ },
+ colors = TextFieldDefaults.colors(),
+ placeholder = {
+ Text(
+ text = "Feedback...",
+ style = MaterialTheme.typography.labelMedium
+ )
+ },
+ keyboardOptions = KeyboardOptions.Default.copy(
+ keyboardType = KeyboardType.Text,
+ capitalization = KeyboardCapitalization.Words
+ ),
+ isError = isError,
+ singleLine = false
+ )
+ if (isError) {
+ Text(
+ text = error ?: "Feedback Cannot be empty",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.error,
+ textAlign = TextAlign.End,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Text(
+ text = "Please donβt include any sensitive information",
+ style = MaterialTheme.typography.labelSmall
+ )
+ Icon(
+ modifier = Modifier.size(14.dp),
+ painter = painterResource(
+ id = IconSax.Linear.Warning2
+ ),
+ contentDescription = null
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ },
+ confirmButton = {
+ Text(
+ text = "Send",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .clickable {
+ onClickSend(currentFeedbackString)
+ }
+ )
+ },
+ dismissButton = {
+ Text(
+ text = "Cancel",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .clickable { onDismiss() }
+ )
+ }
+ )
+}
diff --git a/feature/settings/src/main/java/com/aliumujib/settings/presentation/settings/components/SettingCard.kt b/feature/settings/src/main/java/com/aliumujib/settings/presentation/settings/components/SettingCard.kt
new file mode 100644
index 0000000..a518e11
--- /dev/null
+++ b/feature/settings/src/main/java/com/aliumujib/settings/presentation/settings/components/SettingCard.kt
@@ -0,0 +1,62 @@
+package com.aliumujib.settings.presentation.settings.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+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.res.painterResource
+import androidx.compose.ui.unit.dp
+import io.eyram.iconsax.IconSax
+
+@Composable
+fun SettingCard(onClick: (String) -> Unit, title: String, icon: Int) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .clickable {
+ onClick(title)
+ },
+ shape = MaterialTheme.shapes.large,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ painter = painterResource(id = icon),
+ contentDescription = null
+ )
+ Text(
+ text = title,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Icon(
+ painter = painterResource(id = IconSax.Linear.ArrowCircleRight),
+ contentDescription = null
+ )
+ }
+ }
+}
diff --git a/feature/settings/src/main/java/com/aliumujib/settings/presentation/settings/components/ThemeItem.kt b/feature/settings/src/main/java/com/aliumujib/settings/presentation/settings/components/ThemeItem.kt
new file mode 100644
index 0000000..75e43fe
--- /dev/null
+++ b/feature/settings/src/main/java/com/aliumujib/settings/presentation/settings/components/ThemeItem.kt
@@ -0,0 +1,49 @@
+package com.aliumujib.settings.presentation.settings.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+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.res.painterResource
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun ThemeItem(themeName: String, themeValue: Int, icon: Int, onSelectTheme: (Int) -> Unit) {
+ Card(
+ shape = MaterialTheme.shapes.large,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ ),
+ onClick = {
+ onSelectTheme(themeValue)
+ }
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ painter = painterResource(id = icon),
+ contentDescription = null
+ )
+ Text(
+ modifier = Modifier
+ .padding(12.dp),
+ text = themeName,
+ style = MaterialTheme.typography.labelMedium
+ )
+ }
+ }
+}
diff --git a/feature/settings/src/main/java/com/aliumujib/settings/presentation/settings/components/ThemesDialog.kt b/feature/settings/src/main/java/com/aliumujib/settings/presentation/settings/components/ThemesDialog.kt
new file mode 100644
index 0000000..1bf7aad
--- /dev/null
+++ b/feature/settings/src/main/java/com/aliumujib/settings/presentation/settings/components/ThemesDialog.kt
@@ -0,0 +1,65 @@
+package com.aliumujib.settings.presentation.settings.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import io.eyram.iconsax.IconSax
+
+@Composable
+fun ThemesDialog(onDismiss: () -> Unit, onSelectTheme: (Int) -> Unit) {
+ AlertDialog(
+ containerColor = MaterialTheme.colorScheme.background,
+ onDismissRequest = { onDismiss() },
+ title = {
+ Text(
+ text = "Themes",
+ style = MaterialTheme.typography.titleLarge
+ )
+ },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ ThemeItem(
+ themeName = "Use System Settings",
+ themeValue = com.aliumujib.designsystem.theme.Theme.FOLLOW_SYSTEM.themeValue,
+ icon = IconSax.Linear.Settings,
+ onSelectTheme = onSelectTheme
+ )
+ ThemeItem(
+ themeName = "Light Mode",
+ themeValue = com.aliumujib.designsystem.theme.Theme.LIGHT_THEME.themeValue,
+ icon = IconSax.Linear.Sun,
+ onSelectTheme = onSelectTheme
+ )
+ ThemeItem(
+ themeName = "Dark Mode",
+ themeValue = com.aliumujib.designsystem.theme.Theme.DARK_THEME.themeValue,
+ icon = IconSax.Linear.Moon,
+ onSelectTheme = onSelectTheme
+ )
+ ThemeItem(
+ themeName = "Material You",
+ themeValue = com.aliumujib.designsystem.theme.Theme.MATERIAL_YOU.themeValue,
+ icon = IconSax.Linear.PictureFrame,
+ onSelectTheme = onSelectTheme
+ )
+ }
+ },
+ confirmButton = {
+ Text(
+ text = "OK",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .clickable { onDismiss() }
+ )
+ }
+ )
+}
diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml
new file mode 100644
index 0000000..534c512
--- /dev/null
+++ b/feature/settings/src/main/res/values/strings.xml
@@ -0,0 +1,12 @@
+
+
+ Change Your Theme
+ Suggest or Report Anything
+ Rate Us on Play Store
+ Share the App with Friends
+ Logout
+ Sign Out
+ App Version: %1$s
+ Made with β€οΈ by Mujeeb
+ Settings
+
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..dce7fcf
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,27 @@
+# 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
+
++android.defaults.buildfeatures.buildconfig=true
++android.enableBuildConfigAsBytecode=true
+android.buildConfig=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..3235a60
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,148 @@
+[versions]
+kotlin = "1.9.22"
+kotlinxDatetime = "0.5.0"
+ksp = "1.9.22-1.0.17"
+ktx = "1.12.0"
+gradle = "8.2.1"
+lifecycle = "2.7.0"
+junit = "4.13.2"
+coroutines = "1.8.0-RC2"
+dagger-hilt = "2.50"
+hilt-compiler = "1.1.0"
+squigglyslider = "1.0.0"
+timber = "5.0.1"
+desugar-jdk-libs = "2.0.4"
+compose = "1.6.7"
+compose-compiler = "1.5.8"
+coil = "2.5.0"
+compose-activity = "1.8.2"
+compose-paging = "3.2.1"
+compose-livedata = "1.5.4"
+composeMaterial3 = "1.2.1"
+onebone = "2.3.5"
+mixpanel = "7.3.3"
+accompanist = "0.32.0"
+compose-destinations = "1.9.62"
+room = "2.6.1"
+paging = "3.2.1"
+datastore = "1.0.0"
+lottie = "6.3.0"
+arch-core-testing = "2.2.0"
+truth = "1.2.0"
+leakcanary = "2.13"
+junit-ext = "1.1.5"
+espresso-core = "3.5.1"
+core-splash-screen = "1.0.1"
+appcompat = "1.6.1"
+chucker = "4.0.0"
+material-icons-extended = "1.5.4"
+compose-hilt-navigation = "1.1.0"
+compose-lifecycle = "2.8.0"
+spotless = "6.24.0"
+android-material = "1.11.0"
+konsist = "0.13.0"
+iconsax = "1.0.0"
+kotlinx-coroutines-test = "1.6.1"
+turbine = "0.7.0"
+mockk = "1.12.0"
+junit4 = "4.13.2"
+graphicsShapes = "1.0.0-beta01"
+media3Session = "1.3.1"
+media3Exoplayer = "1.3.1"
+retrofit = "2.11.0"
+okhttp = "5.0.0-alpha.12"
+fixture = "1.2.0"
+
+[libraries]
+accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" }
+androidx-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "ktx" }
+kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
+lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+
+coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
+coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
+
+squigglyslider = { module = "me.saket.squigglyslider:squigglyslider", version.ref = "squigglyslider" }
+viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
+
+dagger-hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "dagger-hilt" }
+dagger-hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "dagger-hilt" }
+hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hilt-compiler" }
+
+compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "material-icons-extended" }
+compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "compose" }
+compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "compose" }
+compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "compose" }
+androidx-lifecycle-compose-android = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose-android", version.ref = "compose-lifecycle" }
+
+compose-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version.ref = "compose" }
+compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "compose" }
+
+activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "compose-activity" }
+compose-paging = { group = "androidx.paging", name = "paging-compose", version.ref = "compose-paging" }
+compose-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "compose-livedata" }
+compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "composeMaterial3" }
+lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
+compose-hilt-navigation = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "compose-hilt-navigation" }
+timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
+desugar-jdk-libs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugar-jdk-libs" }
+coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
+toolbar-compose = { module = "me.onebone:toolbar-compose", version.ref = "onebone" }
+mixpanel = { module = "com.mixpanel.android:mixpanel-android", version.ref = "mixpanel" }
+accompanist-swiperefresh = { group = "com.google.accompanist", name = "accompanist-swiperefresh", version.ref = "accompanist" }
+accompanist-pager = { group = "com.google.accompanist", name = "accompanist-pager", version.ref = "accompanist" }
+accompanist-pager-indicators = { group = "com.google.accompanist", name = "accompanist-pager-indicators", version.ref = "accompanist" }
+leakcanary-android = { group = "com.squareup.leakcanary", name = "leakcanary-android", version.ref = "leakcanary" }
+truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
+arch-core-testing = { group = "androidx.arch.core", name = "core-testing", version.ref = "arch-core-testing" }
+retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
+lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottie" }
+datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
+paging-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "paging" }
+junit-ext = { group = "androidx.test.ext", name = "junit", version.ref = "junit-ext" }
+espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" }
+compose-destinations-animations = { module = "io.github.raamcosta.compose-destinations:animations-core", version.ref = "compose-destinations" }
+compose-destinations-ksp = { module = "io.github.raamcosta.compose-destinations:ksp", version.ref = "compose-destinations" }
+room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
+room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
+room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
+room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
+core-splash-screen = { module = "androidx.core:core-splashscreen", version.ref = "core-splash-screen" }
+appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+accompanist-system-ui-controller = { group = "com.google.accompanist", name = "accompanist-systemuicontroller", version.ref = "accompanist" }
+android-material = { group = "com.google.android.material", name = "material", version.ref = "android-material" }
+graphics-shapes = { group = "androidx.graphics", name = "graphics-shapes", version.ref = "graphicsShapes" }
+compose-animation = { group = "androidx.compose.animation", name = "animation", version.ref = "compose" }
+konsist = { group = "com.lemonappdev", name = "konsist", version.ref = "konsist" }
+iconsax-android = { group = "io.github.being-eyram", name = "iconsax-android", version.ref = "iconsax" }
+squareup-retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
+squareup-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
+squareup-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
+test-fixture = { group = "com.appmattus.fixture", name = "fixture", version.ref = "fixture" }
+chucker-debug = { group = "com.github.chuckerteam.chucker", name = "library", version.ref = "chucker" }
+chucker-release = { group = "com.github.chuckerteam.chucker", name = "library-no-op", version.ref = "chucker" }
+
+kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test" }
+turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
+mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
+mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
+
+junit4 = { module = "junit:junit", version.ref = "junit4" }
+androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3Session" }
+androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3Exoplayer" }
+androidx-material3-android = { group = "androidx.compose.material3", name = "material3-android", version.ref = "composeMaterial3" }
+
+[plugins]
+android-kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+android-application = { id = "com.android.application", version.ref = "gradle" }
+android-library = { id = "com.android.library", version.ref = "gradle" }
+hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "dagger-hilt" }
+spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
+kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
+parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
+
+[bundles]
+testing = ["kotlinx-coroutines-test", "turbine", "truth", "mockk", "junit4", "arch-core-testing", "test-fixture"]
\ 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..4434c00
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Fri Dec 01 09:18:11 EAT 2023
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
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..107acd3
--- /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/pre-commit b/pre-commit
new file mode 100644
index 0000000..1009935
--- /dev/null
+++ b/pre-commit
@@ -0,0 +1,21 @@
+#!/bin/bash
+echo "*********************************************************"
+echo "Running git pre-commit hook. Running Static analysis... "
+echo "*********************************************************"
+
+./gradlew ktlintCheck
+
+status=$?
+
+if [ "$status" = 0 ] ; then
+ echo "Static analysis found no problems."
+ exit 0
+else
+ echo "*********************************************************"
+ echo " ******************************************** "
+ echo 1>&2 "Static analysis found violations it could not fix."
+ echo "Run ./gradlew ktlintFormat to fix formatting related issues..."
+ echo " ******************************************** "
+ echo "*********************************************************"
+ exit 1
+fi
\ No newline at end of file
diff --git a/renovate.json b/renovate.json
new file mode 100644
index 0000000..8f1c4e6
--- /dev/null
+++ b/renovate.json
@@ -0,0 +1,92 @@
+{
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
+ "extends": [
+ "config:base"
+ ],
+ "schedule": [
+ "on friday"
+ ],
+ "timezone": "Africa/Nairobi",
+ "labels": [
+ "dependency-update"
+ ],
+ "prHourlyLimit": 0,
+ "baseBranches": [
+ "develop"
+ ],
+ "separateMultipleMajor": true,
+ "dependencyDashboardTitle": "automated dependency updates dashboard",
+ "dependencyDashboard": true,
+ "branchPrefix": "chore/",
+ "additionalBranchPrefix": "update-libs/",
+ "commitMessageAction": "update",
+ "commitMessageExtra": "from {{{currentValue}}} to {{#if isPinDigest}}{{{newDigestShort}}}{{else}}{{#if isMajor}}{{prettyNewMajor}}{{else}}{{#if isSingleVersion}}{{prettyNewVersion}}{{else}}{{#if newValue}}{{{newValue}}}{{else}}{{{newDigestShort}}}{{/if}}{{/if}}{{/if}}{{/if}}",
+ "packageRules": [
+ {
+ "groupName": "all non-major dependencies",
+ "groupSlug": "all-minor-patch",
+ "matchPackagePatterns": [
+ "*"
+ ],
+ "matchUpdateTypes": [
+ "minor",
+ "patch"
+ ]
+ },
+ {
+ "groupName": "kotlin dependencies",
+ "matchPackagePatterns": [
+ "org.jetbrains.kotlin:*",
+ "com.google.devtools.ksp",
+ "composeOptions"
+ ]
+ },
+ {
+ "groupName": "coroutine dependencies",
+ "matchPackagePatterns": [
+ "io.coil-kt:*",
+ "org.jetbrains.kotlinx:*"
+ ]
+ },
+ {
+ "groupName": "plugin dependencies",
+ "matchPackagePatterns": [
+ "com.android.library",
+ "com.android.application",
+ "app.cash.paparazzi"
+ ]
+ },
+ {
+ "groupName": "sonar",
+ "matchPackagePatterns": [
+ "org.sonarqube"
+ ]
+ },
+ {
+ "groupName": "target sdk 34",
+ "matchPackagePatterns": [
+ "androidx.navigation:navigation-compose"
+ ]
+ },
+ {
+ "groupName": "ktlint",
+ "matchPackagePatterns": [
+ "org.jlleitschuh.gradle.ktlint"
+ ]
+ },
+ {
+ "groupName": "test dependencies",
+ "matchPackagePatterns": [
+ "com.google.truth:truth",
+ "androidx.compose.ui:ui-test-junit4",
+ "androidx.compose.ui:ui-test-manifest",
+ "org.robolectric:robolectric",
+ "junit:junit",
+ "androidx.test:core-ktx"
+ ]
+ }
+ ],
+ "ignoreDeps": [
+ "androidx.emoji2:emoji2"
+ ]
+}
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..ec24f5a
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,39 @@
+enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
+pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ google()
+ mavenCentral()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ maven(url = "https://jitpack.io")
+ maven("https://androidx.dev/storage/compose-compiler/repository/") {
+ content {
+ includeGroup("androidx.compose.compiler")
+ }
+ }
+ }
+}
+rootProject.name = "CatBreedBrowser"
+include(":app")
+include(":feature:settings")
+include(":feature:breeds:all-breeds")
+include(":feature:breeds:favorite-breeds")
+include(":feature:breeds:common")
+include(":feature:breeds:breed-details")
+include(":feature:breeds:breeds-data")
+include(":feature:breeds:breeds-domain")
+include(":core:designsystem")
+include(":core:models")
+include(":core:common")
+include(":core:common-test")
+include(":core:database")
+include(":core:analytics")
+include(":core:preferences")
+include(":core:network")
+include(":core:common-domain")
diff --git a/spotless/copyright.java b/spotless/copyright.java
new file mode 100644
index 0000000..8f50c30
--- /dev/null
+++ b/spotless/copyright.java
@@ -0,0 +1,15 @@
+/*
+ * Copyright $YEAR Abdul-Mujeeb Aliu
+ *
+ * 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
+ *
+ * http://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.
+ */
\ No newline at end of file
diff --git a/spotless/copyright.kt b/spotless/copyright.kt
new file mode 100644
index 0000000..a07d3df
--- /dev/null
+++ b/spotless/copyright.kt
@@ -0,0 +1,15 @@
+/*
+ * Copyright $YEAR Abdul-Mujeeb Aliu.
+ *
+ * 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
+ *
+ * http://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.
+ */
\ No newline at end of file