├── .gitignore ├── .idea ├── .gitignore ├── compiler.xml ├── deploymentTargetSelector.xml ├── gradle.xml ├── kotlinc.xml ├── migrations.xml ├── misc.xml ├── studiobot.xml └── vcs.xml ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── example │ │ └── imagevista │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── example │ │ │ └── imagevista │ │ │ ├── data │ │ │ ├── local │ │ │ │ ├── EditorialFeedDao.kt │ │ │ │ ├── FavoriteImagesDao.kt │ │ │ │ ├── ImageVistaDatabase.kt │ │ │ │ └── entity │ │ │ │ │ ├── FavoriteImageEntity.kt │ │ │ │ │ ├── UnsplashImageEntity.kt │ │ │ │ │ └── UnsplashRemoteKeys.kt │ │ │ ├── mapper │ │ │ │ └── Mappers.kt │ │ │ ├── paging │ │ │ │ ├── EditorialFeedRemoteMediator.kt │ │ │ │ └── SearchPagingSource.kt │ │ │ ├── remote │ │ │ │ ├── UnsplashApiService.kt │ │ │ │ └── dto │ │ │ │ │ ├── UnsplashImageDto.kt │ │ │ │ │ ├── UnsplashImagesSearchResult.kt │ │ │ │ │ └── UserDto.kt │ │ │ ├── repository │ │ │ │ ├── AndroidImageDownloader.kt │ │ │ │ ├── ImageRepositoryImpl.kt │ │ │ │ └── NetworkConnectivityObserverImpl.kt │ │ │ └── util │ │ │ │ └── Constants.kt │ │ │ ├── di │ │ │ └── AppModule.kt │ │ │ ├── domain │ │ │ ├── model │ │ │ │ ├── NetworkStatus.kt │ │ │ │ └── UnsplashImage.kt │ │ │ └── repository │ │ │ │ ├── Downloader.kt │ │ │ │ ├── ImageRepository.kt │ │ │ │ └── NetworkConnectivityObserver.kt │ │ │ └── presentation │ │ │ ├── ImageVistaApp.kt │ │ │ ├── MainActivity.kt │ │ │ ├── component │ │ │ ├── DownloadOptionsBottomSheet.kt │ │ │ ├── ImageCard.kt │ │ │ ├── ImageVerticalGrid.kt │ │ │ ├── ImageVistaAppBar.kt │ │ │ ├── ImageVistaLoadingBar.kt │ │ │ ├── NetworkStatusBar.kt │ │ │ └── ZoomedImageCard.kt │ │ │ ├── favorites_screen │ │ │ ├── FavoritesScreen.kt │ │ │ └── FavoritesViewModel.kt │ │ │ ├── full_image_screen │ │ │ ├── FullImageScreen.kt │ │ │ └── FullImageViewModel.kt │ │ │ ├── home_screen │ │ │ ├── HomeScreen.kt │ │ │ └── HomeViewModel.kt │ │ │ ├── navigation │ │ │ ├── NavGraph.kt │ │ │ └── Routes.kt │ │ │ ├── profile_screen │ │ │ └── ProfileScreen.kt │ │ │ ├── search_screen │ │ │ ├── SearchScreen.kt │ │ │ └── SearchViewModel.kt │ │ │ ├── theme │ │ │ ├── Color.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ │ │ └── util │ │ │ ├── CommonUtil.kt │ │ │ ├── SnackbarEvent.kt │ │ │ └── WindowInsetsControllerExt.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ ├── app_logo.png │ │ ├── ic_download.xml │ │ ├── ic_error.xml │ │ ├── ic_launcher_background.xml │ │ ├── ic_save.xml │ │ └── img_empty_bookmarks.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── values-night │ │ └── splash.xml │ │ ├── values │ │ ├── colors.xml │ │ ├── splash.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ └── test │ └── java │ └── com │ └── example │ └── imagevista │ └── ExampleUnitTest.kt ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── readme-assets ├── 0.png ├── 1.gif ├── 2.gif ├── 3.gif ├── 4.gif ├── 5.gif ├── 6.gif ├── 7.gif ├── 8.gif ├── 9.gif ├── test └── youtube playlist.png └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | .kotlin -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/deploymentTargetSelector.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 18 | 19 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /.idea/studiobot.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Mohammad Arif 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![0](./readme-assets/0.png) 3 |

4 | Image Vista Android App 5 |

