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 @@ + + + + + + + + + + + + \ 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