├── .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 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/.idea/kotlinc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/migrations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.idea/studiobot.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 | 
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 | 
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 |
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 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/app/src/test/java/com/example/imagevista/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.example.imagevista
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 | plugins {
3 | alias(libs.plugins.android.application) apply false
4 | alias(libs.plugins.compose.compiler) apply false
5 | alias(libs.plugins.jetbrains.kotlin.android) apply false
6 | alias(libs.plugins.dagger.hilt) apply false
7 | alias(libs.plugins.ksp) apply false
8 | alias(libs.plugins.kotlin.serialization) apply false
9 | }
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. For more details, visit
12 | # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | agp = "8.4.1"
3 | cloudy = "0.1.2"
4 | coilCompose = "2.5.0"
5 | converterGson = "2.9.0"
6 | coreSplashscreen = "1.0.1"
7 | hiltAndroid = "2.49"
8 | hiltAndroidCompiler = "2.48"
9 | hiltCompiler = "1.2.0"
10 | hiltNavigationCompose = "1.2.0"
11 | kotlin = "2.0.0"
12 | ksp = "2.0.0-1.0.21"
13 | coreKtx = "1.13.1"
14 | junit = "4.13.2"
15 | junitVersion = "1.1.5"
16 | espressoCore = "3.5.1"
17 | kotlinxSerializationJson = "1.6.3"
18 | lifecycleRuntimeKtx = "2.8.1"
19 | activityCompose = "1.9.0"
20 | composeBom = "2024.05.00"
21 | lifecycleViewmodelCompose = "2.8.1"
22 | navigationCompose = "2.8.0-beta02"
23 | pagingVersion = "3.3.0"
24 | okhttp = "4.12.0"
25 | retrofit = "2.9.0"
26 | retrofit2KotlinxSerializationConverter = "1.0.0"
27 | roomVersion = "2.6.1"
28 |
29 | [libraries]
30 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
31 | androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
32 | androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
33 | androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltCompiler" }
34 | androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleViewmodelCompose" }
35 | androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
36 | androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
37 | androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "pagingVersion" }
38 | androidx-paging-runtime-ktx = { module = "androidx.paging:paging-runtime-ktx", version.ref = "pagingVersion" }
39 | androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomVersion" }
40 | androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "roomVersion" }
41 | androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomVersion" }
42 | androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomVersion" }
43 | cloudy = { module = "com.github.skydoves:cloudy", version.ref = "cloudy" }
44 | coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" }
45 | converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" }
46 | converter-scalars = { module = "com.squareup.retrofit2:converter-scalars", version.ref = "converterGson" }
47 | hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltAndroidCompiler" }
48 | hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroid" }
49 | junit = { group = "junit", name = "junit", version.ref = "junit" }
50 | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
51 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
52 | androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
53 | androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
54 | androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
55 | androidx-ui = { group = "androidx.compose.ui", name = "ui" }
56 | androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
57 | androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
58 | androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
59 | androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
60 | androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
61 | androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
62 | kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
63 | okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
64 | retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
65 | retrofit2-kotlinx-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofit2KotlinxSerializationConverter" }
66 |
67 | [plugins]
68 | android-application = { id = "com.android.application", version.ref = "agp" }
69 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
70 | jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
71 | dagger-hilt = { id = "com.google.dagger.hilt.android", version.ref = "hiltAndroid" }
72 | ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
73 | kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
74 |
75 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeInKotLang/ImageVista/54828ff32a3ee27337caa7e86b2abda065e70544/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Mon Jun 03 17:24:19 IST 2024
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/readme-assets/0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeInKotLang/ImageVista/54828ff32a3ee27337caa7e86b2abda065e70544/readme-assets/0.png
--------------------------------------------------------------------------------
/readme-assets/1.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeInKotLang/ImageVista/54828ff32a3ee27337caa7e86b2abda065e70544/readme-assets/1.gif
--------------------------------------------------------------------------------
/readme-assets/2.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeInKotLang/ImageVista/54828ff32a3ee27337caa7e86b2abda065e70544/readme-assets/2.gif
--------------------------------------------------------------------------------
/readme-assets/3.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeInKotLang/ImageVista/54828ff32a3ee27337caa7e86b2abda065e70544/readme-assets/3.gif
--------------------------------------------------------------------------------
/readme-assets/4.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeInKotLang/ImageVista/54828ff32a3ee27337caa7e86b2abda065e70544/readme-assets/4.gif
--------------------------------------------------------------------------------
/readme-assets/5.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeInKotLang/ImageVista/54828ff32a3ee27337caa7e86b2abda065e70544/readme-assets/5.gif
--------------------------------------------------------------------------------
/readme-assets/6.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeInKotLang/ImageVista/54828ff32a3ee27337caa7e86b2abda065e70544/readme-assets/6.gif
--------------------------------------------------------------------------------
/readme-assets/7.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeInKotLang/ImageVista/54828ff32a3ee27337caa7e86b2abda065e70544/readme-assets/7.gif
--------------------------------------------------------------------------------
/readme-assets/8.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeInKotLang/ImageVista/54828ff32a3ee27337caa7e86b2abda065e70544/readme-assets/8.gif
--------------------------------------------------------------------------------
/readme-assets/9.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeInKotLang/ImageVista/54828ff32a3ee27337caa7e86b2abda065e70544/readme-assets/9.gif
--------------------------------------------------------------------------------
/readme-assets/test:
--------------------------------------------------------------------------------
1 | test
2 |
--------------------------------------------------------------------------------
/readme-assets/youtube playlist.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeInKotLang/ImageVista/54828ff32a3ee27337caa7e86b2abda065e70544/readme-assets/youtube playlist.png
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google {
4 | content {
5 | includeGroupByRegex("com\\.android.*")
6 | includeGroupByRegex("com\\.google.*")
7 | includeGroupByRegex("androidx.*")
8 | }
9 | }
10 | mavenCentral()
11 | gradlePluginPortal()
12 | }
13 | }
14 | dependencyResolutionManagement {
15 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
16 | repositories {
17 | google()
18 | mavenCentral()
19 | }
20 | }
21 |
22 | rootProject.name = "ImageVista"
23 | include(":app")
24 |
--------------------------------------------------------------------------------