6 | 7 | Welcome to the Image Vista Android App repository. We will build an amazing Android application that showcases beautiful images from the Unsplash API. We'll be using a range of powerful libraries and frameworks to build our app. 8 | 9 | # :building_construction: Tech Stack :building_construction: 10 | 11 | - **Jetpack Compose:** To build the User Interface 12 | - **Material 3:** To Design a beautiful and consistent UI. 13 | - **Splash Screen:** To create a captivating splash screen for our app. 14 | - **Paging 3:** To Implement efficient and smooth infinite scrolling. 15 | - **Coil:** To Load and display images effortlessly. 16 | - **Dagger Hilt:** To Manage dependency injection for cleaner, modular code. 17 | - **Compose Navigation:** To Navigate between screens seamlessly. 18 | - **Retrofit:** To Make network requests and handle API responses. 19 | - **Room:** To Store and manage local data. 20 | 21 | # :camera_flash: **Screenshots** :camera_flash: 22 | 23 | | Main Feed Screen | Search Screen | Bookmarks Screen | 24 | |-----------------------------------|-----------------------------------|-----------------------------------| 25 | | | | | 26 | | Profile (WebView) | Splash Screen | Preview Image | 27 | | | | | 28 | | Zoom Image | Download Image | Dark Mode | 29 | | | | | 30 | 31 | 32 | # :hammer_and_wrench: Youtube Playlist :hammer_and_wrench: 33 | 34 | 🎥 All videos can be found here [YouTube Course Playlist](https://youtube.com/playlist?list=PL1b73-6UjePBns1mFhHNhZvIUXEFNdd8c&si=1xu29HdLqcZJ_RW-). 35 | 36 | ![Screenshot (177)](https://github.com/CodeInKotLang/ImageVista/assets/110901093/4dbd8638-5464-4bd6-9a1d-54e104da6d13) 37 | 38 | 39 | # :memo: Authors :memo: 40 | - [Mohammad Arif](https://github.com/CodeInKotLang) 41 | 42 | Check out my Udemy online course: [MeasureMate Android App](https://www.udemy.com/course/measuremate/?referralCode=B3DE352F96BC3C3E9E80) 43 | 44 | 45 | Buy Me A Coffee 46 | 47 | 48 | Happy learning and building amazing Android apps! 49 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import java.util.Properties 2 | 3 | plugins { 4 | alias(libs.plugins.android.application) 5 | alias(libs.plugins.compose.compiler) 6 | alias(libs.plugins.jetbrains.kotlin.android) 7 | alias(libs.plugins.dagger.hilt) 8 | alias(libs.plugins.ksp) 9 | alias(libs.plugins.kotlin.serialization) 10 | } 11 | 12 | android { 13 | namespace = "com.example.imagevista" 14 | compileSdk = 34 15 | 16 | defaultConfig { 17 | applicationId = "com.example.imagevista" 18 | minSdk = 21 19 | targetSdk = 34 20 | versionCode = 1 21 | versionName = "1.0" 22 | 23 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 24 | vectorDrawables { 25 | useSupportLibrary = true 26 | } 27 | val properties = Properties() 28 | properties.load(project.rootProject.file("local.properties").inputStream()) 29 | buildConfigField("String", "UNSPLASH_API_KEY", properties.getProperty("UNSPLASH_API_KEY")) 30 | } 31 | 32 | buildTypes { 33 | release { 34 | isMinifyEnabled = false 35 | proguardFiles( 36 | getDefaultProguardFile("proguard-android-optimize.txt"), 37 | "proguard-rules.pro" 38 | ) 39 | } 40 | } 41 | compileOptions { 42 | sourceCompatibility = JavaVersion.VERSION_1_8 43 | targetCompatibility = JavaVersion.VERSION_1_8 44 | } 45 | kotlinOptions { 46 | jvmTarget = "1.8" 47 | } 48 | buildFeatures { 49 | buildConfig = true 50 | compose = true 51 | } 52 | packaging { 53 | resources { 54 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 55 | } 56 | } 57 | } 58 | 59 | composeCompiler { 60 | enableStrongSkippingMode = true 61 | } 62 | 63 | dependencies { 64 | implementation(libs.androidx.core.ktx) 65 | implementation(libs.androidx.lifecycle.runtime.ktx) 66 | implementation(libs.androidx.activity.compose) 67 | implementation(platform(libs.androidx.compose.bom)) 68 | implementation(libs.androidx.ui) 69 | implementation(libs.androidx.ui.graphics) 70 | implementation(libs.androidx.ui.tooling.preview) 71 | implementation(libs.androidx.material3) 72 | testImplementation(libs.junit) 73 | androidTestImplementation(libs.androidx.junit) 74 | androidTestImplementation(libs.androidx.espresso.core) 75 | androidTestImplementation(platform(libs.androidx.compose.bom)) 76 | androidTestImplementation(libs.androidx.ui.test.junit4) 77 | debugImplementation(libs.androidx.ui.tooling) 78 | debugImplementation(libs.androidx.ui.test.manifest) 79 | 80 | //compose 81 | implementation(libs.androidx.lifecycle.viewmodel.compose) 82 | implementation(libs.androidx.lifecycle.runtime.compose) 83 | implementation(libs.androidx.navigation.compose) 84 | implementation(libs.coil.compose) 85 | 86 | // Room 87 | ksp(libs.androidx.room.compiler) 88 | implementation(libs.androidx.room.runtime) 89 | implementation(libs.androidx.room.ktx) 90 | implementation(libs.androidx.room.paging) 91 | 92 | //Splash Screen 93 | implementation(libs.androidx.core.splashscreen) 94 | 95 | //Cloudy for blurring effect 96 | implementation(libs.cloudy) 97 | 98 | //Paging 99 | implementation(libs.androidx.paging.runtime.ktx) 100 | implementation(libs.androidx.paging.compose) 101 | 102 | // KotlinX Serialization 103 | implementation(libs.kotlinx.serialization.json) 104 | 105 | // Retrofit 106 | implementation(libs.retrofit) 107 | implementation(libs.converter.gson) 108 | implementation(libs.converter.scalars) 109 | implementation(libs.retrofit2.kotlinx.serialization.converter) 110 | implementation(libs.okhttp) 111 | 112 | //Dagger-Hilt 113 | implementation(libs.hilt.android) 114 | ksp(libs.hilt.android.compiler) 115 | ksp(libs.androidx.hilt.compiler) 116 | implementation(libs.androidx.hilt.navigation.compose) 117 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/com/example/imagevista/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.example.imagevista", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 19 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/data/local/EditorialFeedDao.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.data.local 2 | 3 | import androidx.paging.PagingSource 4 | import androidx.room.Dao 5 | import androidx.room.Query 6 | import androidx.room.Upsert 7 | import com.example.imagevista.data.local.entity.UnsplashImageEntity 8 | import com.example.imagevista.data.local.entity.UnsplashRemoteKeys 9 | 10 | @Dao 11 | interface EditorialFeedDao { 12 | 13 | @Query("SELECT * FROM images_table") 14 | fun getAllEditorialFeedImages(): PagingSource 15 | 16 | @Upsert 17 | suspend fun insertEditorialFeedImages(images: List) 18 | 19 | @Query("DELETE FROM images_table") 20 | suspend fun deleteAllEditorialFeedImages() 21 | 22 | @Query("SELECT * FROM remote_keys_table WHERE id = :id") 23 | suspend fun getRemoteKeys(id: String): UnsplashRemoteKeys 24 | 25 | @Upsert 26 | suspend fun insertAllRemoteKeys(remoteKeys: List) 27 | 28 | @Query("DELETE FROM remote_keys_table") 29 | suspend fun deleteAllRemoteKeys() 30 | 31 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/data/local/FavoriteImagesDao.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.data.local 2 | 3 | import androidx.paging.PagingSource 4 | import androidx.room.Dao 5 | import androidx.room.Delete 6 | import androidx.room.Query 7 | import androidx.room.Upsert 8 | import com.example.imagevista.data.local.entity.FavoriteImageEntity 9 | import kotlinx.coroutines.flow.Flow 10 | 11 | @Dao 12 | interface FavoriteImagesDao { 13 | 14 | @Query("SELECT * FROM favorite_images_table") 15 | fun getAllFavoriteImages(): PagingSource 16 | 17 | @Upsert 18 | suspend fun insertFavoriteImage(image: FavoriteImageEntity) 19 | 20 | @Delete 21 | suspend fun deleteFavoriteImage(image: FavoriteImageEntity) 22 | 23 | @Query("SELECT EXISTS(SELECT 1 FROM favorite_images_table WHERE id = :id)") 24 | suspend fun isImageFavorite(id: String): Boolean 25 | 26 | @Query("SELECT id FROM favorite_images_table") 27 | fun getFavoriteImageIds(): Flow> 28 | 29 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/data/local/ImageVistaDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.data.local 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import com.example.imagevista.data.local.entity.FavoriteImageEntity 6 | import com.example.imagevista.data.local.entity.UnsplashImageEntity 7 | import com.example.imagevista.data.local.entity.UnsplashRemoteKeys 8 | 9 | @Database( 10 | entities = [FavoriteImageEntity::class, UnsplashImageEntity::class, UnsplashRemoteKeys::class], 11 | version = 1, 12 | exportSchema = false 13 | ) 14 | abstract class ImageVistaDatabase: RoomDatabase() { 15 | abstract fun favoriteImagesDao(): FavoriteImagesDao 16 | 17 | abstract fun editorialFeedDao(): EditorialFeedDao 18 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/data/local/entity/FavoriteImageEntity.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.data.local.entity 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | import com.example.imagevista.data.util.Constants.FAVORITE_IMAGE_TABLE 6 | 7 | @Entity(tableName = FAVORITE_IMAGE_TABLE) 8 | data class FavoriteImageEntity( 9 | @PrimaryKey 10 | val id: String, 11 | val imageUrlSmall: String, 12 | val imageUrlRegular: String, 13 | val imageUrlRaw: String, 14 | val photographerName: String, 15 | val photographerUsername: String, 16 | val photographerProfileImgUrl: String, 17 | val photographerProfileLink: String, 18 | val width: Int, 19 | val height: Int, 20 | val description: String? 21 | ) 22 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/data/local/entity/UnsplashImageEntity.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.data.local.entity 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | import com.example.imagevista.data.util.Constants.UNSPLASH_IMAGE_TABLE 6 | 7 | @Entity(tableName = UNSPLASH_IMAGE_TABLE) 8 | data class UnsplashImageEntity( 9 | @PrimaryKey 10 | val id: String, 11 | val imageUrlSmall: String, 12 | val imageUrlRegular: String, 13 | val imageUrlRaw: String, 14 | val photographerName: String, 15 | val photographerUsername: String, 16 | val photographerProfileImgUrl: String, 17 | val photographerProfileLink: String, 18 | val width: Int, 19 | val height: Int, 20 | val description: String? 21 | ) 22 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/data/local/entity/UnsplashRemoteKeys.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.data.local.entity 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | import com.example.imagevista.data.util.Constants.REMOTE_KEYS_TABLE 6 | 7 | @Entity(tableName = REMOTE_KEYS_TABLE) 8 | data class UnsplashRemoteKeys( 9 | @PrimaryKey 10 | val id: String, 11 | val prevPage: Int?, 12 | val nextPage: Int? 13 | ) 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/data/mapper/Mappers.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.data.mapper 2 | 3 | import com.example.imagevista.data.local.entity.FavoriteImageEntity 4 | import com.example.imagevista.data.local.entity.UnsplashImageEntity 5 | import com.example.imagevista.data.remote.dto.UnsplashImageDto 6 | import com.example.imagevista.domain.model.UnsplashImage 7 | 8 | fun UnsplashImageDto.toDomainModel(): UnsplashImage { 9 | return UnsplashImage( 10 | id = this.id, 11 | imageUrlSmall = this.urls.small, 12 | imageUrlRegular = this.urls.regular, 13 | imageUrlRaw = this.urls.raw, 14 | photographerName = this.user.name, 15 | photographerUsername = this.user.username, 16 | photographerProfileImgUrl = this.user.profileImage.small, 17 | photographerProfileLink = this.user.links.html, 18 | width = this.width, 19 | height = this.height, 20 | description = description 21 | ) 22 | } 23 | 24 | fun UnsplashImageDto.toEntity(): UnsplashImageEntity { 25 | return UnsplashImageEntity( 26 | id = this.id, 27 | imageUrlSmall = this.urls.small, 28 | imageUrlRegular = this.urls.regular, 29 | imageUrlRaw = this.urls.raw, 30 | photographerName = this.user.name, 31 | photographerUsername = this.user.username, 32 | photographerProfileImgUrl = this.user.profileImage.small, 33 | photographerProfileLink = this.user.links.html, 34 | width = this.width, 35 | height = this.height, 36 | description = description 37 | ) 38 | } 39 | 40 | fun UnsplashImage.toFavoriteImageEntity(): FavoriteImageEntity { 41 | return FavoriteImageEntity( 42 | id = this.id, 43 | imageUrlSmall = this.imageUrlSmall, 44 | imageUrlRegular = this.imageUrlRegular, 45 | imageUrlRaw = this.imageUrlRaw, 46 | photographerName = this.photographerName, 47 | photographerUsername = this.photographerUsername, 48 | photographerProfileImgUrl = this.photographerProfileImgUrl, 49 | photographerProfileLink = this.photographerProfileLink, 50 | width = this.width, 51 | height = this.height, 52 | description = description 53 | ) 54 | } 55 | 56 | fun FavoriteImageEntity.toDomainModel(): UnsplashImage { 57 | return UnsplashImage( 58 | id = this.id, 59 | imageUrlSmall = this.imageUrlSmall, 60 | imageUrlRegular = this.imageUrlRegular, 61 | imageUrlRaw = this.imageUrlRaw, 62 | photographerName = this.photographerName, 63 | photographerUsername = this.photographerUsername, 64 | photographerProfileImgUrl = this.photographerProfileImgUrl, 65 | photographerProfileLink = this.photographerProfileLink, 66 | width = this.width, 67 | height = this.height, 68 | description = description 69 | ) 70 | } 71 | 72 | fun UnsplashImageEntity.toDomainModel(): UnsplashImage { 73 | return UnsplashImage( 74 | id = this.id, 75 | imageUrlSmall = this.imageUrlSmall, 76 | imageUrlRegular = this.imageUrlRegular, 77 | imageUrlRaw = this.imageUrlRaw, 78 | photographerName = this.photographerName, 79 | photographerUsername = this.photographerUsername, 80 | photographerProfileImgUrl = this.photographerProfileImgUrl, 81 | photographerProfileLink = this.photographerProfileLink, 82 | width = this.width, 83 | height = this.height, 84 | description = description 85 | ) 86 | } 87 | 88 | fun List.toDomainModelList(): List { 89 | return this.map { it.toDomainModel() } 90 | } 91 | 92 | fun List.toEntityList(): List { 93 | return this.map { it.toEntity() } 94 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/data/paging/EditorialFeedRemoteMediator.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.data.paging 2 | 3 | import android.util.Log 4 | import androidx.paging.ExperimentalPagingApi 5 | import androidx.paging.LoadType 6 | import androidx.paging.LoadType.APPEND 7 | import androidx.paging.LoadType.PREPEND 8 | import androidx.paging.LoadType.REFRESH 9 | import androidx.paging.PagingState 10 | import androidx.paging.RemoteMediator 11 | import androidx.room.withTransaction 12 | import com.example.imagevista.data.local.ImageVistaDatabase 13 | import com.example.imagevista.data.local.entity.UnsplashImageEntity 14 | import com.example.imagevista.data.local.entity.UnsplashRemoteKeys 15 | import com.example.imagevista.data.mapper.toEntityList 16 | import com.example.imagevista.data.remote.UnsplashApiService 17 | import com.example.imagevista.data.util.Constants 18 | import com.example.imagevista.data.util.Constants.ITEMS_PER_PAGE 19 | 20 | @OptIn(ExperimentalPagingApi::class) 21 | class EditorialFeedRemoteMediator( 22 | private val apiService: UnsplashApiService, 23 | private val database: ImageVistaDatabase 24 | ) : RemoteMediator() { 25 | 26 | companion object { 27 | private const val STARTING_PAGE_INDEX = 1 28 | } 29 | 30 | private val editorialFeedDao = database.editorialFeedDao() 31 | 32 | override suspend fun load( 33 | loadType: LoadType, 34 | state: PagingState 35 | ): MediatorResult { 36 | try { 37 | val currentPage = when (loadType) { 38 | REFRESH -> { 39 | val remoteKeys = getRemoteKeyClosestToCurrentPosition(state) 40 | remoteKeys?.nextPage?.minus(1) ?: STARTING_PAGE_INDEX 41 | } 42 | 43 | PREPEND -> { 44 | val remoteKeys = getRemoteKeyForFirstItem(state) 45 | Log.d(Constants.IV_LOG_TAG, "remoteKeysPrev: ${remoteKeys?.prevPage}") 46 | val prevPage = remoteKeys?.prevPage 47 | ?: return MediatorResult.Success(endOfPaginationReached = remoteKeys != null) 48 | prevPage 49 | } 50 | 51 | APPEND -> { 52 | val remoteKeys = getRemoteKeyForLastItem(state) 53 | Log.d(Constants.IV_LOG_TAG, "remoteKeysNext: ${remoteKeys?.nextPage}") 54 | val nextPage = remoteKeys?.nextPage 55 | ?: return MediatorResult.Success(endOfPaginationReached = remoteKeys != null) 56 | nextPage 57 | } 58 | } 59 | 60 | val response = 61 | apiService.getEditorialFeedImages(page = currentPage, perPage = ITEMS_PER_PAGE) 62 | val endOfPaginationReached = response.isEmpty() 63 | Log.d(Constants.IV_LOG_TAG, "endOfPaginationReached: $endOfPaginationReached") 64 | 65 | val prevPage = if (currentPage == 1) null else currentPage - 1 66 | val nextPage = if (endOfPaginationReached) null else currentPage + 1 67 | Log.d(Constants.IV_LOG_TAG, "prevPage: $prevPage") 68 | Log.d(Constants.IV_LOG_TAG, "nextPage: $nextPage") 69 | 70 | database.withTransaction { 71 | if (loadType == REFRESH) { 72 | editorialFeedDao.deleteAllEditorialFeedImages() 73 | editorialFeedDao.deleteAllRemoteKeys() 74 | } 75 | val remoteKeys = response.map { unsplashImageDto -> 76 | UnsplashRemoteKeys( 77 | id = unsplashImageDto.id, 78 | prevPage = prevPage, 79 | nextPage = nextPage 80 | ) 81 | } 82 | editorialFeedDao.insertAllRemoteKeys(remoteKeys) 83 | editorialFeedDao.insertEditorialFeedImages(response.toEntityList()) 84 | } 85 | return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached) 86 | } catch (e: Exception) { 87 | Log.d(Constants.IV_LOG_TAG, "LoadResultError: ${e.message}") 88 | return MediatorResult.Error(e) 89 | } 90 | } 91 | 92 | private suspend fun getRemoteKeyClosestToCurrentPosition( 93 | state: PagingState 94 | ): UnsplashRemoteKeys? { 95 | return state.anchorPosition?.let { position -> 96 | state.closestItemToPosition(position)?.id?.let { id -> 97 | editorialFeedDao.getRemoteKeys(id = id) 98 | } 99 | } 100 | } 101 | 102 | private suspend fun getRemoteKeyForFirstItem( 103 | state: PagingState 104 | ): UnsplashRemoteKeys? { 105 | return state.pages.firstOrNull { it.data.isNotEmpty() }?.data?.firstOrNull() 106 | ?.let { unsplashImage -> 107 | editorialFeedDao.getRemoteKeys(id = unsplashImage.id) 108 | } 109 | } 110 | 111 | private suspend fun getRemoteKeyForLastItem( 112 | state: PagingState 113 | ): UnsplashRemoteKeys? { 114 | return state.pages.lastOrNull { it.data.isNotEmpty() }?.data?.lastOrNull() 115 | ?.let { unsplashImage -> 116 | editorialFeedDao.getRemoteKeys(id = unsplashImage.id) 117 | } 118 | } 119 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/data/paging/SearchPagingSource.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.data.paging 2 | 3 | import android.util.Log 4 | import androidx.paging.PagingSource 5 | import androidx.paging.PagingState 6 | import com.example.imagevista.data.mapper.toDomainModelList 7 | import com.example.imagevista.data.remote.UnsplashApiService 8 | import com.example.imagevista.data.util.Constants 9 | import com.example.imagevista.domain.model.UnsplashImage 10 | 11 | class SearchPagingSource( 12 | private val query: String, 13 | private val unsplashApi: UnsplashApiService 14 | ): PagingSource() { 15 | 16 | companion object { 17 | private const val STARTING_PAGE_INDEX = 1 18 | } 19 | 20 | override fun getRefreshKey(state: PagingState): Int? { 21 | Log.d(Constants.IV_LOG_TAG, "getRefreshKey: ${state.anchorPosition}") 22 | return state.anchorPosition 23 | } 24 | 25 | override suspend fun load(params: LoadParams): LoadResult { 26 | val currentPage = params.key ?: STARTING_PAGE_INDEX 27 | Log.d(Constants.IV_LOG_TAG, "currentPage: $currentPage") 28 | return try { 29 | val response = unsplashApi.searchImages( 30 | query = query, 31 | page = currentPage, 32 | perPage = params.loadSize 33 | ) 34 | val endOfPaginationReached = response.images.isEmpty() 35 | Log.d(Constants.IV_LOG_TAG, "Load Result response: ${response.images.toDomainModelList()}") 36 | Log.d(Constants.IV_LOG_TAG, "endOfPaginationReached: $endOfPaginationReached") 37 | 38 | LoadResult.Page( 39 | data = response.images.toDomainModelList(), 40 | prevKey = if (currentPage == STARTING_PAGE_INDEX) null else currentPage - 1, 41 | nextKey = if (endOfPaginationReached) null else currentPage + 1 42 | ) 43 | } catch (e: Exception) { 44 | Log.d(Constants.IV_LOG_TAG, "LoadResultError: ${e.message}") 45 | LoadResult.Error(e) 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/data/remote/UnsplashApiService.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.data.remote 2 | 3 | import com.example.imagevista.data.remote.dto.UnsplashImageDto 4 | import com.example.imagevista.data.remote.dto.UnsplashImagesSearchResult 5 | import com.example.imagevista.data.util.Constants.API_KEY 6 | import retrofit2.http.GET 7 | import retrofit2.http.Headers 8 | import retrofit2.http.Path 9 | import retrofit2.http.Query 10 | 11 | interface UnsplashApiService { 12 | 13 | @Headers("Authorization: Client-ID $API_KEY") 14 | @GET("/photos") 15 | suspend fun getEditorialFeedImages( 16 | @Query("page") page: Int, 17 | @Query("per_page") perPage: Int 18 | ): List 19 | 20 | @Headers("Authorization: Client-ID $API_KEY") 21 | @GET("/search/photos") 22 | suspend fun searchImages( 23 | @Query("query") query: String, 24 | @Query("page") page: Int, 25 | @Query("per_page") perPage: Int 26 | ): UnsplashImagesSearchResult 27 | 28 | @Headers("Authorization: Client-ID $API_KEY") 29 | @GET("/photos/{id}") 30 | suspend fun getImage( 31 | @Path("id") imageId: String 32 | ): UnsplashImageDto 33 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/data/remote/dto/UnsplashImageDto.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.data.remote.dto 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class UnsplashImageDto( 7 | val id: String, 8 | val description: String?, 9 | val height: Int, 10 | val width: Int, 11 | val urls: UrlsDto, 12 | val user: UserDto, 13 | ) 14 | 15 | @Serializable 16 | data class UrlsDto( 17 | val full: String, 18 | val raw: String, 19 | val regular: String, 20 | val small: String, 21 | val thumb: String 22 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/data/remote/dto/UnsplashImagesSearchResult.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.data.remote.dto 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class UnsplashImagesSearchResult( 8 | @SerialName("results") 9 | val images: List, 10 | val total: Int, 11 | @SerialName("total_pages") 12 | val totalPages: Int 13 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/data/remote/dto/UserDto.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.data.remote.dto 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class UserDto( 8 | val links: UserLinksDto, 9 | val name: String, 10 | @SerialName("profile_image") 11 | val profileImage: ProfileImageDto, 12 | val username: String 13 | ) 14 | 15 | @Serializable 16 | data class ProfileImageDto( 17 | val small: String 18 | ) 19 | 20 | @Serializable 21 | data class UserLinksDto( 22 | val html: String, 23 | val likes: String, 24 | val photos: String, 25 | val portfolio: String, 26 | val self: String 27 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/data/repository/AndroidImageDownloader.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.data.repository 2 | 3 | import android.app.DownloadManager 4 | import android.content.Context 5 | import android.os.Environment 6 | import androidx.core.net.toUri 7 | import com.example.imagevista.domain.repository.Downloader 8 | import java.io.File 9 | 10 | class AndroidImageDownloader( 11 | context: Context 12 | ) : Downloader { 13 | 14 | private val downLoadManager = 15 | context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager 16 | 17 | override fun downloadFile(url: String, fileName: String?) { 18 | try { 19 | val title = fileName ?: "New Image" 20 | val request = DownloadManager.Request(url.toUri()) 21 | .setMimeType("image/*") 22 | .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) 23 | .setTitle(title) 24 | .setDestinationInExternalPublicDir( 25 | Environment.DIRECTORY_PICTURES, 26 | File.separator + title + ".jpg" 27 | ) 28 | downLoadManager.enqueue(request) 29 | } catch (e: Exception) { 30 | e.printStackTrace() 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/data/repository/ImageRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.data.repository 2 | 3 | import androidx.paging.ExperimentalPagingApi 4 | import androidx.paging.Pager 5 | import androidx.paging.PagingConfig 6 | import androidx.paging.PagingData 7 | import androidx.paging.map 8 | import com.example.imagevista.data.local.ImageVistaDatabase 9 | import com.example.imagevista.data.mapper.toDomainModel 10 | import com.example.imagevista.data.mapper.toFavoriteImageEntity 11 | import com.example.imagevista.data.paging.EditorialFeedRemoteMediator 12 | import com.example.imagevista.data.paging.SearchPagingSource 13 | import com.example.imagevista.data.remote.UnsplashApiService 14 | import com.example.imagevista.data.util.Constants.ITEMS_PER_PAGE 15 | import com.example.imagevista.domain.model.UnsplashImage 16 | import com.example.imagevista.domain.repository.ImageRepository 17 | import kotlinx.coroutines.flow.Flow 18 | import kotlinx.coroutines.flow.map 19 | 20 | @OptIn(ExperimentalPagingApi::class) 21 | class ImageRepositoryImpl( 22 | private val unsplashApi: UnsplashApiService, 23 | private val database: ImageVistaDatabase 24 | ) : ImageRepository { 25 | 26 | private val favoriteImagesDao = database.favoriteImagesDao() 27 | private val editorialFeedDao = database.editorialFeedDao() 28 | 29 | override fun getEditorialFeedImages(): Flow> { 30 | return Pager( 31 | config = PagingConfig(pageSize = ITEMS_PER_PAGE), 32 | remoteMediator = EditorialFeedRemoteMediator(unsplashApi, database), 33 | pagingSourceFactory = { editorialFeedDao.getAllEditorialFeedImages() } 34 | ) 35 | .flow 36 | .map { pagingData -> 37 | pagingData.map { it.toDomainModel() } 38 | } 39 | } 40 | 41 | override suspend fun getImage(imageId: String): UnsplashImage { 42 | return unsplashApi.getImage(imageId).toDomainModel() 43 | } 44 | 45 | override fun searchImages(query: String): Flow> { 46 | return Pager( 47 | config = PagingConfig(pageSize = ITEMS_PER_PAGE), 48 | pagingSourceFactory = { SearchPagingSource(query, unsplashApi) } 49 | ).flow 50 | } 51 | 52 | override fun getAllFavoriteImages(): Flow> { 53 | return Pager( 54 | config = PagingConfig(pageSize = ITEMS_PER_PAGE), 55 | pagingSourceFactory = { favoriteImagesDao.getAllFavoriteImages() } 56 | ) 57 | .flow 58 | .map { pagingData -> 59 | pagingData.map { it.toDomainModel() } 60 | } 61 | } 62 | 63 | override suspend fun toggleFavoriteStatus(image: UnsplashImage) { 64 | val isFavorite = favoriteImagesDao.isImageFavorite(image.id) 65 | val favoriteImage = image.toFavoriteImageEntity() 66 | if (isFavorite) { 67 | favoriteImagesDao.deleteFavoriteImage(favoriteImage) 68 | } else { 69 | favoriteImagesDao.insertFavoriteImage(favoriteImage) 70 | } 71 | } 72 | 73 | override fun getFavoriteImageIds(): Flow> { 74 | return favoriteImagesDao.getFavoriteImageIds() 75 | } 76 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/data/repository/NetworkConnectivityObserverImpl.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.data.repository 2 | 3 | import android.content.Context 4 | import android.net.ConnectivityManager 5 | import android.net.ConnectivityManager.NetworkCallback 6 | import android.net.Network 7 | import android.net.NetworkCapabilities 8 | import android.net.NetworkRequest 9 | import com.example.imagevista.domain.model.NetworkStatus 10 | import com.example.imagevista.domain.repository.NetworkConnectivityObserver 11 | import kotlinx.coroutines.CoroutineScope 12 | import kotlinx.coroutines.channels.awaitClose 13 | import kotlinx.coroutines.flow.Flow 14 | import kotlinx.coroutines.flow.SharingStarted 15 | import kotlinx.coroutines.flow.StateFlow 16 | import kotlinx.coroutines.flow.callbackFlow 17 | import kotlinx.coroutines.flow.distinctUntilChanged 18 | import kotlinx.coroutines.flow.stateIn 19 | 20 | class NetworkConnectivityObserverImpl( 21 | context: Context, 22 | scope: CoroutineScope 23 | ): NetworkConnectivityObserver { 24 | 25 | private val connectivityManager = 26 | context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager 27 | 28 | private val _networkStatus = observe().stateIn( 29 | scope = scope, 30 | started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), 31 | initialValue = NetworkStatus.Disconnected 32 | ) 33 | override val networkStatus: StateFlow = _networkStatus 34 | 35 | private fun observe(): Flow { 36 | return callbackFlow { 37 | val connectivityCallback = object : NetworkCallback() { 38 | override fun onAvailable(network: Network) { 39 | super.onAvailable(network) 40 | trySend(NetworkStatus.Connected) 41 | } 42 | 43 | override fun onLost(network: Network) { 44 | super.onLost(network) 45 | trySend(NetworkStatus.Disconnected) 46 | } 47 | } 48 | val request = NetworkRequest.Builder() 49 | .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) 50 | .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) 51 | .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) 52 | .build() 53 | connectivityManager.registerNetworkCallback(request, connectivityCallback) 54 | awaitClose { 55 | connectivityManager.unregisterNetworkCallback(connectivityCallback) 56 | } 57 | } 58 | .distinctUntilChanged() 59 | } 60 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/data/util/Constants.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.data.util 2 | 3 | import com.example.imagevista.BuildConfig 4 | 5 | object Constants { 6 | 7 | const val IV_LOG_TAG = "ImageVistaLogs" 8 | 9 | const val API_KEY = BuildConfig.UNSPLASH_API_KEY 10 | const val BASE_URL = "https://api.unsplash.com/" 11 | 12 | const val IMAGE_VISTA_DATABASE = "unsplash_images.db" 13 | const val FAVORITE_IMAGE_TABLE = "favorite_images_table" 14 | const val UNSPLASH_IMAGE_TABLE = "images_table" 15 | const val REMOTE_KEYS_TABLE = "remote_keys_table" 16 | 17 | const val ITEMS_PER_PAGE = 10 18 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.di 2 | 3 | import android.content.Context 4 | import androidx.room.Room 5 | import com.example.imagevista.data.local.ImageVistaDatabase 6 | import com.example.imagevista.data.remote.UnsplashApiService 7 | import com.example.imagevista.data.repository.AndroidImageDownloader 8 | import com.example.imagevista.data.repository.ImageRepositoryImpl 9 | import com.example.imagevista.data.repository.NetworkConnectivityObserverImpl 10 | import com.example.imagevista.data.util.Constants 11 | import com.example.imagevista.data.util.Constants.IMAGE_VISTA_DATABASE 12 | import com.example.imagevista.domain.repository.Downloader 13 | import com.example.imagevista.domain.repository.ImageRepository 14 | import com.example.imagevista.domain.repository.NetworkConnectivityObserver 15 | import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory 16 | import dagger.Module 17 | import dagger.Provides 18 | import dagger.hilt.InstallIn 19 | import dagger.hilt.android.qualifiers.ApplicationContext 20 | import dagger.hilt.components.SingletonComponent 21 | import kotlinx.coroutines.CoroutineScope 22 | import kotlinx.coroutines.Dispatchers 23 | import kotlinx.coroutines.SupervisorJob 24 | import kotlinx.serialization.json.Json 25 | import okhttp3.MediaType.Companion.toMediaType 26 | import retrofit2.Retrofit 27 | import javax.inject.Singleton 28 | 29 | @Module 30 | @InstallIn(SingletonComponent::class) 31 | object AppModule { 32 | 33 | @Provides 34 | @Singleton 35 | fun provideUnsplashApiService(): UnsplashApiService { 36 | val contentType = "application/json".toMediaType() 37 | val json = Json { ignoreUnknownKeys = true } 38 | val retrofit = Retrofit.Builder() 39 | .addConverterFactory(json.asConverterFactory(contentType)) 40 | .baseUrl(Constants.BASE_URL) 41 | .build() 42 | return retrofit.create(UnsplashApiService::class.java) 43 | } 44 | 45 | @Provides 46 | @Singleton 47 | fun provideImageVistaDatabase( 48 | @ApplicationContext context: Context 49 | ): ImageVistaDatabase { 50 | return Room 51 | .databaseBuilder( 52 | context, 53 | ImageVistaDatabase::class.java, 54 | IMAGE_VISTA_DATABASE 55 | ) 56 | .build() 57 | } 58 | 59 | @Provides 60 | @Singleton 61 | fun provideImageRepository( 62 | apiService: UnsplashApiService, 63 | database: ImageVistaDatabase 64 | ): ImageRepository { 65 | return ImageRepositoryImpl(apiService, database) 66 | } 67 | 68 | @Provides 69 | @Singleton 70 | fun provideAndroidImageDownloader( 71 | @ApplicationContext context: Context 72 | ): Downloader { 73 | return AndroidImageDownloader(context) 74 | } 75 | 76 | @Provides 77 | @Singleton 78 | fun provideApplicationScope(): CoroutineScope { 79 | return CoroutineScope(SupervisorJob() + Dispatchers.Default) 80 | } 81 | 82 | @Provides 83 | @Singleton 84 | fun provideNetworkConnectivityObserver( 85 | @ApplicationContext context: Context, 86 | scope: CoroutineScope 87 | ): NetworkConnectivityObserver { 88 | return NetworkConnectivityObserverImpl(context, scope) 89 | } 90 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/domain/model/NetworkStatus.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.domain.model 2 | 3 | sealed class NetworkStatus { 4 | data object Connected: NetworkStatus() 5 | data object Disconnected: NetworkStatus() 6 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/domain/model/UnsplashImage.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.domain.model 2 | 3 | data class UnsplashImage( 4 | val id: String, 5 | val imageUrlSmall: String, 6 | val imageUrlRegular: String, 7 | val imageUrlRaw: String, 8 | val photographerName: String, 9 | val photographerUsername: String, 10 | val photographerProfileImgUrl: String, 11 | val photographerProfileLink: String, 12 | val width: Int, 13 | val height: Int, 14 | val description: String? 15 | ) 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/domain/repository/Downloader.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.domain.repository 2 | 3 | interface Downloader { 4 | fun downloadFile(url: String, fileName: String?) 5 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/domain/repository/ImageRepository.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.domain.repository 2 | 3 | import androidx.paging.PagingData 4 | import com.example.imagevista.domain.model.UnsplashImage 5 | import kotlinx.coroutines.flow.Flow 6 | 7 | interface ImageRepository { 8 | 9 | fun getEditorialFeedImages(): Flow> 10 | 11 | suspend fun getImage(imageId: String): UnsplashImage 12 | 13 | fun searchImages(query: String): Flow> 14 | 15 | fun getAllFavoriteImages(): Flow> 16 | 17 | suspend fun toggleFavoriteStatus(image: UnsplashImage) 18 | 19 | fun getFavoriteImageIds(): Flow> 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/domain/repository/NetworkConnectivityObserver.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.domain.repository 2 | 3 | import com.example.imagevista.domain.model.NetworkStatus 4 | import kotlinx.coroutines.flow.StateFlow 5 | 6 | interface NetworkConnectivityObserver { 7 | val networkStatus: StateFlow 8 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/ImageVistaApp.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | @HiltAndroidApp 7 | class ImageVistaApp : Application() -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation 2 | 3 | import android.annotation.SuppressLint 4 | import android.os.Bundle 5 | import androidx.activity.ComponentActivity 6 | import androidx.activity.compose.setContent 7 | import androidx.activity.enableEdgeToEdge 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.material3.ExperimentalMaterial3Api 10 | import androidx.compose.material3.Scaffold 11 | import androidx.compose.material3.SnackbarHost 12 | import androidx.compose.material3.SnackbarHostState 13 | import androidx.compose.material3.TopAppBarDefaults 14 | import androidx.compose.runtime.LaunchedEffect 15 | import androidx.compose.runtime.collectAsState 16 | import androidx.compose.runtime.getValue 17 | import androidx.compose.runtime.mutableStateOf 18 | import androidx.compose.runtime.remember 19 | import androidx.compose.runtime.saveable.rememberSaveable 20 | import androidx.compose.runtime.setValue 21 | import androidx.compose.ui.Modifier 22 | import androidx.compose.ui.graphics.Color 23 | import androidx.compose.ui.input.nestedscroll.nestedScroll 24 | import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen 25 | import androidx.navigation.compose.rememberNavController 26 | import com.example.imagevista.domain.model.NetworkStatus 27 | import com.example.imagevista.domain.repository.NetworkConnectivityObserver 28 | import com.example.imagevista.presentation.component.NetworkStatusBar 29 | import com.example.imagevista.presentation.navigation.NavGraphSetup 30 | import com.example.imagevista.presentation.theme.CustomGreen 31 | import com.example.imagevista.presentation.theme.ImageVistaTheme 32 | import dagger.hilt.android.AndroidEntryPoint 33 | import kotlinx.coroutines.delay 34 | import javax.inject.Inject 35 | 36 | @AndroidEntryPoint 37 | class MainActivity : ComponentActivity() { 38 | 39 | @Inject 40 | lateinit var connectivityObserver: NetworkConnectivityObserver 41 | 42 | @OptIn(ExperimentalMaterial3Api::class) 43 | @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") 44 | override fun onCreate(savedInstanceState: Bundle?) { 45 | super.onCreate(savedInstanceState) 46 | installSplashScreen() 47 | enableEdgeToEdge() 48 | setContent { 49 | val status by connectivityObserver.networkStatus.collectAsState() 50 | var showMessageBar by rememberSaveable { mutableStateOf(false) } 51 | var message by rememberSaveable { mutableStateOf("") } 52 | var backgroundColor by remember { mutableStateOf(Color.Red) } 53 | 54 | LaunchedEffect(key1 = status) { 55 | when (status) { 56 | NetworkStatus.Connected -> { 57 | message = "Connected to Internet" 58 | backgroundColor = CustomGreen 59 | delay(timeMillis = 2000) 60 | showMessageBar = false 61 | } 62 | 63 | NetworkStatus.Disconnected -> { 64 | showMessageBar = true 65 | message = "No Internet Connection" 66 | backgroundColor = Color.Red 67 | } 68 | } 69 | } 70 | 71 | ImageVistaTheme { 72 | val navController = rememberNavController() 73 | val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() 74 | val snackbarHostState = remember { SnackbarHostState() } 75 | 76 | var searchQuery by rememberSaveable { mutableStateOf("") } 77 | 78 | Scaffold( 79 | snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, 80 | modifier = Modifier 81 | .fillMaxSize() 82 | .nestedScroll(scrollBehavior.nestedScrollConnection), 83 | bottomBar = { 84 | NetworkStatusBar( 85 | showMessageBar = showMessageBar, 86 | message = message, 87 | backgroundColor = backgroundColor 88 | ) 89 | } 90 | ) { 91 | NavGraphSetup( 92 | navController = navController, 93 | scrollBehavior = scrollBehavior, 94 | snackbarHostState = snackbarHostState, 95 | searchQuery = searchQuery, 96 | onSearchQueryChange = { searchQuery = it } 97 | ) 98 | } 99 | } 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/component/DownloadOptionsBottomSheet.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation.component 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material3.ExperimentalMaterial3Api 8 | import androidx.compose.material3.MaterialTheme 9 | import androidx.compose.material3.ModalBottomSheet 10 | import androidx.compose.material3.SheetState 11 | import androidx.compose.material3.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.unit.dp 16 | 17 | @OptIn(ExperimentalMaterial3Api::class) 18 | @Composable 19 | fun DownloadOptionsBottomSheet( 20 | modifier: Modifier = Modifier, 21 | isOpen: Boolean, 22 | sheetState: SheetState, 23 | onDismissRequest: () -> Unit, 24 | onOptionClick: (ImageDownloadOption) -> Unit, 25 | options: List = ImageDownloadOption.entries 26 | ) { 27 | if (isOpen) { 28 | ModalBottomSheet( 29 | modifier = modifier, 30 | sheetState = sheetState, 31 | onDismissRequest = { onDismissRequest() } 32 | ) { 33 | options.forEach { option -> 34 | Box( 35 | modifier = Modifier 36 | .fillMaxWidth() 37 | .clickable { onOptionClick(option) } 38 | .padding(16.dp), 39 | contentAlignment = Alignment.Center 40 | ) { 41 | Text(text = option.label, style = MaterialTheme.typography.bodyLarge) 42 | } 43 | } 44 | } 45 | } 46 | } 47 | 48 | enum class ImageDownloadOption(val label: String) { 49 | SMALL(label = "Download Small Size"), 50 | MEDIUM(label = "Download Medium Size"), 51 | ORIGINAL(label = "Download Original Size"), 52 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/component/ImageCard.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation.component 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.aspectRatio 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.shape.RoundedCornerShape 8 | import androidx.compose.material.icons.Icons 9 | import androidx.compose.material.icons.filled.Favorite 10 | import androidx.compose.material.icons.filled.FavoriteBorder 11 | import androidx.compose.material3.Card 12 | import androidx.compose.material3.FilledIconToggleButton 13 | import androidx.compose.material3.Icon 14 | import androidx.compose.material3.IconButtonDefaults 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.runtime.derivedStateOf 17 | import androidx.compose.runtime.getValue 18 | import androidx.compose.runtime.remember 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.graphics.Color 22 | import androidx.compose.ui.layout.ContentScale 23 | import androidx.compose.ui.platform.LocalContext 24 | import androidx.compose.ui.unit.dp 25 | import coil.compose.AsyncImage 26 | import coil.request.ImageRequest 27 | import com.example.imagevista.domain.model.UnsplashImage 28 | 29 | @Composable 30 | fun ImageCard( 31 | modifier: Modifier = Modifier, 32 | image: UnsplashImage?, 33 | isFavorite: Boolean, 34 | onToggleFavoriteStatus: () -> Unit 35 | ) { 36 | val imageRequest = ImageRequest.Builder(LocalContext.current) 37 | .data(image?.imageUrlSmall) 38 | .crossfade(true) 39 | .build() 40 | val aspectRatio: Float by remember { 41 | derivedStateOf { (image?.width?.toFloat() ?: 1f) / (image?.height?.toFloat() ?: 1f) } 42 | } 43 | 44 | Card( 45 | shape = RoundedCornerShape(10.dp), 46 | modifier = Modifier 47 | .fillMaxWidth() 48 | .aspectRatio(aspectRatio) 49 | .then(modifier) 50 | ) { 51 | Box { 52 | AsyncImage( 53 | model = imageRequest, 54 | contentDescription = null, 55 | contentScale = ContentScale.FillBounds, 56 | modifier = Modifier.fillMaxSize() 57 | ) 58 | FavoriteButton( 59 | isFavorite = isFavorite, 60 | onClick = onToggleFavoriteStatus, 61 | modifier = Modifier.align(Alignment.BottomEnd) 62 | ) 63 | } 64 | } 65 | } 66 | 67 | @Composable 68 | fun FavoriteButton( 69 | modifier: Modifier = Modifier, 70 | isFavorite: Boolean, 71 | onClick: () -> Unit 72 | ) { 73 | FilledIconToggleButton( 74 | modifier = modifier, 75 | checked = isFavorite, 76 | onCheckedChange = { onClick() }, 77 | colors = IconButtonDefaults.filledIconToggleButtonColors( 78 | containerColor = Color.Transparent 79 | ) 80 | ) { 81 | if (isFavorite) { 82 | Icon(imageVector = Icons.Default.Favorite, contentDescription = null) 83 | } else { 84 | Icon(imageVector = Icons.Default.FavoriteBorder, contentDescription = null) 85 | } 86 | } 87 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/component/ImageVerticalGrid.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation.component 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.PaddingValues 7 | import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid 8 | import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.input.pointer.pointerInput 12 | import androidx.compose.ui.unit.dp 13 | import androidx.paging.compose.LazyPagingItems 14 | import com.example.imagevista.domain.model.UnsplashImage 15 | 16 | @Composable 17 | fun ImagesVerticalGrid( 18 | modifier: Modifier = Modifier, 19 | images: LazyPagingItems, 20 | favoriteImageIds: List, 21 | onImageClick: (String) -> Unit, 22 | onImageDragStart: (UnsplashImage?) -> Unit, 23 | onImageDragEnd: () -> Unit, 24 | onToggleFavoriteStatus: (UnsplashImage) -> Unit 25 | ) { 26 | LazyVerticalStaggeredGrid( 27 | modifier = modifier, 28 | columns = StaggeredGridCells.Adaptive(120.dp), 29 | contentPadding = PaddingValues(10.dp), 30 | verticalItemSpacing = 10.dp, 31 | horizontalArrangement = Arrangement.spacedBy(10.dp) 32 | ) { 33 | items(count = images.itemCount) { index -> 34 | val image = images[index] 35 | ImageCard( 36 | image = image, 37 | modifier = Modifier 38 | .clickable { image?.id?.let { onImageClick(it) } } 39 | .pointerInput(Unit) { 40 | detectDragGesturesAfterLongPress( 41 | onDragStart = { onImageDragStart(image) }, 42 | onDragCancel = { onImageDragEnd() }, 43 | onDragEnd = { onImageDragEnd() }, 44 | onDrag = { _, _ -> } 45 | ) 46 | }, 47 | onToggleFavoriteStatus = { image?.let { onToggleFavoriteStatus(it) } }, 48 | isFavorite = favoriteImageIds.contains(image?.id) 49 | ) 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/component/ImageVistaAppBar.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation.component 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.animation.fadeIn 5 | import androidx.compose.animation.fadeOut 6 | import androidx.compose.animation.slideInVertically 7 | import androidx.compose.animation.slideOutVertically 8 | import androidx.compose.foundation.clickable 9 | import androidx.compose.foundation.layout.Column 10 | import androidx.compose.foundation.layout.Row 11 | import androidx.compose.foundation.layout.Spacer 12 | import androidx.compose.foundation.layout.size 13 | import androidx.compose.foundation.layout.width 14 | import androidx.compose.foundation.shape.CircleShape 15 | import androidx.compose.material.icons.Icons 16 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 17 | import androidx.compose.material.icons.filled.Search 18 | import androidx.compose.material3.CenterAlignedTopAppBar 19 | import androidx.compose.material3.ExperimentalMaterial3Api 20 | import androidx.compose.material3.Icon 21 | import androidx.compose.material3.IconButton 22 | import androidx.compose.material3.MaterialTheme 23 | import androidx.compose.material3.Text 24 | import androidx.compose.material3.TopAppBarDefaults 25 | import androidx.compose.material3.TopAppBarScrollBehavior 26 | import androidx.compose.runtime.Composable 27 | import androidx.compose.ui.Alignment 28 | import androidx.compose.ui.Modifier 29 | import androidx.compose.ui.draw.clip 30 | import androidx.compose.ui.res.painterResource 31 | import androidx.compose.ui.text.SpanStyle 32 | import androidx.compose.ui.text.buildAnnotatedString 33 | import androidx.compose.ui.text.font.FontWeight 34 | import androidx.compose.ui.text.withStyle 35 | import androidx.compose.ui.unit.dp 36 | import coil.compose.AsyncImage 37 | import com.example.imagevista.R 38 | import com.example.imagevista.domain.model.UnsplashImage 39 | 40 | @OptIn(ExperimentalMaterial3Api::class) 41 | @Composable 42 | fun ImageVistaTopAppBar( 43 | modifier: Modifier = Modifier, 44 | scrollBehavior: TopAppBarScrollBehavior, 45 | title: String = "Image Vista", 46 | onSearchClick: () -> Unit = {}, 47 | navigationIcon: @Composable () -> Unit = {} 48 | ) { 49 | CenterAlignedTopAppBar( 50 | modifier = modifier, 51 | scrollBehavior = scrollBehavior, 52 | title = { 53 | Text( 54 | text = buildAnnotatedString { 55 | withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) { 56 | append(title.split(" ").first()) 57 | } 58 | withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.secondary)) { 59 | append(" ${title.split(" ").last()}") 60 | } 61 | }, 62 | fontWeight = FontWeight.ExtraBold 63 | ) 64 | }, 65 | actions = { 66 | IconButton(onClick = { onSearchClick() }) { 67 | Icon( 68 | imageVector = Icons.Filled.Search, 69 | contentDescription = "Search" 70 | ) 71 | } 72 | }, 73 | colors = TopAppBarDefaults.centerAlignedTopAppBarColors( 74 | scrolledContainerColor = MaterialTheme.colorScheme.background 75 | ), 76 | navigationIcon = navigationIcon 77 | ) 78 | } 79 | 80 | @Composable 81 | fun FullImageViewTopBar( 82 | modifier: Modifier = Modifier, 83 | image: UnsplashImage?, 84 | isVisible: Boolean, 85 | onBackClick: () -> Unit, 86 | onPhotographerNameClick: (String) -> Unit, 87 | onDownloadImgClick: () -> Unit, 88 | ) { 89 | AnimatedVisibility( 90 | visible = isVisible, 91 | enter = fadeIn() + slideInVertically(), 92 | exit = fadeOut() + slideOutVertically() 93 | ) { 94 | Row( 95 | modifier = modifier, 96 | verticalAlignment = Alignment.CenterVertically 97 | ) { 98 | IconButton(onClick = { onBackClick() }) { 99 | Icon( 100 | imageVector = Icons.AutoMirrored.Filled.ArrowBack, 101 | contentDescription = "Go Back" 102 | ) 103 | } 104 | AsyncImage( 105 | modifier = Modifier 106 | .size(30.dp) 107 | .clip(CircleShape), 108 | model = image?.photographerProfileImgUrl, 109 | contentDescription = null 110 | ) 111 | Spacer(modifier = Modifier.width(10.dp)) 112 | Column( 113 | modifier = Modifier.clickable { 114 | image?.let { onPhotographerNameClick(it.photographerProfileLink) } 115 | } 116 | ) { 117 | Text( 118 | text = image?.photographerName ?: "", 119 | style = MaterialTheme.typography.titleMedium 120 | ) 121 | Text( 122 | text = image?.photographerUsername ?: "", 123 | style = MaterialTheme.typography.bodySmall 124 | ) 125 | } 126 | Spacer(modifier = Modifier.weight(1f)) 127 | IconButton(onClick = { onDownloadImgClick() }) { 128 | Icon( 129 | painter = painterResource(id = R.drawable.ic_download), 130 | contentDescription = "Download the image", 131 | tint = MaterialTheme.colorScheme.onBackground 132 | ) 133 | } 134 | } 135 | } 136 | } 137 | 138 | 139 | 140 | 141 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/component/ImageVistaLoadingBar.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation.component 2 | 3 | import androidx.compose.animation.core.RepeatMode 4 | import androidx.compose.animation.core.animateFloat 5 | import androidx.compose.animation.core.infiniteRepeatable 6 | import androidx.compose.animation.core.rememberInfiniteTransition 7 | import androidx.compose.animation.core.tween 8 | import androidx.compose.foundation.border 9 | import androidx.compose.foundation.layout.Box 10 | import androidx.compose.foundation.layout.size 11 | import androidx.compose.foundation.shape.CircleShape 12 | import androidx.compose.material3.MaterialTheme 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.getValue 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.draw.alpha 17 | import androidx.compose.ui.draw.scale 18 | import androidx.compose.ui.unit.Dp 19 | import androidx.compose.ui.unit.dp 20 | 21 | @Composable 22 | fun ImageVistaLoadingBar( 23 | modifier: Modifier = Modifier, 24 | size: Dp = 80.dp 25 | ) { 26 | val animation = rememberInfiniteTransition(label = "Loading Bar") 27 | val progress by animation.animateFloat( 28 | initialValue = 0f, 29 | targetValue = 1f, 30 | animationSpec = infiniteRepeatable( 31 | animation = tween(durationMillis = 1000), 32 | repeatMode = RepeatMode.Restart 33 | ), 34 | label = "" 35 | ) 36 | Box( 37 | modifier = modifier 38 | .size(size) 39 | .scale(progress) 40 | .alpha(1f - progress) 41 | .border( 42 | width = 5.dp, 43 | color = MaterialTheme.colorScheme.onBackground, 44 | shape = CircleShape 45 | ) 46 | ) 47 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/component/NetworkStatusBar.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation.component 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.animation.core.tween 5 | import androidx.compose.animation.slideInVertically 6 | import androidx.compose.animation.slideOutVertically 7 | import androidx.compose.foundation.background 8 | import androidx.compose.foundation.layout.Box 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.material3.MaterialTheme 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.graphics.Color 17 | import androidx.compose.ui.unit.dp 18 | 19 | @Composable 20 | fun NetworkStatusBar( 21 | modifier: Modifier = Modifier, 22 | showMessageBar: Boolean, 23 | message: String, 24 | backgroundColor: Color 25 | ) { 26 | AnimatedVisibility( 27 | modifier = modifier, 28 | visible = showMessageBar, 29 | enter = slideInVertically(animationSpec = tween(durationMillis = 600)) { h -> h }, 30 | exit = slideOutVertically(animationSpec = tween(durationMillis = 600)) { h -> h } 31 | ) { 32 | Box( 33 | modifier = Modifier 34 | .fillMaxWidth() 35 | .background(backgroundColor) 36 | .padding(4.dp), 37 | contentAlignment = Alignment.Center 38 | ) { 39 | Text( 40 | text = message, 41 | color = Color.White, 42 | style = MaterialTheme.typography.bodySmall 43 | ) 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/component/ZoomedImageCard.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation.component 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.animation.fadeIn 5 | import androidx.compose.animation.fadeOut 6 | import androidx.compose.animation.scaleIn 7 | import androidx.compose.animation.scaleOut 8 | import androidx.compose.foundation.layout.Box 9 | import androidx.compose.foundation.layout.Row 10 | import androidx.compose.foundation.layout.fillMaxSize 11 | import androidx.compose.foundation.layout.fillMaxWidth 12 | import androidx.compose.foundation.layout.padding 13 | import androidx.compose.foundation.layout.size 14 | import androidx.compose.foundation.shape.CircleShape 15 | import androidx.compose.material3.Card 16 | import androidx.compose.material3.MaterialTheme 17 | import androidx.compose.material3.Text 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.draw.clip 22 | import androidx.compose.ui.platform.LocalContext 23 | import androidx.compose.ui.unit.dp 24 | import coil.compose.AsyncImage 25 | import coil.memory.MemoryCache 26 | import coil.request.ImageRequest 27 | import com.example.imagevista.domain.model.UnsplashImage 28 | import com.skydoves.cloudy.Cloudy 29 | 30 | @Composable 31 | fun ZoomedImageCard( 32 | modifier: Modifier = Modifier, 33 | isVisible: Boolean, 34 | image: UnsplashImage? 35 | ) { 36 | val imageRequest = ImageRequest.Builder(LocalContext.current) 37 | .data(image?.imageUrlRegular) 38 | .crossfade(true) 39 | .placeholderMemoryCacheKey(MemoryCache.Key(image?.imageUrlSmall ?: "")) 40 | .build() 41 | Box( 42 | modifier = Modifier.fillMaxSize(), 43 | contentAlignment = Alignment.Center 44 | ) { 45 | if (isVisible) { 46 | Cloudy(modifier = Modifier.fillMaxSize(), radius = 25) {} 47 | } 48 | AnimatedVisibility( 49 | visible = isVisible, 50 | enter = scaleIn() + fadeIn(), 51 | exit = scaleOut() + fadeOut() 52 | ) { 53 | Card(modifier = modifier) { 54 | Row( 55 | modifier = Modifier.fillMaxWidth(), 56 | verticalAlignment = Alignment.CenterVertically 57 | ) { 58 | AsyncImage( 59 | modifier = Modifier 60 | .padding(10.dp) 61 | .clip(CircleShape) 62 | .size(25.dp), 63 | model = image?.photographerProfileImgUrl, 64 | contentDescription = null 65 | ) 66 | Text( 67 | text = image?.photographerName ?: "Anonymous", 68 | style = MaterialTheme.typography.labelLarge 69 | ) 70 | } 71 | AsyncImage( 72 | modifier = Modifier.fillMaxWidth(), 73 | model = imageRequest, 74 | contentDescription = null 75 | ) 76 | } 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/favorites_screen/FavoritesScreen.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation.favorites_screen 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.Spacer 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.height 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.material.icons.Icons 13 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 14 | import androidx.compose.material3.ExperimentalMaterial3Api 15 | import androidx.compose.material3.Icon 16 | import androidx.compose.material3.IconButton 17 | import androidx.compose.material3.MaterialTheme 18 | import androidx.compose.material3.SnackbarHostState 19 | import androidx.compose.material3.Text 20 | import androidx.compose.material3.TopAppBarScrollBehavior 21 | import androidx.compose.runtime.Composable 22 | import androidx.compose.runtime.LaunchedEffect 23 | import androidx.compose.runtime.getValue 24 | import androidx.compose.runtime.mutableStateOf 25 | import androidx.compose.runtime.remember 26 | import androidx.compose.runtime.setValue 27 | import androidx.compose.ui.Alignment 28 | import androidx.compose.ui.Modifier 29 | import androidx.compose.ui.res.painterResource 30 | import androidx.compose.ui.text.font.FontWeight 31 | import androidx.compose.ui.text.style.TextAlign 32 | import androidx.compose.ui.unit.dp 33 | import androidx.paging.compose.LazyPagingItems 34 | import com.example.imagevista.R 35 | import com.example.imagevista.domain.model.UnsplashImage 36 | import com.example.imagevista.presentation.component.ImageVistaTopAppBar 37 | import com.example.imagevista.presentation.component.ImagesVerticalGrid 38 | import com.example.imagevista.presentation.component.ZoomedImageCard 39 | import com.example.imagevista.presentation.util.SnackbarEvent 40 | import kotlinx.coroutines.flow.Flow 41 | 42 | @OptIn(ExperimentalMaterial3Api::class) 43 | @Composable 44 | fun FavoritesScreen( 45 | snackbarHostState: SnackbarHostState, 46 | favoriteImages: LazyPagingItems, 47 | snackbarEvent: Flow, 48 | scrollBehavior: TopAppBarScrollBehavior, 49 | onSearchClick: () -> Unit, 50 | favoriteImageIds: List, 51 | onBackClick: () -> Unit, 52 | onImageClick: (String) -> Unit, 53 | onToggleFavoriteStatus: (UnsplashImage) -> Unit 54 | ) { 55 | 56 | var showImagePreview by remember { mutableStateOf(false) } 57 | var activeImage by remember { mutableStateOf(null) } 58 | 59 | LaunchedEffect(key1 = true) { 60 | snackbarEvent.collect { event -> 61 | snackbarHostState.showSnackbar( 62 | message = event.message, 63 | duration = event.duration 64 | ) 65 | } 66 | } 67 | 68 | Box(modifier = Modifier.fillMaxSize()) { 69 | Column( 70 | modifier = Modifier.fillMaxSize(), 71 | horizontalAlignment = Alignment.CenterHorizontally 72 | ) { 73 | ImageVistaTopAppBar( 74 | title = "Favorite Images", 75 | scrollBehavior = scrollBehavior, 76 | onSearchClick = onSearchClick, 77 | navigationIcon = { 78 | IconButton(onClick = { onBackClick() }) { 79 | Icon( 80 | imageVector = Icons.AutoMirrored.Filled.ArrowBack, 81 | contentDescription = "Go Back" 82 | ) 83 | } 84 | } 85 | ) 86 | ImagesVerticalGrid( 87 | images = favoriteImages, 88 | onImageClick = onImageClick, 89 | favoriteImageIds = favoriteImageIds, 90 | onImageDragStart = { image -> 91 | activeImage = image 92 | showImagePreview = true 93 | }, 94 | onImageDragEnd = { showImagePreview = false }, 95 | onToggleFavoriteStatus = onToggleFavoriteStatus 96 | ) 97 | } 98 | ZoomedImageCard( 99 | modifier = Modifier.padding(20.dp), 100 | isVisible = showImagePreview, 101 | image = activeImage 102 | ) 103 | if (favoriteImages.itemCount == 0) { 104 | EmptyState( 105 | modifier = Modifier 106 | .fillMaxSize() 107 | .padding(16.dp) 108 | ) 109 | } 110 | } 111 | } 112 | 113 | @Composable 114 | private fun EmptyState(modifier: Modifier = Modifier) { 115 | Column( 116 | modifier = modifier, 117 | verticalArrangement = Arrangement.Center, 118 | horizontalAlignment = Alignment.CenterHorizontally 119 | ) { 120 | Image( 121 | modifier = Modifier.fillMaxWidth(), 122 | painter = painterResource(id = R.drawable.img_empty_bookmarks), 123 | contentDescription = null 124 | ) 125 | Spacer(modifier = Modifier.height(48.dp)) 126 | Text( 127 | text = "No Saved Images", 128 | modifier = Modifier.fillMaxWidth(), 129 | textAlign = TextAlign.Center, 130 | style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold) 131 | ) 132 | Spacer(modifier = Modifier.height(8.dp)) 133 | Text( 134 | text = "Images you save will be stored here", 135 | modifier = Modifier.fillMaxWidth(), 136 | textAlign = TextAlign.Center, 137 | style = MaterialTheme.typography.bodyMedium 138 | ) 139 | } 140 | } 141 | 142 | 143 | 144 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/favorites_screen/FavoritesViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation.favorites_screen 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import androidx.paging.PagingData 6 | import androidx.paging.cachedIn 7 | import com.example.imagevista.domain.model.UnsplashImage 8 | import com.example.imagevista.domain.repository.ImageRepository 9 | import com.example.imagevista.presentation.util.SnackbarEvent 10 | import dagger.hilt.android.lifecycle.HiltViewModel 11 | import kotlinx.coroutines.channels.Channel 12 | import kotlinx.coroutines.flow.SharingStarted 13 | import kotlinx.coroutines.flow.StateFlow 14 | import kotlinx.coroutines.flow.catch 15 | import kotlinx.coroutines.flow.receiveAsFlow 16 | import kotlinx.coroutines.flow.stateIn 17 | import kotlinx.coroutines.launch 18 | import javax.inject.Inject 19 | 20 | @HiltViewModel 21 | class FavoritesViewModel @Inject constructor( 22 | private val repository: ImageRepository 23 | ) : ViewModel() { 24 | 25 | private val _snackbarEvent = Channel() 26 | val snackbarEvent = _snackbarEvent.receiveAsFlow() 27 | 28 | val favoriteImages: StateFlow> = repository.getAllFavoriteImages() 29 | .catch { exception -> 30 | _snackbarEvent.send( 31 | SnackbarEvent(message = "Something went wrong. ${exception.message}") 32 | ) 33 | } 34 | .cachedIn(viewModelScope) 35 | .stateIn( 36 | scope = viewModelScope, 37 | started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), 38 | initialValue = PagingData.empty() 39 | ) 40 | 41 | val favoriteImageIds: StateFlow> = repository.getFavoriteImageIds() 42 | .catch { exception -> 43 | _snackbarEvent.send( 44 | SnackbarEvent(message = "Something went wrong. ${exception.message}") 45 | ) 46 | } 47 | .stateIn( 48 | scope = viewModelScope, 49 | started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), 50 | initialValue = emptyList() 51 | ) 52 | 53 | fun toggleFavoriteStatus(image: UnsplashImage) { 54 | viewModelScope.launch { 55 | try { 56 | repository.toggleFavoriteStatus(image) 57 | } catch (e: Exception) { 58 | _snackbarEvent.send( 59 | SnackbarEvent(message = "Something went wrong. ${e.message}") 60 | ) 61 | } 62 | } 63 | } 64 | 65 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/full_image_screen/FullImageScreen.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation.full_image_screen 2 | 3 | import android.widget.Toast 4 | import androidx.activity.compose.BackHandler 5 | import androidx.compose.foundation.ExperimentalFoundationApi 6 | import androidx.compose.foundation.Image 7 | import androidx.compose.foundation.combinedClickable 8 | import androidx.compose.foundation.gestures.animateZoomBy 9 | import androidx.compose.foundation.gestures.rememberTransformableState 10 | import androidx.compose.foundation.gestures.transformable 11 | import androidx.compose.foundation.interaction.MutableInteractionSource 12 | import androidx.compose.foundation.layout.Box 13 | import androidx.compose.foundation.layout.BoxWithConstraints 14 | import androidx.compose.foundation.layout.fillMaxSize 15 | import androidx.compose.foundation.layout.fillMaxWidth 16 | import androidx.compose.foundation.layout.padding 17 | import androidx.compose.material3.ExperimentalMaterial3Api 18 | import androidx.compose.material3.SnackbarHostState 19 | import androidx.compose.material3.rememberModalBottomSheetState 20 | import androidx.compose.runtime.Composable 21 | import androidx.compose.runtime.LaunchedEffect 22 | import androidx.compose.runtime.derivedStateOf 23 | import androidx.compose.runtime.getValue 24 | import androidx.compose.runtime.mutableFloatStateOf 25 | import androidx.compose.runtime.mutableStateOf 26 | import androidx.compose.runtime.remember 27 | import androidx.compose.runtime.rememberCoroutineScope 28 | import androidx.compose.runtime.saveable.rememberSaveable 29 | import androidx.compose.runtime.setValue 30 | import androidx.compose.ui.Alignment 31 | import androidx.compose.ui.Modifier 32 | import androidx.compose.ui.geometry.Offset 33 | import androidx.compose.ui.graphics.graphicsLayer 34 | import androidx.compose.ui.platform.LocalContext 35 | import androidx.compose.ui.res.painterResource 36 | import androidx.compose.ui.unit.dp 37 | import coil.compose.AsyncImagePainter 38 | import coil.compose.rememberAsyncImagePainter 39 | import com.example.imagevista.R 40 | import com.example.imagevista.domain.model.UnsplashImage 41 | import com.example.imagevista.presentation.component.DownloadOptionsBottomSheet 42 | import com.example.imagevista.presentation.component.FullImageViewTopBar 43 | import com.example.imagevista.presentation.component.ImageDownloadOption 44 | import com.example.imagevista.presentation.component.ImageVistaLoadingBar 45 | import com.example.imagevista.presentation.util.SnackbarEvent 46 | import com.example.imagevista.presentation.util.rememberWindowInsetsController 47 | import com.example.imagevista.presentation.util.toggleStatusBars 48 | import kotlinx.coroutines.flow.Flow 49 | import kotlinx.coroutines.launch 50 | import kotlin.math.max 51 | 52 | @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) 53 | @Composable 54 | fun FullImageScreen( 55 | snackbarHostState: SnackbarHostState, 56 | snackbarEvent: Flow, 57 | image: UnsplashImage?, 58 | onBackClick: () -> Unit, 59 | onPhotographerNameClick: (String) -> Unit, 60 | onImageDownloadClick: (String, String?) -> Unit 61 | ) { 62 | val context = LocalContext.current 63 | val scope = rememberCoroutineScope() 64 | var showBars by rememberSaveable { mutableStateOf(false) } 65 | val windowInsetsController = rememberWindowInsetsController() 66 | 67 | val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) 68 | var isDownloadBottomSheetOpen by remember { mutableStateOf(false) } 69 | 70 | LaunchedEffect(key1 = true) { 71 | snackbarEvent.collect { event -> 72 | snackbarHostState.showSnackbar( 73 | message = event.message, 74 | duration = event.duration 75 | ) 76 | } 77 | } 78 | 79 | LaunchedEffect(key1 = Unit) { 80 | windowInsetsController.toggleStatusBars(show = showBars) 81 | } 82 | 83 | BackHandler(enabled = !showBars) { 84 | windowInsetsController.toggleStatusBars(show = true) 85 | onBackClick() 86 | } 87 | 88 | DownloadOptionsBottomSheet( 89 | isOpen = isDownloadBottomSheetOpen, 90 | sheetState = sheetState, 91 | onDismissRequest = { isDownloadBottomSheetOpen = false }, 92 | onOptionClick = { option -> 93 | scope.launch { sheetState.hide() }.invokeOnCompletion { 94 | if (!sheetState.isVisible) isDownloadBottomSheetOpen = false 95 | } 96 | val url = when (option) { 97 | ImageDownloadOption.SMALL -> image?.imageUrlSmall 98 | ImageDownloadOption.MEDIUM -> image?.imageUrlRegular 99 | ImageDownloadOption.ORIGINAL -> image?.imageUrlRaw 100 | } 101 | url?.let { 102 | onImageDownloadClick(it, image?.description?.take(20)) 103 | Toast.makeText(context, "Downloading...", Toast.LENGTH_SHORT).show() 104 | } 105 | } 106 | ) 107 | 108 | Box( 109 | modifier = Modifier.fillMaxSize() 110 | ) { 111 | BoxWithConstraints( 112 | modifier = Modifier.fillMaxSize(), 113 | contentAlignment = Alignment.Center 114 | ) { 115 | var scale by remember { mutableFloatStateOf(1f) } 116 | var offset by remember { mutableStateOf(Offset.Zero) } 117 | val isImageZoomed: Boolean by remember { derivedStateOf { scale != 1f } } 118 | val transformState = rememberTransformableState { zoomChange, offsetChange, _ -> 119 | scale = max(scale * zoomChange, 1f) 120 | val maxX = (constraints.maxWidth * (scale - 1)) / 2 121 | val maxY = (constraints.maxHeight * (scale - 1)) / 2 122 | offset = Offset( 123 | x = (offset.x + offsetChange.x).coerceIn(-maxX, maxX), 124 | y = (offset.y + offsetChange.y).coerceIn(-maxY, maxY) 125 | ) 126 | } 127 | 128 | var isLoading by remember { mutableStateOf(true) } 129 | var isError by remember { mutableStateOf(false) } 130 | val imageLoader = rememberAsyncImagePainter( 131 | model = image?.imageUrlRaw, 132 | onState = { imageState -> 133 | isLoading = imageState is AsyncImagePainter.State.Loading 134 | isError = imageState is AsyncImagePainter.State.Error 135 | } 136 | ) 137 | if (isLoading) { 138 | ImageVistaLoadingBar() 139 | } 140 | Image( 141 | painter = if (isError.not()) imageLoader else painterResource(id = R.drawable.ic_error), 142 | contentDescription = null, 143 | modifier = Modifier 144 | .fillMaxSize() 145 | .transformable(transformState) 146 | .combinedClickable( 147 | onDoubleClick = { 148 | if (isImageZoomed) { 149 | scale = 1f 150 | offset = Offset.Zero 151 | } else { 152 | scope.launch { transformState.animateZoomBy(zoomFactor = 3f) } 153 | } 154 | }, 155 | onClick = { 156 | showBars = !showBars 157 | windowInsetsController.toggleStatusBars(show = showBars) 158 | }, 159 | indication = null, 160 | interactionSource = remember { MutableInteractionSource() } 161 | ) 162 | .graphicsLayer { 163 | scaleX = scale 164 | scaleY = scale 165 | translationX = offset.x 166 | translationY = offset.y 167 | } 168 | ) 169 | } 170 | FullImageViewTopBar( 171 | modifier = Modifier 172 | .align(Alignment.TopCenter) 173 | .fillMaxWidth() 174 | .padding(horizontal = 5.dp, vertical = 40.dp), 175 | image = image, 176 | isVisible = showBars, 177 | onBackClick = onBackClick, 178 | onPhotographerNameClick = onPhotographerNameClick, 179 | onDownloadImgClick = { isDownloadBottomSheetOpen = true } 180 | ) 181 | } 182 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/full_image_screen/FullImageViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation.full_image_screen 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import androidx.lifecycle.SavedStateHandle 7 | import androidx.lifecycle.ViewModel 8 | import androidx.lifecycle.viewModelScope 9 | import androidx.navigation.toRoute 10 | import com.example.imagevista.domain.model.UnsplashImage 11 | import com.example.imagevista.domain.repository.Downloader 12 | import com.example.imagevista.domain.repository.ImageRepository 13 | import com.example.imagevista.presentation.navigation.Routes 14 | import com.example.imagevista.presentation.util.SnackbarEvent 15 | import dagger.hilt.android.lifecycle.HiltViewModel 16 | import kotlinx.coroutines.channels.Channel 17 | import kotlinx.coroutines.flow.receiveAsFlow 18 | import kotlinx.coroutines.launch 19 | import java.net.UnknownHostException 20 | import javax.inject.Inject 21 | 22 | @HiltViewModel 23 | class FullImageViewModel @Inject constructor( 24 | private val repository: ImageRepository, 25 | private val downloader: Downloader, 26 | savedStateHandle: SavedStateHandle 27 | ) : ViewModel() { 28 | 29 | private val imageId = savedStateHandle.toRoute().imageId 30 | 31 | private val _snackbarEvent = Channel() 32 | val snackbarEvent = _snackbarEvent.receiveAsFlow() 33 | 34 | var image: UnsplashImage? by mutableStateOf(null) 35 | private set 36 | 37 | init { 38 | getImage() 39 | } 40 | 41 | private fun getImage() { 42 | viewModelScope.launch { 43 | try { 44 | val result = repository.getImage(imageId) 45 | image = result 46 | } catch (e: UnknownHostException) { 47 | _snackbarEvent.send( 48 | SnackbarEvent(message = "No Internet connection. Please check you network.") 49 | ) 50 | } catch (e: Exception) { 51 | _snackbarEvent.send( 52 | SnackbarEvent(message = "Something went wrong: ${e.message}") 53 | ) 54 | } 55 | } 56 | } 57 | 58 | fun downloadImage(url: String, title: String?) { 59 | viewModelScope.launch { 60 | try { 61 | downloader.downloadFile(url, title) 62 | } catch (e: Exception) { 63 | _snackbarEvent.send( 64 | SnackbarEvent(message = "Something went wrong: ${e.message}") 65 | ) 66 | } 67 | } 68 | } 69 | 70 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/home_screen/HomeScreen.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation.home_screen 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material3.ExperimentalMaterial3Api 8 | import androidx.compose.material3.FloatingActionButton 9 | import androidx.compose.material3.Icon 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.SnackbarHostState 12 | import androidx.compose.material3.TopAppBarScrollBehavior 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.LaunchedEffect 15 | import androidx.compose.runtime.getValue 16 | import androidx.compose.runtime.mutableStateOf 17 | import androidx.compose.runtime.remember 18 | import androidx.compose.runtime.setValue 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.res.painterResource 22 | import androidx.compose.ui.unit.dp 23 | import androidx.paging.compose.LazyPagingItems 24 | import com.example.imagevista.R 25 | import com.example.imagevista.domain.model.UnsplashImage 26 | import com.example.imagevista.presentation.component.ImageVistaTopAppBar 27 | import com.example.imagevista.presentation.component.ImagesVerticalGrid 28 | import com.example.imagevista.presentation.component.ZoomedImageCard 29 | import com.example.imagevista.presentation.util.SnackbarEvent 30 | import kotlinx.coroutines.flow.Flow 31 | 32 | @OptIn(ExperimentalMaterial3Api::class) 33 | @Composable 34 | fun HomeScreen( 35 | snackbarHostState: SnackbarHostState, 36 | snackbarEvent: Flow, 37 | scrollBehavior: TopAppBarScrollBehavior, 38 | images: LazyPagingItems, 39 | favoriteImageIds: List, 40 | onImageClick: (String) -> Unit, 41 | onSearchClick: () -> Unit, 42 | onFABClick: () -> Unit, 43 | onToggleFavoriteStatus: (UnsplashImage) -> Unit 44 | ) { 45 | 46 | var showImagePreview by remember { mutableStateOf(false) } 47 | var activeImage by remember { mutableStateOf(null) } 48 | 49 | LaunchedEffect(key1 = true) { 50 | snackbarEvent.collect { event -> 51 | snackbarHostState.showSnackbar( 52 | message = event.message, 53 | duration = event.duration 54 | ) 55 | } 56 | } 57 | 58 | Box(modifier = Modifier.fillMaxSize()) { 59 | Column( 60 | modifier = Modifier.fillMaxSize(), 61 | horizontalAlignment = Alignment.CenterHorizontally 62 | ) { 63 | ImageVistaTopAppBar( 64 | scrollBehavior = scrollBehavior, 65 | onSearchClick = onSearchClick 66 | ) 67 | ImagesVerticalGrid( 68 | images = images, 69 | onImageClick = onImageClick, 70 | favoriteImageIds = favoriteImageIds, 71 | onImageDragStart = { image -> 72 | activeImage = image 73 | showImagePreview = true 74 | }, 75 | onImageDragEnd = { showImagePreview = false }, 76 | onToggleFavoriteStatus = onToggleFavoriteStatus 77 | ) 78 | } 79 | FloatingActionButton( 80 | modifier = Modifier 81 | .align(Alignment.BottomEnd) 82 | .padding(24.dp), 83 | onClick = { onFABClick() } 84 | ) { 85 | Icon( 86 | painter = painterResource(R.drawable.ic_save), 87 | contentDescription = "Favorites", 88 | tint = MaterialTheme.colorScheme.onBackground 89 | ) 90 | } 91 | ZoomedImageCard( 92 | modifier = Modifier.padding(20.dp), 93 | isVisible = showImagePreview, 94 | image = activeImage 95 | ) 96 | } 97 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/home_screen/HomeViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation.home_screen 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import androidx.paging.PagingData 9 | import androidx.paging.cachedIn 10 | import com.example.imagevista.domain.model.UnsplashImage 11 | import com.example.imagevista.domain.repository.ImageRepository 12 | import com.example.imagevista.presentation.util.SnackbarEvent 13 | import dagger.hilt.android.lifecycle.HiltViewModel 14 | import kotlinx.coroutines.channels.Channel 15 | import kotlinx.coroutines.flow.SharingStarted 16 | import kotlinx.coroutines.flow.StateFlow 17 | import kotlinx.coroutines.flow.catch 18 | import kotlinx.coroutines.flow.receiveAsFlow 19 | import kotlinx.coroutines.flow.stateIn 20 | import kotlinx.coroutines.launch 21 | import java.net.UnknownHostException 22 | import javax.inject.Inject 23 | 24 | @HiltViewModel 25 | class HomeViewModel @Inject constructor( 26 | private val repository: ImageRepository 27 | ) : ViewModel() { 28 | 29 | private val _snackbarEvent = Channel() 30 | val snackbarEvent = _snackbarEvent.receiveAsFlow() 31 | 32 | val images: StateFlow> = repository.getEditorialFeedImages() 33 | .catch { exception -> 34 | _snackbarEvent.send( 35 | SnackbarEvent(message = "Something went wrong. ${exception.message}") 36 | ) 37 | } 38 | .cachedIn(viewModelScope) 39 | .stateIn( 40 | scope = viewModelScope, 41 | started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), 42 | initialValue = PagingData.empty() 43 | ) 44 | 45 | val favoriteImageIds: StateFlow> = repository.getFavoriteImageIds() 46 | .catch { exception -> 47 | _snackbarEvent.send( 48 | SnackbarEvent(message = "Something went wrong. ${exception.message}") 49 | ) 50 | } 51 | .stateIn( 52 | scope = viewModelScope, 53 | started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), 54 | initialValue = emptyList() 55 | ) 56 | 57 | fun toggleFavoriteStatus(image: UnsplashImage) { 58 | viewModelScope.launch { 59 | try { 60 | repository.toggleFavoriteStatus(image) 61 | } catch (e: Exception) { 62 | _snackbarEvent.send( 63 | SnackbarEvent(message = "Something went wrong. ${e.message}") 64 | ) 65 | } 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/navigation/NavGraph.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation.navigation 2 | 3 | import androidx.compose.material3.ExperimentalMaterial3Api 4 | import androidx.compose.material3.SnackbarHostState 5 | import androidx.compose.material3.TopAppBarScrollBehavior 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.getValue 8 | import androidx.hilt.navigation.compose.hiltViewModel 9 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 10 | import androidx.navigation.NavHostController 11 | import androidx.navigation.compose.NavHost 12 | import androidx.navigation.compose.composable 13 | import androidx.navigation.toRoute 14 | import androidx.paging.compose.collectAsLazyPagingItems 15 | import com.example.imagevista.presentation.favorites_screen.FavoritesScreen 16 | import com.example.imagevista.presentation.favorites_screen.FavoritesViewModel 17 | import com.example.imagevista.presentation.full_image_screen.FullImageScreen 18 | import com.example.imagevista.presentation.full_image_screen.FullImageViewModel 19 | import com.example.imagevista.presentation.home_screen.HomeScreen 20 | import com.example.imagevista.presentation.home_screen.HomeViewModel 21 | import com.example.imagevista.presentation.profile_screen.ProfileScreen 22 | import com.example.imagevista.presentation.search_screen.SearchScreen 23 | import com.example.imagevista.presentation.search_screen.SearchViewModel 24 | 25 | @OptIn(ExperimentalMaterial3Api::class) 26 | @Composable 27 | fun NavGraphSetup( 28 | navController: NavHostController, 29 | scrollBehavior: TopAppBarScrollBehavior, 30 | snackbarHostState: SnackbarHostState, 31 | searchQuery: String, 32 | onSearchQueryChange: (String) -> Unit, 33 | ) { 34 | NavHost( 35 | navController = navController, 36 | startDestination = Routes.HomeScreen 37 | ) { 38 | composable { 39 | val homeViewModel: HomeViewModel = hiltViewModel() 40 | val images = homeViewModel.images.collectAsLazyPagingItems() 41 | val favoriteImageIds by homeViewModel.favoriteImageIds.collectAsStateWithLifecycle() 42 | HomeScreen( 43 | snackbarHostState = snackbarHostState, 44 | snackbarEvent = homeViewModel.snackbarEvent, 45 | scrollBehavior = scrollBehavior, 46 | images = images, 47 | favoriteImageIds = favoriteImageIds, 48 | onImageClick = { imageId -> 49 | navController.navigate(Routes.FullImageScreen(imageId)) 50 | }, 51 | onSearchClick = { navController.navigate(Routes.SearchScreen) }, 52 | onFABClick = { navController.navigate(Routes.FavoritesScreen) }, 53 | onToggleFavoriteStatus = { homeViewModel.toggleFavoriteStatus(it) } 54 | ) 55 | } 56 | composable { 57 | val searchViewModel: SearchViewModel = hiltViewModel() 58 | val searchedImages = searchViewModel.searchImages.collectAsLazyPagingItems() 59 | val favoriteImageIds by searchViewModel.favoriteImageIds.collectAsStateWithLifecycle() 60 | SearchScreen( 61 | snackbarHostState = snackbarHostState, 62 | snackbarEvent = searchViewModel.snackbarEvent, 63 | searchedImages = searchedImages, 64 | favoriteImageIds = favoriteImageIds, 65 | searchQuery = searchQuery, 66 | onSearchQueryChange = onSearchQueryChange, 67 | onBackClick = { navController.navigateUp() }, 68 | onImageClick = { imageId -> 69 | navController.navigate(Routes.FullImageScreen(imageId)) 70 | }, 71 | onSearch = { searchViewModel.searchImages(it) }, 72 | onToggleFavoriteStatus = { searchViewModel.toggleFavoriteStatus(it) } 73 | ) 74 | } 75 | composable { 76 | val favoritesViewModel: FavoritesViewModel = hiltViewModel() 77 | val favoriteImages = favoritesViewModel.favoriteImages.collectAsLazyPagingItems() 78 | val favoriteImageIds by favoritesViewModel.favoriteImageIds.collectAsStateWithLifecycle() 79 | FavoritesScreen( 80 | snackbarHostState = snackbarHostState, 81 | favoriteImages = favoriteImages, 82 | snackbarEvent = favoritesViewModel.snackbarEvent, 83 | scrollBehavior = scrollBehavior, 84 | onSearchClick = { navController.navigate(Routes.SearchScreen) }, 85 | favoriteImageIds = favoriteImageIds, 86 | onBackClick = { navController.navigateUp() }, 87 | onImageClick = { imageId -> 88 | navController.navigate(Routes.FullImageScreen(imageId)) 89 | }, 90 | onToggleFavoriteStatus = { favoritesViewModel.toggleFavoriteStatus(it) } 91 | ) 92 | } 93 | composable { 94 | val fullImageViewModel: FullImageViewModel = hiltViewModel() 95 | FullImageScreen( 96 | snackbarHostState = snackbarHostState, 97 | snackbarEvent = fullImageViewModel.snackbarEvent, 98 | image = fullImageViewModel.image, 99 | onBackClick = { navController.navigateUp() }, 100 | onPhotographerNameClick = { profileLink -> 101 | navController.navigate(Routes.ProfileScreen(profileLink)) 102 | }, 103 | onImageDownloadClick = { url, title -> 104 | fullImageViewModel.downloadImage(url, title) 105 | } 106 | ) 107 | } 108 | composable { backStackEntry -> 109 | val profileLink = backStackEntry.toRoute().profileLink 110 | ProfileScreen( 111 | profileLink = profileLink, 112 | onBackClick = { navController.navigateUp() } 113 | ) 114 | } 115 | } 116 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/navigation/Routes.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation.navigation 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | sealed class Routes { 7 | @Serializable 8 | data object HomeScreen : Routes() 9 | @Serializable 10 | data object SearchScreen : Routes() 11 | @Serializable 12 | data object FavoritesScreen : Routes() 13 | @Serializable 14 | data class FullImageScreen(val imageId: String) : Routes() 15 | @Serializable 16 | data class ProfileScreen(val profileLink: String) : Routes() 17 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/profile_screen/ProfileScreen.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation.profile_screen 2 | 3 | import android.webkit.WebView 4 | import android.webkit.WebViewClient 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.material.icons.Icons 9 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 10 | import androidx.compose.material3.CircularProgressIndicator 11 | import androidx.compose.material3.ExperimentalMaterial3Api 12 | import androidx.compose.material3.Icon 13 | import androidx.compose.material3.IconButton 14 | import androidx.compose.material3.Text 15 | import androidx.compose.material3.TopAppBar 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.runtime.getValue 18 | import androidx.compose.runtime.mutableStateOf 19 | import androidx.compose.runtime.saveable.rememberSaveable 20 | import androidx.compose.runtime.setValue 21 | import androidx.compose.ui.Alignment 22 | import androidx.compose.ui.Modifier 23 | import androidx.compose.ui.platform.LocalContext 24 | import androidx.compose.ui.viewinterop.AndroidView 25 | 26 | @OptIn(ExperimentalMaterial3Api::class) 27 | @Composable 28 | fun ProfileScreen( 29 | profileLink: String, 30 | onBackClick: () -> Unit 31 | ) { 32 | val context = LocalContext.current 33 | var isLoading by rememberSaveable { mutableStateOf(true) } 34 | Column(modifier = Modifier.fillMaxSize()) { 35 | TopAppBar( 36 | title = { Text(text = "Profile") }, 37 | navigationIcon = { 38 | IconButton(onClick = onBackClick) { 39 | Icon( 40 | imageVector = Icons.AutoMirrored.Filled.ArrowBack, 41 | contentDescription = null 42 | ) 43 | } 44 | } 45 | ) 46 | Box( 47 | modifier = Modifier.fillMaxSize(), 48 | contentAlignment = Alignment.Center 49 | ) { 50 | AndroidView( 51 | factory = { 52 | WebView(context).apply { 53 | webViewClient = object : WebViewClient() { 54 | override fun onPageFinished(view: WebView?, url: String?) { 55 | super.onPageFinished(view, url) 56 | isLoading = false 57 | } 58 | } 59 | loadUrl(profileLink) 60 | } 61 | } 62 | ) 63 | if (isLoading) { 64 | CircularProgressIndicator() 65 | } 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/search_screen/SearchScreen.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation.search_screen 2 | 3 | import android.util.Log 4 | import androidx.compose.animation.AnimatedVisibility 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.PaddingValues 9 | import androidx.compose.foundation.layout.fillMaxSize 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.lazy.LazyRow 12 | import androidx.compose.foundation.lazy.items 13 | import androidx.compose.material.icons.Icons 14 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 15 | import androidx.compose.material.icons.filled.ArrowBack 16 | import androidx.compose.material.icons.filled.Close 17 | import androidx.compose.material.icons.filled.Search 18 | import androidx.compose.material3.ExperimentalMaterial3Api 19 | import androidx.compose.material3.FloatingActionButton 20 | import androidx.compose.material3.Icon 21 | import androidx.compose.material3.IconButton 22 | import androidx.compose.material3.MaterialTheme 23 | import androidx.compose.material3.SearchBar 24 | import androidx.compose.material3.SnackbarHostState 25 | import androidx.compose.material3.SuggestionChip 26 | import androidx.compose.material3.SuggestionChipDefaults 27 | import androidx.compose.material3.Text 28 | import androidx.compose.runtime.Composable 29 | import androidx.compose.runtime.LaunchedEffect 30 | import androidx.compose.runtime.getValue 31 | import androidx.compose.runtime.mutableStateOf 32 | import androidx.compose.runtime.remember 33 | import androidx.compose.runtime.setValue 34 | import androidx.compose.ui.Alignment 35 | import androidx.compose.ui.Modifier 36 | import androidx.compose.ui.focus.FocusRequester 37 | import androidx.compose.ui.focus.focusRequester 38 | import androidx.compose.ui.focus.onFocusChanged 39 | import androidx.compose.ui.platform.LocalFocusManager 40 | import androidx.compose.ui.platform.LocalSoftwareKeyboardController 41 | import androidx.compose.ui.res.painterResource 42 | import androidx.compose.ui.unit.dp 43 | import androidx.paging.compose.LazyPagingItems 44 | import com.example.imagevista.R 45 | import com.example.imagevista.data.util.Constants 46 | import com.example.imagevista.domain.model.UnsplashImage 47 | import com.example.imagevista.presentation.component.ImageVistaTopAppBar 48 | import com.example.imagevista.presentation.component.ImagesVerticalGrid 49 | import com.example.imagevista.presentation.component.ZoomedImageCard 50 | import com.example.imagevista.presentation.util.SnackbarEvent 51 | import com.example.imagevista.presentation.util.searchKeywords 52 | import kotlinx.coroutines.delay 53 | import kotlinx.coroutines.flow.Flow 54 | 55 | @OptIn(ExperimentalMaterial3Api::class) 56 | @Composable 57 | fun SearchScreen( 58 | snackbarHostState: SnackbarHostState, 59 | searchedImages: LazyPagingItems, 60 | snackbarEvent: Flow, 61 | favoriteImageIds: List, 62 | searchQuery: String, 63 | onSearchQueryChange: (String) -> Unit, 64 | onBackClick: () -> Unit, 65 | onSearch: (String) -> Unit, 66 | onImageClick: (String) -> Unit, 67 | onToggleFavoriteStatus: (UnsplashImage) -> Unit 68 | ) { 69 | val focusRequester = remember { FocusRequester() } 70 | val focusManager = LocalFocusManager.current 71 | val keyboardController = LocalSoftwareKeyboardController.current 72 | 73 | var isSuggestionChipsVisible by remember { mutableStateOf(false) } 74 | 75 | Log.d(Constants.IV_LOG_TAG, "searchedImagesCount: ${searchedImages.itemCount}") 76 | 77 | var showImagePreview by remember { mutableStateOf(false) } 78 | var activeImage by remember { mutableStateOf(null) } 79 | 80 | LaunchedEffect(key1 = true) { 81 | snackbarEvent.collect { event -> 82 | snackbarHostState.showSnackbar( 83 | message = event.message, 84 | duration = event.duration 85 | ) 86 | } 87 | } 88 | 89 | LaunchedEffect(key1 = Unit) { 90 | delay(500) 91 | focusRequester.requestFocus() 92 | } 93 | 94 | Box(modifier = Modifier.fillMaxSize()) { 95 | Column( 96 | modifier = Modifier.fillMaxSize(), 97 | horizontalAlignment = Alignment.CenterHorizontally 98 | ) { 99 | SearchBar( 100 | modifier = Modifier 101 | .padding(vertical = 10.dp) 102 | .focusRequester(focusRequester) 103 | .onFocusChanged { isSuggestionChipsVisible = it.isFocused }, 104 | query = searchQuery, 105 | onQueryChange = { onSearchQueryChange(it) }, 106 | onSearch = { 107 | onSearch(searchQuery) 108 | keyboardController?.hide() 109 | focusManager.clearFocus() 110 | }, 111 | placeholder = { Text(text = "Search...") }, 112 | leadingIcon = { 113 | Icon(imageVector = Icons.Filled.Search, contentDescription = "Search") 114 | }, 115 | trailingIcon = { 116 | IconButton( 117 | onClick = { 118 | if (searchQuery.isNotEmpty()) onSearchQueryChange("") 119 | else onBackClick() 120 | } 121 | ) { 122 | Icon(imageVector = Icons.Filled.Close, contentDescription = "Close") 123 | } 124 | }, 125 | active = false, 126 | onActiveChange = {}, 127 | content = {} 128 | ) 129 | AnimatedVisibility(visible = isSuggestionChipsVisible) { 130 | LazyRow( 131 | contentPadding = PaddingValues(horizontal = 10.dp), 132 | horizontalArrangement = Arrangement.spacedBy(10.dp) 133 | ) { 134 | items(searchKeywords) { keyword -> 135 | SuggestionChip( 136 | onClick = { 137 | onSearch(keyword) 138 | onSearchQueryChange(keyword) 139 | keyboardController?.hide() 140 | focusManager.clearFocus() 141 | }, 142 | label = { Text(text = keyword) }, 143 | colors = SuggestionChipDefaults.suggestionChipColors( 144 | containerColor = MaterialTheme.colorScheme.primaryContainer, 145 | labelColor = MaterialTheme.colorScheme.onPrimaryContainer 146 | ) 147 | ) 148 | } 149 | } 150 | } 151 | ImagesVerticalGrid( 152 | images = searchedImages, 153 | onImageClick = onImageClick, 154 | favoriteImageIds = favoriteImageIds, 155 | onImageDragStart = { image -> 156 | activeImage = image 157 | showImagePreview = true 158 | }, 159 | onImageDragEnd = { showImagePreview = false }, 160 | onToggleFavoriteStatus = onToggleFavoriteStatus 161 | ) 162 | } 163 | ZoomedImageCard( 164 | modifier = Modifier.padding(20.dp), 165 | isVisible = showImagePreview, 166 | image = activeImage 167 | ) 168 | } 169 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/search_screen/SearchViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation.search_screen 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import androidx.paging.PagingData 9 | import androidx.paging.cachedIn 10 | import com.example.imagevista.domain.model.UnsplashImage 11 | import com.example.imagevista.domain.repository.ImageRepository 12 | import com.example.imagevista.presentation.util.SnackbarEvent 13 | import dagger.hilt.android.lifecycle.HiltViewModel 14 | import kotlinx.coroutines.channels.Channel 15 | import kotlinx.coroutines.flow.MutableStateFlow 16 | import kotlinx.coroutines.flow.SharingStarted 17 | import kotlinx.coroutines.flow.StateFlow 18 | import kotlinx.coroutines.flow.catch 19 | import kotlinx.coroutines.flow.receiveAsFlow 20 | import kotlinx.coroutines.flow.stateIn 21 | import kotlinx.coroutines.launch 22 | import java.net.UnknownHostException 23 | import javax.inject.Inject 24 | 25 | @HiltViewModel 26 | class SearchViewModel @Inject constructor( 27 | private val repository: ImageRepository 28 | ) : ViewModel() { 29 | 30 | private val _snackbarEvent = Channel() 31 | val snackbarEvent = _snackbarEvent.receiveAsFlow() 32 | 33 | private val _searchImages = MutableStateFlow>(PagingData.empty()) 34 | val searchImages = _searchImages 35 | 36 | fun searchImages(query: String) { 37 | viewModelScope.launch { 38 | try { 39 | repository 40 | .searchImages(query) 41 | .cachedIn(viewModelScope) 42 | .collect { _searchImages.value = it} 43 | } catch (e: Exception) { 44 | _snackbarEvent.send( 45 | SnackbarEvent(message = "Something went wrong. ${e.message}") 46 | ) 47 | } 48 | } 49 | } 50 | 51 | val favoriteImageIds: StateFlow> = repository.getFavoriteImageIds() 52 | .catch { exception -> 53 | _snackbarEvent.send( 54 | SnackbarEvent(message = "Something went wrong. ${exception.message}") 55 | ) 56 | } 57 | .stateIn( 58 | scope = viewModelScope, 59 | started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), 60 | initialValue = emptyList() 61 | ) 62 | 63 | fun toggleFavoriteStatus(image: UnsplashImage) { 64 | viewModelScope.launch { 65 | try { 66 | repository.toggleFavoriteStatus(image) 67 | } catch (e: Exception) { 68 | _snackbarEvent.send( 69 | SnackbarEvent(message = "Something went wrong. ${e.message}") 70 | ) 71 | } 72 | } 73 | } 74 | 75 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val primaryLight = Color(0xFF246488) 6 | val onPrimaryLight = Color(0xFFFFFFFF) 7 | val primaryContainerLight = Color(0xFFC8E6FF) 8 | val onPrimaryContainerLight = Color(0xFF001E2F) 9 | val secondaryLight = Color(0xFF8F4A4E) 10 | val onSecondaryLight = Color(0xFFFFFFFF) 11 | val secondaryContainerLight = Color(0xFFFFDADA) 12 | val onSecondaryContainerLight = Color(0xFF3B080F) 13 | val tertiaryLight = Color(0xFF64597C) 14 | val onTertiaryLight = Color(0xFFFFFFFF) 15 | val tertiaryContainerLight = Color(0xFFE9DDFF) 16 | val onTertiaryContainerLight = Color(0xFF1F1635) 17 | val errorLight = Color(0xFFBA1A1A) 18 | val onErrorLight = Color(0xFFFFFFFF) 19 | val errorContainerLight = Color(0xFFFFDAD6) 20 | val onErrorContainerLight = Color(0xFF410002) 21 | val backgroundLight = Color(0xFFF6F9FE) 22 | val onBackgroundLight = Color(0xFF181C20) 23 | val surfaceLight = Color(0xFFF6F9FE) 24 | val onSurfaceLight = Color(0xFF181C20) 25 | val surfaceVariantLight = Color(0xFFDDE3EA) 26 | val onSurfaceVariantLight = Color(0xFF41474D) 27 | val outlineLight = Color(0xFF71787E) 28 | val outlineVariantLight = Color(0xFFC1C7CE) 29 | val scrimLight = Color(0xFF000000) 30 | val inverseSurfaceLight = Color(0xFF2D3135) 31 | val inverseOnSurfaceLight = Color(0xFFEEF1F6) 32 | val inversePrimaryLight = Color(0xFF94CDF7) 33 | 34 | val primaryDark = Color(0xFF94CDF7) 35 | val onPrimaryDark = Color(0xFF00344D) 36 | val primaryContainerDark = Color(0xFF004C6E) 37 | val onPrimaryContainerDark = Color(0xFFC8E6FF) 38 | val secondaryDark = Color(0xFFFFB3B5) 39 | val onSecondaryDark = Color(0xFF561D22) 40 | val secondaryContainerDark = Color(0xFF723337) 41 | val onSecondaryContainerDark = Color(0xFFFFDADA) 42 | val tertiaryDark = Color(0xFFCEC0E8) 43 | val onTertiaryDark = Color(0xFF352B4B) 44 | val tertiaryContainerDark = Color(0xFF4B4163) 45 | val onTertiaryContainerDark = Color(0xFFE9DDFF) 46 | val errorDark = Color(0xFFFFB4AB) 47 | val onErrorDark = Color(0xFF690005) 48 | val errorContainerDark = Color(0xFF93000A) 49 | val onErrorContainerDark = Color(0xFFFFDAD6) 50 | val backgroundDark = Color(0xFF101417) 51 | val onBackgroundDark = Color(0xFFDFE3E8) 52 | val surfaceDark = Color(0xFF101417) 53 | val onSurfaceDark = Color(0xFFDFE3E8) 54 | val surfaceVariantDark = Color(0xFF41474D) 55 | val onSurfaceVariantDark = Color(0xFFC1C7CE) 56 | val outlineDark = Color(0xFF8B9198) 57 | val outlineVariantDark = Color(0xFF41474D) 58 | val scrimDark = Color(0xFF000000) 59 | val inverseSurfaceDark = Color(0xFFDFE3E8) 60 | val inverseOnSurfaceDark = Color(0xFF2D3135) 61 | val inversePrimaryDark = Color(0xFF246488) 62 | 63 | val CustomGreen = Color(0xFF19B661) 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation.theme 2 | 3 | import android.os.Build 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.darkColorScheme 7 | import androidx.compose.material3.dynamicDarkColorScheme 8 | import androidx.compose.material3.dynamicLightColorScheme 9 | import androidx.compose.material3.lightColorScheme 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.platform.LocalContext 12 | 13 | private val lightScheme = lightColorScheme( 14 | primary = primaryLight, 15 | onPrimary = onPrimaryLight, 16 | primaryContainer = primaryContainerLight, 17 | onPrimaryContainer = onPrimaryContainerLight, 18 | secondary = secondaryLight, 19 | onSecondary = onSecondaryLight, 20 | secondaryContainer = secondaryContainerLight, 21 | onSecondaryContainer = onSecondaryContainerLight, 22 | tertiary = tertiaryLight, 23 | onTertiary = onTertiaryLight, 24 | tertiaryContainer = tertiaryContainerLight, 25 | onTertiaryContainer = onTertiaryContainerLight, 26 | error = errorLight, 27 | onError = onErrorLight, 28 | errorContainer = errorContainerLight, 29 | onErrorContainer = onErrorContainerLight, 30 | background = backgroundLight, 31 | onBackground = onBackgroundLight, 32 | surface = surfaceLight, 33 | onSurface = onSurfaceLight, 34 | surfaceVariant = surfaceVariantLight, 35 | onSurfaceVariant = onSurfaceVariantLight, 36 | outline = outlineLight, 37 | outlineVariant = outlineVariantLight, 38 | scrim = scrimLight, 39 | inverseSurface = inverseSurfaceLight, 40 | inverseOnSurface = inverseOnSurfaceLight, 41 | inversePrimary = inversePrimaryLight 42 | ) 43 | 44 | private val darkScheme = darkColorScheme( 45 | primary = primaryDark, 46 | onPrimary = onPrimaryDark, 47 | primaryContainer = primaryContainerDark, 48 | onPrimaryContainer = onPrimaryContainerDark, 49 | secondary = secondaryDark, 50 | onSecondary = onSecondaryDark, 51 | secondaryContainer = secondaryContainerDark, 52 | onSecondaryContainer = onSecondaryContainerDark, 53 | tertiary = tertiaryDark, 54 | onTertiary = onTertiaryDark, 55 | tertiaryContainer = tertiaryContainerDark, 56 | onTertiaryContainer = onTertiaryContainerDark, 57 | error = errorDark, 58 | onError = onErrorDark, 59 | errorContainer = errorContainerDark, 60 | onErrorContainer = onErrorContainerDark, 61 | background = backgroundDark, 62 | onBackground = onBackgroundDark, 63 | surface = surfaceDark, 64 | onSurface = onSurfaceDark, 65 | surfaceVariant = surfaceVariantDark, 66 | onSurfaceVariant = onSurfaceVariantDark, 67 | outline = outlineDark, 68 | outlineVariant = outlineVariantDark, 69 | scrim = scrimDark, 70 | inverseSurface = inverseSurfaceDark, 71 | inverseOnSurface = inverseOnSurfaceDark, 72 | inversePrimary = inversePrimaryDark 73 | ) 74 | 75 | @Composable 76 | fun ImageVistaTheme( 77 | darkTheme: Boolean = isSystemInDarkTheme(), 78 | // Dynamic color is available on Android 12+ 79 | dynamicColor: Boolean = true, 80 | content: @Composable () -> Unit 81 | ) { 82 | val colorScheme = when { 83 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 84 | val context = LocalContext.current 85 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 86 | } 87 | 88 | darkTheme -> darkScheme 89 | else -> lightScheme 90 | } 91 | 92 | MaterialTheme( 93 | colorScheme = colorScheme, 94 | typography = Typography, 95 | content = content 96 | ) 97 | } 98 | 99 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | lineHeight = 24.sp, 16 | letterSpacing = 0.5.sp 17 | ) 18 | /* Other default text styles to override 19 | titleLarge = TextStyle( 20 | fontFamily = FontFamily.Default, 21 | fontWeight = FontWeight.Normal, 22 | fontSize = 22.sp, 23 | lineHeight = 28.sp, 24 | letterSpacing = 0.sp 25 | ), 26 | labelSmall = TextStyle( 27 | fontFamily = FontFamily.Default, 28 | fontWeight = FontWeight.Medium, 29 | fontSize = 11.sp, 30 | lineHeight = 16.sp, 31 | letterSpacing = 0.5.sp 32 | ) 33 | */ 34 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/util/CommonUtil.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation.util 2 | 3 | val searchKeywords: List = listOf( 4 | "Landscape", 5 | "Portrait", 6 | "Nature", 7 | "Architecture", 8 | "Travel", 9 | "Food", 10 | "Animals", 11 | "Abstract", 12 | "Technology", 13 | "Fashion", 14 | "Sports", 15 | "Fitness", 16 | "Music", 17 | "Art", 18 | "City", 19 | "Culture", 20 | "Vintage", 21 | "Wellness", 22 | "Education", 23 | "Business" 24 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/util/SnackbarEvent.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation.util 2 | 3 | import androidx.compose.material3.SnackbarDuration 4 | 5 | data class SnackbarEvent( 6 | val message: String, 7 | val duration: SnackbarDuration = SnackbarDuration.Short 8 | ) 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/imagevista/presentation/util/WindowInsetsControllerExt.kt: -------------------------------------------------------------------------------- 1 | package com.example.imagevista.presentation.util 2 | 3 | import android.app.Activity 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.remember 6 | import androidx.compose.ui.platform.LocalContext 7 | import androidx.core.view.WindowCompat 8 | import androidx.core.view.WindowInsetsCompat 9 | import androidx.core.view.WindowInsetsControllerCompat 10 | 11 | @Composable 12 | fun rememberWindowInsetsController(): WindowInsetsControllerCompat { 13 | val window = with(LocalContext.current as Activity) {return@with window} 14 | return remember { 15 | WindowCompat.getInsetsController(window, window.decorView) 16 | } 17 | } 18 | 19 | fun WindowInsetsControllerCompat.toggleStatusBars(show: Boolean) { 20 | if (show) show(WindowInsetsCompat.Type.systemBars()) 21 | else hide(WindowInsetsCompat.Type.systemBars()) 22 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/app_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeInKotLang/ImageVista/54828ff32a3ee27337caa7e86b2abda065e70544/app/src/main/res/drawable/app_logo.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_download.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_error.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_save.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/img_empty_bookmarks.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 23 | 28 | 29 | 35 | 36 | 37 | 38 | 39 | 40 | 45 | 46 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeInKotLang/ImageVista/54828ff32a3ee27337caa7e86b2abda065e70544/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeInKotLang/ImageVista/54828ff32a3ee27337caa7e86b2abda065e70544/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeInKotLang/ImageVista/54828ff32a3ee27337caa7e86b2abda065e70544/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeInKotLang/ImageVista/54828ff32a3ee27337caa7e86b2abda065e70544/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeInKotLang/ImageVista/54828ff32a3ee27337caa7e86b2abda065e70544/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeInKotLang/ImageVista/54828ff32a3ee27337caa7e86b2abda065e70544/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeInKotLang/ImageVista/54828ff32a3ee27337caa7e86b2abda065e70544/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeInKotLang/ImageVista/54828ff32a3ee27337caa7e86b2abda065e70544/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeInKotLang/ImageVista/54828ff32a3ee27337caa7e86b2abda065e70544/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeInKotLang/ImageVista/54828ff32a3ee27337caa7e86b2abda065e70544/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/values-night/splash.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/splash.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | ImageVista 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |