├── .gitignore ├── .idea ├── .gitignore ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── compiler.xml ├── dictionaries │ └── Ragvax.xml ├── gradle.xml ├── jarRepositories.xml ├── misc.xml └── vcs.xml ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── ragvax │ │ └── picttr │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ │ └── com │ │ │ └── ragvax │ │ │ └── picttr │ │ │ ├── GlideAppModule.kt │ │ │ ├── MainActivity.kt │ │ │ ├── Picttr.kt │ │ │ ├── data │ │ │ ├── collection │ │ │ │ └── model │ │ │ │ │ └── collection │ │ │ │ │ └── Collection.kt │ │ │ ├── common │ │ │ │ └── model │ │ │ │ │ └── Statistics.kt │ │ │ ├── photo │ │ │ │ ├── PhotoService.kt │ │ │ │ └── model │ │ │ │ │ └── Photo.kt │ │ │ ├── topic │ │ │ │ ├── TopicService.kt │ │ │ │ └── model │ │ │ │ │ └── Topic.kt │ │ │ └── user │ │ │ │ └── model │ │ │ │ └── User.kt │ │ │ ├── di │ │ │ └── NetworkModule.kt │ │ │ ├── domain │ │ │ ├── photo │ │ │ │ ├── PhotoPagingSource.kt │ │ │ │ ├── PhotoRepository.kt │ │ │ │ └── TopicPhotoPagingSource.kt │ │ │ └── topic │ │ │ │ └── TopicRepository.kt │ │ │ ├── ui │ │ │ ├── gallery │ │ │ │ ├── GalleryFragment.kt │ │ │ │ ├── GalleryGridSpacingItemDecoration.kt │ │ │ │ ├── GalleryViewModel.kt │ │ │ │ └── adapter │ │ │ │ │ ├── GalleryAdapter.kt │ │ │ │ │ ├── GalleryLoadStateAdapter.kt │ │ │ │ │ └── GalleryTopicsAdapter.kt │ │ │ ├── photodetails │ │ │ │ ├── PhotoDetailsFragment.kt │ │ │ │ └── PhotoDetailsViewModel.kt │ │ │ ├── photozoom │ │ │ │ ├── PhotoZoomFragment.kt │ │ │ │ └── PhotoZoomViewModel.kt │ │ │ └── profile │ │ │ │ ├── ProfileFragment.kt │ │ │ │ └── ProfileViewModel.kt │ │ │ └── utils │ │ │ ├── Constant.kt │ │ │ ├── ContextExt.kt │ │ │ ├── DpToPxConverter.kt │ │ │ ├── FlowObserver.kt │ │ │ ├── ImageViewExt.kt │ │ │ ├── NetworkConnectivity.kt │ │ │ ├── NumberExt.kt │ │ │ ├── Resource.kt │ │ │ └── ViewExt.kt │ └── res │ │ ├── drawable │ │ ├── gradient_dark_to_transparent.xml │ │ ├── ic_launcher_background.xml │ │ ├── ic_launcher_foreground.xml │ │ └── ic_round_public_24.xml │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── content_main.xml │ │ ├── fragment_gallery.xml │ │ ├── fragment_photo_details.xml │ │ ├── fragment_photo_zoom.xml │ │ ├── fragment_profile.xml │ │ ├── gallery_load_state_header_footer.xml │ │ ├── item_gallery_tag.xml │ │ └── item_photo.xml │ │ ├── menu │ │ └── menu_profile.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── navigation │ │ └── nav_graph.xml │ │ ├── values-night │ │ └── themes.xml │ │ └── values │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── themes.xml │ └── test │ └── java │ └── com │ └── ragvax │ └── picttr │ └── ExampleUnitTest.kt ├── assets ├── Details.png ├── Home.png └── Profile.png ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.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 | /gradle.properties 17 | gradle.properties 18 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 20 | 22 | 23 | 135 | 136 | 138 | 139 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/dictionaries/Ragvax.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | gson 5 | picttr 6 | ragvax 7 | unsplash 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 21 | 22 | -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | 29 | 30 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Picttr 2 | 3 | Another Unsplash client, seriously? Well, I built this project to study the use of modern android architecture components with the MVVM architecture and Jetpack Library. 4 | 5 | > **Caution:** This project contains large amount of **Code Smell.** Use it at Your Own Risk. 6 | 7 | ## Screenshot 8 | 9 | 10 | ## Built With 11 | * [Foundation][0] - Components for core system capabilities, Kotlin extensions and support for 12 | multidex and automated testing. 13 | * [AppCompat][1] - Degrade gracefully on older versions of Android. 14 | * [Android KTX][2] - Write more concise, idiomatic Kotlin code. 15 | * [Architecture][10] - A collection of libraries that help you design robust, testable, and 16 | maintainable apps. Start with classes for managing your UI component lifecycle and handling data 17 | persistence. 18 | * [Lifecycles][12] - Create a UI that automatically responds to lifecycle events. 19 | * [LiveData][13] - Build data objects that notify views when the underlying database changes. 20 | * [Navigation][14] - Handle everything needed for in-app navigation. 21 | * [ViewModel][17] - Store UI-related data that isn't destroyed on app rotations. Easily schedule 22 | asynchronous tasks for optimal execution. 23 | * Third party and miscellaneous libraries 24 | * [Glide][90] for image loading 25 | * [Hilt][92]: for [dependency injection][93] 26 | * [Kotlin Coroutines][91] for managing background threads with simplified code and reducing needs for callbacks 27 | 28 | [0]: https://developer.android.com/jetpack/components 29 | [1]: https://developer.android.com/topic/libraries/support-library/packages#v7-appcompat 30 | [2]: https://developer.android.com/kotlin/ktx 31 | [4]: https://developer.android.com/training/testing/ 32 | [10]: https://developer.android.com/jetpack/arch/ 33 | [11]: https://developer.android.com/topic/libraries/data-binding/ 34 | [12]: https://developer.android.com/topic/libraries/architecture/lifecycle 35 | [13]: https://developer.android.com/topic/libraries/architecture/livedata 36 | [14]: https://developer.android.com/topic/libraries/architecture/navigation/ 37 | [16]: https://developer.android.com/topic/libraries/architecture/room 38 | [17]: https://developer.android.com/topic/libraries/architecture/viewmodel 39 | [18]: https://developer.android.com/topic/libraries/architecture/workmanager 40 | [30]: https://developer.android.com/guide/topics/ui 41 | [31]: https://developer.android.com/training/animation/ 42 | [34]: https://developer.android.com/guide/components/fragments 43 | [35]: https://developer.android.com/guide/topics/ui/declaring-layout 44 | [90]: https://bumptech.github.io/glide/ 45 | [91]: https://kotlinlang.org/docs/reference/coroutines-overview.html 46 | [92]: https://developer.android.com/training/dependency-injection/hilt-android 47 | [93]: https://developer.android.com/training/dependency-injection 48 | 49 | ### Unsplash API key 50 | 51 | Picttr uses the [Unsplash API](https://unsplash.com/developers) to load pictures. To use the API, you will need to obtain a free developer API key. See the 52 | [Unsplash API Documentation](https://unsplash.com/documentation) for instructions. 53 | 54 | Once you have the key, add this line to the `gradle.properties` file, either in your user home 55 | directory (usually `~/.gradle/gradle.properties` on Linux and Mac) or in the project's root folder: 56 | 57 | ``` 58 | unsplash_access_key= 59 | ``` -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'kotlin-android' 4 | id 'kotlin-parcelize' 5 | id 'kotlin-kapt' 6 | id "androidx.navigation.safeargs.kotlin" 7 | id 'dagger.hilt.android.plugin' 8 | } 9 | 10 | android { 11 | compileSdkVersion 30 12 | buildToolsVersion "30.0.2" 13 | 14 | defaultConfig { 15 | applicationId "com.ragvax.picttr" 16 | minSdkVersion 21 17 | targetSdkVersion 30 18 | versionCode 1 19 | versionName "1.0" 20 | 21 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 22 | 23 | buildConfigField("String", "UNSPLASH_ACCESS_KEY", unsplash_access_key) 24 | } 25 | 26 | buildTypes { 27 | release { 28 | minifyEnabled false 29 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 30 | } 31 | } 32 | buildFeatures { 33 | viewBinding true 34 | } 35 | compileOptions { 36 | sourceCompatibility JavaVersion.VERSION_1_8 37 | targetCompatibility JavaVersion.VERSION_1_8 38 | } 39 | kotlinOptions { 40 | jvmTarget = '1.8' 41 | } 42 | } 43 | 44 | dependencies { 45 | 46 | implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" 47 | implementation 'androidx.core:core-ktx:1.3.2' 48 | implementation 'androidx.appcompat:appcompat:1.2.0' 49 | implementation 'androidx.constraintlayout:constraintlayout:2.0.4' 50 | 51 | // Material Components 52 | implementation 'com.google.android.material:material:1.3.0' 53 | 54 | // Coroutines 55 | implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.2' 56 | 57 | // Lifecycle 58 | implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.0" 59 | implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.3.0" 60 | implementation "androidx.lifecycle:lifecycle-common-java8:2.3.0" 61 | 62 | // Navigation Component 63 | implementation 'androidx.navigation:navigation-fragment-ktx:2.3.4' 64 | implementation 'androidx.navigation:navigation-ui-ktx:2.3.4' 65 | 66 | // Retrofit + GSON Converter 67 | implementation "com.squareup.retrofit2:retrofit:2.9.0" 68 | implementation "com.squareup.retrofit2:converter-gson:2.9.0" 69 | 70 | // Glide 71 | implementation "com.github.bumptech.glide:glide:4.12.0" 72 | kapt 'com.github.bumptech.glide:compiler:4.12.0' 73 | 74 | // Dagger Hilt 75 | implementation "com.google.dagger:hilt-android:$hiltVersion" 76 | implementation "androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha03" 77 | kapt "com.google.dagger:hilt-android-compiler:$hiltVersion" 78 | kapt "androidx.hilt:hilt-compiler:1.0.0-beta01" 79 | 80 | // Paging 3 81 | implementation "androidx.paging:paging-runtime-ktx:3.0.0-beta02" 82 | 83 | // PhotoView 84 | implementation "com.github.chrisbanes:PhotoView:2.3.0" 85 | 86 | // Test dependencies 87 | testImplementation 'junit:junit:4.13.2' 88 | androidTestImplementation 'androidx.test.ext:junit:1.1.2' 89 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' 90 | } 91 | 92 | kapt { 93 | correctErrorTypes true 94 | } -------------------------------------------------------------------------------- /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/ragvax/picttr/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr 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.ragvax.picttr", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 16 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ragvax/Pictrr/acb016757b8f7a9aaa57b146f6a800a7c29c71d6/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/GlideAppModule.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr 2 | 3 | import com.bumptech.glide.annotation.GlideModule 4 | import com.bumptech.glide.module.AppGlideModule 5 | 6 | @GlideModule 7 | class GlideAppModule : AppGlideModule() { 8 | 9 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import androidx.navigation.NavController 6 | import androidx.navigation.fragment.NavHostFragment 7 | import androidx.navigation.fragment.findNavController 8 | import androidx.navigation.ui.AppBarConfiguration 9 | import androidx.navigation.ui.setupActionBarWithNavController 10 | import com.ragvax.picttr.databinding.ActivityMainBinding 11 | import com.ragvax.picttr.utils.show 12 | import dagger.hilt.android.AndroidEntryPoint 13 | 14 | @AndroidEntryPoint 15 | class MainActivity : AppCompatActivity() { 16 | private lateinit var navController: NavController 17 | 18 | override fun onCreate(savedInstanceState: Bundle?) { 19 | super.onCreate(savedInstanceState) 20 | val binding = ActivityMainBinding.inflate(layoutInflater) 21 | setContentView(binding.root) 22 | setSupportActionBar(binding.toolbar) 23 | 24 | val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment 25 | navController = navHostFragment.findNavController() 26 | val appBarConfiguration = AppBarConfiguration(navController.graph) 27 | 28 | supportActionBar?.setDisplayShowTitleEnabled(true) 29 | setupActionBarWithNavController(navController, appBarConfiguration) 30 | observeNavElements(binding, navController) 31 | } 32 | 33 | private fun observeNavElements( 34 | binding: ActivityMainBinding, 35 | navController: NavController 36 | ) { 37 | navController.addOnDestinationChangedListener { _, destination, _ -> 38 | when (destination.id) { 39 | R.id.photoZoomFragment -> { 40 | binding.toolbar.show() 41 | supportActionBar!!.setDisplayShowTitleEnabled(false) 42 | } 43 | else -> { 44 | binding.toolbar.show() 45 | supportActionBar!!.setDisplayShowTitleEnabled(true) 46 | } 47 | } 48 | } 49 | } 50 | 51 | override fun onSupportNavigateUp(): Boolean { 52 | return navController.navigateUp() || super.onSupportNavigateUp() 53 | } 54 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/Picttr.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | @HiltAndroidApp 7 | class Picttr : Application() -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/data/collection/model/collection/Collection.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.data.collection.model.collection 2 | 3 | import android.os.Parcelable 4 | import com.ragvax.picttr.data.photo.model.Photo 5 | import com.ragvax.picttr.data.photo.model.Tag 6 | import com.ragvax.picttr.data.user.model.User 7 | import kotlinx.parcelize.Parcelize 8 | 9 | @Parcelize 10 | data class Collection( 11 | val id: Int, 12 | val title: String, 13 | val description: String?, 14 | val published_at: String?, 15 | val updated_At: String?, 16 | val curated: Boolean?, 17 | val featured: Boolean?, 18 | val total_photos: Int, 19 | val private: Boolean?, 20 | val share_key: String?, 21 | val tags: List?, 22 | val cover_photo: Photo?, 23 | val preview_photos: List?, 24 | val user: User?, 25 | val links: Links?, 26 | ) : Parcelable 27 | 28 | @Parcelize 29 | data class Links( 30 | val self: String, 31 | val html: String, 32 | val photos: String 33 | ) : Parcelable -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/data/common/model/Statistics.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.data.common.model 2 | 3 | import android.os.Parcelable 4 | import kotlinx.parcelize.Parcelize 5 | 6 | @Parcelize 7 | data class PhotoStatistics( 8 | val id: String, 9 | val downloads: Downloads, 10 | val views: Views, 11 | val likes: Likes, 12 | ) : Parcelable 13 | 14 | @Parcelize 15 | data class UserStatistics( 16 | val username: String, 17 | val downloads: Downloads, 18 | val views: Views, 19 | val likes: Likes, 20 | ) : Parcelable 21 | 22 | @Parcelize 23 | data class Downloads( 24 | val total: Int, 25 | val historical: Historical, 26 | ) : Parcelable 27 | 28 | @Parcelize 29 | data class Views( 30 | val total: Int, 31 | val historical: Historical, 32 | ) : Parcelable 33 | 34 | @Parcelize 35 | data class Likes( 36 | val total: Int, 37 | val historical: Historical, 38 | ) : Parcelable 39 | 40 | @Parcelize 41 | data class Historical( 42 | val change: Int, 43 | val resolution: String, 44 | val quality: String, 45 | val values: List, 46 | ) : Parcelable 47 | 48 | @Parcelize 49 | data class Value( 50 | val date: String, 51 | val value: Int 52 | ) : Parcelable -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/data/photo/PhotoService.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.data.photo 2 | 3 | import com.ragvax.picttr.BuildConfig 4 | import com.ragvax.picttr.data.photo.model.Photo 5 | import retrofit2.Response 6 | import retrofit2.http.GET 7 | import retrofit2.http.Headers 8 | import retrofit2.http.Path 9 | import retrofit2.http.Query 10 | 11 | interface PhotoService { 12 | 13 | @Headers("Accept-Version: v1", "Authorization: Client-ID $CLIENT_ID") 14 | @GET("photos") 15 | suspend fun getPhotos( 16 | @Query("page") page: Int, 17 | @Query("per_page") perPage: Int, 18 | ): List 19 | 20 | @Headers("Accept-Version: v1", "Authorization: Client-ID $CLIENT_ID") 21 | @GET("photos/{id}") 22 | suspend fun getPhoto(@Path("id") id: String): Response 23 | 24 | @Headers("Accept-Version: v1", "Authorization: Client-ID $CLIENT_ID") 25 | @GET("topics/{id}/photos") 26 | suspend fun getTopicPhotos( 27 | @Path("id") id: String, 28 | @Query("page") page: Int, 29 | @Query("per_page") perPage: Int, 30 | ): List 31 | 32 | companion object { 33 | const val CLIENT_ID = BuildConfig.UNSPLASH_ACCESS_KEY 34 | } 35 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/data/photo/model/Photo.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.data.photo.model 2 | 3 | import android.os.Parcelable 4 | import com.ragvax.picttr.data.collection.model.collection.Collection 5 | import com.ragvax.picttr.data.common.model.PhotoStatistics 6 | import com.ragvax.picttr.data.user.model.User 7 | import kotlinx.parcelize.Parcelize 8 | 9 | @Parcelize 10 | data class Photo( 11 | val id: String, 12 | val created_at: String?, 13 | val updated_at: String?, 14 | val width: Int, 15 | val height: Int, 16 | val color: String? = "#E0E0E0", 17 | val blur_hash: String?, 18 | val views: Int?, 19 | val downloads: Int?, 20 | val likes: Int?, 21 | var liked_by_user: Boolean?, 22 | val description: String?, 23 | val alt_description: String?, 24 | val exif: Exif?, 25 | val location: Location?, 26 | val tags: List?, 27 | val current_user_collection: List?, 28 | val sponsorship: Sponsorship?, 29 | val urls: Urls, 30 | val links: Links?, 31 | val user: User?, 32 | val statistics: PhotoStatistics?, 33 | ) : Parcelable 34 | 35 | @Parcelize 36 | data class Exif( 37 | val make: String?, 38 | val model: String?, 39 | val exposure_time: String?, 40 | val aperture: String?, 41 | val focal_length: String?, 42 | val iso: Int?, 43 | ) : Parcelable 44 | 45 | @Parcelize 46 | data class Location( 47 | val title: String?, 48 | val name: String?, 49 | val city: String?, 50 | val country: String?, 51 | val position: Position?, 52 | ) : Parcelable 53 | 54 | @Parcelize 55 | data class Position( 56 | val latitude: Double?, 57 | val longitude: Double?, 58 | ) : Parcelable 59 | 60 | @Parcelize 61 | data class Tag( 62 | val type: String?, 63 | val title: String?, 64 | ) : Parcelable 65 | 66 | @Parcelize 67 | data class Urls( 68 | val raw: String, 69 | val full: String, 70 | val regular: String, 71 | val small: String, 72 | val thumb: String 73 | ) : Parcelable 74 | 75 | @Parcelize 76 | data class Links( 77 | val self: String, 78 | val html: String, 79 | val download: String, 80 | val download_location: String, 81 | ) : Parcelable 82 | 83 | @Parcelize 84 | data class Sponsorship( 85 | val sponsor: User? 86 | ) : Parcelable -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/data/topic/TopicService.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.data.topic 2 | 3 | import com.ragvax.picttr.BuildConfig 4 | import com.ragvax.picttr.data.topic.model.Topic 5 | import retrofit2.Response 6 | import retrofit2.http.GET 7 | import retrofit2.http.Headers 8 | import retrofit2.http.Query 9 | 10 | interface TopicService { 11 | 12 | @Headers("Accept-Version: v1", "Authorization: Client-ID $CLIENT_ID") 13 | @GET("topics") 14 | suspend fun getTopics(@Query("per_page") per_page: Int): Response> 15 | 16 | companion object { 17 | const val CLIENT_ID = BuildConfig.UNSPLASH_ACCESS_KEY 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/data/topic/model/Topic.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.data.topic.model 2 | 3 | import android.os.Parcelable 4 | import com.ragvax.picttr.data.user.model.User 5 | import kotlinx.parcelize.Parcelize 6 | 7 | @Parcelize 8 | data class Topic( 9 | val id: String, 10 | val slug: String, 11 | val title: String, 12 | val description: String?, 13 | val owners: List?, 14 | ) : Parcelable 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/data/user/model/User.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.data.user.model 2 | 3 | import android.os.Parcelable 4 | import com.ragvax.picttr.data.photo.model.Photo 5 | import kotlinx.parcelize.Parcelize 6 | 7 | @Parcelize 8 | data class User( 9 | val id: String, 10 | val updated_at: String?, 11 | val username: String?, 12 | val name: String?, 13 | val first_name: String?, 14 | val last_name: String?, 15 | val instagram_username: String?, 16 | val twitter_username: String?, 17 | val portfolio_url: String?, 18 | val bio: String?, 19 | val location: String?, 20 | val total_likes: Int?, 21 | val total_photos: Int?, 22 | val total_collections: Int?, 23 | val followed_by_user: Boolean?, 24 | val followers_count: Int?, 25 | val following_count: Int?, 26 | val downloads: Int?, 27 | val profile_image: ProfileImage?, 28 | val badge: Badge?, 29 | val links: Links?, 30 | val photos: List?, 31 | ) : Parcelable 32 | 33 | @Parcelize 34 | data class ProfileImage( 35 | val small: String, 36 | val medium: String, 37 | val large: String, 38 | ) : Parcelable 39 | 40 | @Parcelize 41 | data class Badge( 42 | val title: String?, 43 | val primary: Boolean?, 44 | val slug: String?, 45 | val link: String?, 46 | ) : Parcelable 47 | 48 | @Parcelize 49 | data class Links( 50 | val self: String, 51 | val html: String, 52 | val photos: String, 53 | val likes: String, 54 | val portfolio: String, 55 | val following: String, 56 | val followers: String, 57 | ) : Parcelable -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/di/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.di 2 | 3 | import android.content.Context 4 | import android.os.Build 5 | import androidx.annotation.RequiresApi 6 | import com.ragvax.picttr.data.photo.PhotoService 7 | import com.ragvax.picttr.data.topic.TopicService 8 | import com.ragvax.picttr.utils.Network 9 | import com.ragvax.picttr.utils.NetworkConnectivity 10 | import dagger.Module 11 | import dagger.Provides 12 | import dagger.hilt.InstallIn 13 | import dagger.hilt.android.qualifiers.ApplicationContext 14 | import dagger.hilt.components.SingletonComponent 15 | import retrofit2.Retrofit 16 | import retrofit2.converter.gson.GsonConverterFactory 17 | import javax.inject.Singleton 18 | 19 | private const val BASE_URL = "https://api.unsplash.com/" 20 | @InstallIn(SingletonComponent::class) 21 | @Module 22 | object NetworkModule { 23 | 24 | @Provides 25 | @Singleton 26 | fun provideRetrofit(): Retrofit = 27 | Retrofit.Builder() 28 | .baseUrl(BASE_URL) 29 | .addConverterFactory(GsonConverterFactory.create()) 30 | .build() 31 | 32 | @Provides 33 | @Singleton 34 | fun providePhotoService(retrofit: Retrofit): PhotoService = 35 | retrofit.create(PhotoService::class.java) 36 | 37 | @Provides 38 | @Singleton 39 | fun provideTopicService(retrofit: Retrofit): TopicService = 40 | retrofit.create(TopicService::class.java) 41 | 42 | @RequiresApi(Build.VERSION_CODES.LOLLIPOP) 43 | @Singleton 44 | @Provides 45 | fun provideNetworkConnectivity(@ApplicationContext context: Context) : NetworkConnectivity { 46 | return Network(context) 47 | } 48 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/domain/photo/PhotoPagingSource.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.domain.photo 2 | 3 | import androidx.paging.PagingSource 4 | import androidx.paging.PagingState 5 | import com.ragvax.picttr.data.photo.PhotoService 6 | import com.ragvax.picttr.data.photo.model.Photo 7 | import retrofit2.HttpException 8 | import java.io.IOException 9 | 10 | private const val UNSPLASH_STARTING_PAGE_INDEX = 1 11 | 12 | class PhotoPagingSource(private val service: PhotoService) : PagingSource() { 13 | override suspend fun load(params: LoadParams): LoadResult { 14 | val page = params.key ?: UNSPLASH_STARTING_PAGE_INDEX 15 | return try { 16 | val photos = service.getPhotos(page, params.loadSize) 17 | LoadResult.Page( 18 | data = photos, 19 | prevKey = if (page == UNSPLASH_STARTING_PAGE_INDEX) null else page - 1, 20 | nextKey = if (photos.isEmpty()) null else page + 1, 21 | ) 22 | } catch (e: IOException) { 23 | LoadResult.Error(e) 24 | } catch (e: HttpException) { 25 | LoadResult.Error(e) 26 | } 27 | } 28 | 29 | override fun getRefreshKey(state: PagingState): Int? { 30 | return state.anchorPosition 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/domain/photo/PhotoRepository.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.domain.photo 2 | 3 | import androidx.paging.Pager 4 | import androidx.paging.PagingConfig 5 | import androidx.paging.PagingData 6 | import com.ragvax.picttr.data.photo.PhotoService 7 | import com.ragvax.picttr.data.photo.model.Photo 8 | import kotlinx.coroutines.flow.Flow 9 | import javax.inject.Inject 10 | import javax.inject.Singleton 11 | 12 | @Singleton 13 | class PhotoRepository @Inject constructor(private val photoService: PhotoService) { 14 | 15 | fun getPhotos(): Flow> = 16 | Pager( 17 | config = PagingConfig( 18 | initialLoadSize = 15, 19 | pageSize = 15, 20 | maxSize = 100, 21 | prefetchDistance = 5, 22 | enablePlaceholders = false 23 | ), 24 | pagingSourceFactory = { PhotoPagingSource(photoService) } 25 | ).flow 26 | 27 | fun getTopicPhotos(topicId: String): Flow> = 28 | Pager( 29 | config = PagingConfig( 30 | initialLoadSize = 15, 31 | pageSize = 15, 32 | maxSize = 100, 33 | prefetchDistance = 5, 34 | enablePlaceholders = false, 35 | ), 36 | pagingSourceFactory = { TopicPhotoPagingSource(photoService, topicId) } 37 | ).flow 38 | 39 | suspend fun getPhotoDetails(id: String) = photoService.getPhoto(id) 40 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/domain/photo/TopicPhotoPagingSource.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.domain.photo 2 | 3 | import androidx.paging.PagingSource 4 | import androidx.paging.PagingState 5 | import com.ragvax.picttr.data.photo.PhotoService 6 | import com.ragvax.picttr.data.photo.model.Photo 7 | import retrofit2.HttpException 8 | import java.io.IOException 9 | 10 | private const val STARTING_PAGE_INDEX = 1 11 | 12 | class TopicPhotoPagingSource( 13 | private val service: PhotoService, 14 | private val topicId: String 15 | ) : PagingSource() { 16 | override suspend fun load(params: LoadParams): LoadResult { 17 | val page = params.key ?: STARTING_PAGE_INDEX 18 | return try { 19 | val photos = service.getTopicPhotos(topicId, page, params.loadSize) 20 | LoadResult.Page( 21 | data = photos, 22 | prevKey = if (page == STARTING_PAGE_INDEX) null else page - 1, 23 | nextKey = if (photos.isEmpty()) null else page + 1, 24 | ) 25 | } catch (e: IOException) { 26 | LoadResult.Error(e) 27 | } catch (e: HttpException) { 28 | LoadResult.Error(e) 29 | } 30 | } 31 | 32 | override fun getRefreshKey(state: PagingState): Int? { 33 | return state.anchorPosition 34 | } 35 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/domain/topic/TopicRepository.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.domain.topic 2 | 3 | import com.ragvax.picttr.data.topic.TopicService 4 | import com.ragvax.picttr.data.topic.model.Topic 5 | import com.ragvax.picttr.utils.NetworkConnectivity 6 | import com.ragvax.picttr.utils.Resource 7 | import javax.inject.Inject 8 | import javax.inject.Singleton 9 | 10 | @Singleton 11 | class TopicRepository @Inject constructor( 12 | private val topicService: TopicService, 13 | private val networkConnectivity: NetworkConnectivity,) { 14 | 15 | suspend fun getTopics(per_page: Int = 15): Resource> { 16 | if (!networkConnectivity.isConnected()) { 17 | return Resource.Error("No internet connection") 18 | } 19 | 20 | return try { 21 | val response = topicService.getTopics(per_page) 22 | val result = response.body() 23 | if (response.isSuccessful && result != null) { 24 | Resource.Success(result) 25 | } else { 26 | Resource.Error(response.message()) 27 | } 28 | } catch(e: Exception) { 29 | Resource.Error(e.message ?: "An error occurred") 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/ui/gallery/GalleryFragment.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.ui.gallery 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import android.widget.Toast 6 | import androidx.core.view.isVisible 7 | import androidx.fragment.app.Fragment 8 | import androidx.fragment.app.viewModels 9 | import androidx.lifecycle.lifecycleScope 10 | import androidx.navigation.fragment.findNavController 11 | import androidx.paging.LoadState 12 | import androidx.recyclerview.widget.LinearLayoutManager 13 | import androidx.recyclerview.widget.StaggeredGridLayoutManager 14 | import com.ragvax.picttr.R 15 | import com.ragvax.picttr.data.photo.model.Photo 16 | import com.ragvax.picttr.data.topic.model.Topic 17 | import com.ragvax.picttr.databinding.FragmentGalleryBinding 18 | import com.ragvax.picttr.ui.gallery.adapter.GalleryAdapter 19 | import com.ragvax.picttr.ui.gallery.adapter.GalleryLoadStateAdapter 20 | import com.ragvax.picttr.ui.gallery.adapter.GalleryTopicsAdapter 21 | import com.ragvax.picttr.utils.collectWhileStarted 22 | import com.ragvax.picttr.utils.dpToPx 23 | import com.ragvax.picttr.utils.hide 24 | import com.ragvax.picttr.utils.show 25 | import dagger.hilt.android.AndroidEntryPoint 26 | import kotlinx.coroutines.ExperimentalCoroutinesApi 27 | import kotlinx.coroutines.FlowPreview 28 | import kotlinx.coroutines.flow.collectLatest 29 | 30 | @AndroidEntryPoint 31 | class GalleryFragment : Fragment(R.layout.fragment_gallery), 32 | GalleryTopicsAdapter.OnItemClickListener, GalleryAdapter.OnItemClickListener { 33 | private val viewModel: GalleryViewModel by viewModels() 34 | private var _binding: FragmentGalleryBinding? = null 35 | private val binding get() = _binding!! 36 | 37 | private lateinit var photosAdapter: GalleryAdapter 38 | private lateinit var topicsAdapter: GalleryTopicsAdapter 39 | 40 | @ExperimentalCoroutinesApi 41 | @FlowPreview 42 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 43 | super.onViewCreated(view, savedInstanceState) 44 | _binding = FragmentGalleryBinding.bind(view) 45 | 46 | setupPhotosAdapter() 47 | observeViewModel() 48 | photosLoadStateListener() 49 | } 50 | 51 | @FlowPreview 52 | @ExperimentalCoroutinesApi 53 | private fun observeViewModel() { 54 | 55 | viewModel.topicsFlow.collectWhileStarted(viewLifecycleOwner) { 56 | when (it) { 57 | is GalleryViewModel.TopicsEvent.Success -> { 58 | setupTopicsAdapter(it.topics) 59 | binding.tvTopicsErrorMessage.hide() 60 | } 61 | is GalleryViewModel.TopicsEvent.Loading -> { 62 | binding.progressCircularLoading.show() 63 | } 64 | is GalleryViewModel.TopicsEvent.Failure -> { 65 | binding.apply { 66 | tvTopicsErrorMessage.show() 67 | tvTopicsErrorMessage.text = it.errorText 68 | } 69 | } 70 | is GalleryViewModel.TopicsEvent.Empty -> { 71 | Toast.makeText(requireContext(), "Topics is Empty", Toast.LENGTH_SHORT) 72 | .show() 73 | } 74 | } 75 | } 76 | 77 | viewLifecycleOwner.lifecycleScope.launchWhenStarted { 78 | viewModel.photosFlow.collectLatest { 79 | photosAdapter.submitData(it) 80 | } 81 | } 82 | 83 | viewModel.galleryEvent.collectWhileStarted(viewLifecycleOwner) { event -> 84 | when (event) { 85 | is GalleryViewModel.GalleryEvent.NavigateToPhotoDetailsFragment -> { 86 | val action = GalleryFragmentDirections.actionGalleryFragmentToPhotoDetailsFragment(event.photo) 87 | findNavController().navigate(action) 88 | } 89 | } 90 | } 91 | } 92 | 93 | private fun setupPhotosAdapter() { 94 | photosAdapter = GalleryAdapter(this) 95 | val headerAdapter = GalleryLoadStateAdapter{ photosAdapter.retry() } 96 | val footerAdapter = GalleryLoadStateAdapter{ photosAdapter.retry() } 97 | val concatAdapter = photosAdapter.withLoadStateHeaderAndFooter( 98 | header = headerAdapter, 99 | footer = footerAdapter, 100 | ) 101 | val staggeredGridLayoutManager = StaggeredGridLayoutManager(2, LinearLayoutManager.VERTICAL) 102 | staggeredGridLayoutManager.gapStrategy = StaggeredGridLayoutManager.GAP_HANDLING_NONE 103 | 104 | binding.apply { 105 | rvGallery.layoutManager = staggeredGridLayoutManager 106 | rvGallery.setHasFixedSize(true) 107 | rvGallery.adapter = concatAdapter 108 | rvGallery.addItemDecoration(GalleryGridSpacingItemDecoration(16.dpToPx(requireContext()))) 109 | btnRetry.setOnClickListener { 110 | viewModel.getTopics() 111 | photosAdapter.retry() 112 | } 113 | } 114 | } 115 | 116 | private fun setupTopicsAdapter(topics: List) { 117 | topicsAdapter = GalleryTopicsAdapter(topics, this) 118 | val layoutManager = LinearLayoutManager(requireContext()) 119 | layoutManager.orientation = LinearLayoutManager.HORIZONTAL 120 | 121 | binding.apply { 122 | rvGalleryTags.layoutManager = layoutManager 123 | rvGalleryTags.setHasFixedSize(true) 124 | rvGalleryTags.adapter = topicsAdapter 125 | } 126 | } 127 | 128 | private fun photosLoadStateListener() { 129 | photosAdapter.addLoadStateListener { combinedLoadStates -> 130 | binding.apply { 131 | rvGallery.isVisible = combinedLoadStates.source.refresh is LoadState.NotLoading 132 | progressCircularLoading.isVisible = combinedLoadStates.source.refresh is LoadState.Loading 133 | btnRetry.isVisible = combinedLoadStates.source.refresh is LoadState.Error 134 | tvErrorMessage.isVisible = combinedLoadStates.source.refresh is LoadState.Error 135 | } 136 | } 137 | } 138 | 139 | override fun onItemClick(id: String) { 140 | viewModel.onTopicSelected(id) 141 | (binding.rvGallery.layoutManager as StaggeredGridLayoutManager) 142 | .scrollToPositionWithOffset(0, 0) 143 | } 144 | 145 | override fun onPhotoClick(photo: Photo) { 146 | viewModel.onPhotoSelected(photo) 147 | } 148 | 149 | override fun onDestroyView() { 150 | super.onDestroyView() 151 | _binding = null 152 | } 153 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/ui/gallery/GalleryGridSpacingItemDecoration.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.ui.gallery 2 | 3 | import android.graphics.Rect 4 | import android.view.View 5 | import androidx.recyclerview.widget.GridLayoutManager 6 | import androidx.recyclerview.widget.RecyclerView 7 | import androidx.recyclerview.widget.StaggeredGridLayoutManager 8 | 9 | class GalleryGridSpacingItemDecoration(private val spacing: Int) : RecyclerView.ItemDecoration() { 10 | 11 | override fun getItemOffsets( 12 | outRect: Rect, 13 | view: View, 14 | parent: RecyclerView, 15 | state: RecyclerView.State 16 | ) { 17 | if (outRect != null && view != null && parent != null) { 18 | val (spanCount, spanIndex, spanSize) = extractGridData(parent, view) 19 | outRect.left = (spacing * ((spanCount - spanIndex) / spanCount.toFloat())).toInt() 20 | outRect.right = (spacing * ((spanIndex + spanSize) / spanCount.toFloat())).toInt() 21 | outRect.bottom = spacing 22 | } 23 | } 24 | 25 | private fun extractGridData(parent: RecyclerView, view: View): GridItemData { 26 | return when (val layoutManager = parent.layoutManager) { 27 | is GridLayoutManager -> { 28 | extractGridLayoutData(layoutManager, view) 29 | } 30 | is StaggeredGridLayoutManager -> { 31 | extractStaggeredGridLayoutData(layoutManager, view) 32 | } 33 | else -> { 34 | throw UnsupportedOperationException("Bad layout params") 35 | } 36 | } 37 | } 38 | 39 | private fun extractGridLayoutData(layoutManager: GridLayoutManager, view: View): GridItemData { 40 | val lp: GridLayoutManager.LayoutParams = view.layoutParams as GridLayoutManager.LayoutParams 41 | return GridItemData( 42 | layoutManager.spanCount, 43 | lp.spanIndex, 44 | lp.spanSize 45 | ) 46 | } 47 | 48 | private fun extractStaggeredGridLayoutData( 49 | layoutManager: StaggeredGridLayoutManager, 50 | view: View 51 | ): GridItemData { 52 | val lp: StaggeredGridLayoutManager.LayoutParams = 53 | view.layoutParams as StaggeredGridLayoutManager.LayoutParams 54 | return GridItemData( 55 | layoutManager.spanCount, 56 | lp.spanIndex, 57 | if (lp.isFullSpan) layoutManager.spanCount else 1 58 | ) 59 | } 60 | 61 | internal data class GridItemData(val spanCount: Int, val spanIndex: Int, val spanSize: Int) 62 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/ui/gallery/GalleryViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.ui.gallery 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.asFlow 6 | import androidx.lifecycle.viewModelScope 7 | import androidx.paging.PagingData 8 | import androidx.paging.cachedIn 9 | import com.ragvax.picttr.data.photo.model.Photo 10 | import com.ragvax.picttr.data.topic.model.Topic 11 | import com.ragvax.picttr.domain.photo.PhotoRepository 12 | import com.ragvax.picttr.domain.topic.TopicRepository 13 | import com.ragvax.picttr.utils.Resource 14 | import dagger.hilt.android.lifecycle.HiltViewModel 15 | import kotlinx.coroutines.Dispatchers 16 | import kotlinx.coroutines.ExperimentalCoroutinesApi 17 | import kotlinx.coroutines.FlowPreview 18 | import kotlinx.coroutines.channels.Channel 19 | import kotlinx.coroutines.flow.* 20 | import kotlinx.coroutines.launch 21 | import javax.inject.Inject 22 | 23 | @HiltViewModel 24 | class GalleryViewModel @Inject constructor( 25 | private val topicRepository: TopicRepository, 26 | private val photoRepository: PhotoRepository, 27 | private val state: SavedStateHandle, 28 | ) : ViewModel() { 29 | 30 | init { 31 | getTopics() 32 | } 33 | 34 | // val photos = repository.getPhotos().cachedIn(viewModelScope) 35 | private val _topicsFlow = MutableStateFlow(TopicsEvent.Empty) 36 | val topicsFlow: StateFlow = _topicsFlow 37 | 38 | private val photosChannel = Channel(Channel.CONFLATED) 39 | 40 | private val galleryEventsChannel = Channel(Channel.CONFLATED) 41 | val galleryEvent = galleryEventsChannel.receiveAsFlow() 42 | 43 | @ExperimentalCoroutinesApi 44 | @FlowPreview 45 | val photosFlow = flowOf( 46 | photosChannel.receiveAsFlow().map { PagingData.empty() }, 47 | state.getLiveData(CURRENT_QUERY, DEFAULT_QUERY) 48 | .asFlow() 49 | .flatMapLatest { id -> 50 | photoRepository.getTopicPhotos(id) 51 | } 52 | .cachedIn(viewModelScope) 53 | ).flattenMerge() 54 | 55 | fun getTopics() = viewModelScope.launch(Dispatchers.IO) { 56 | _topicsFlow.value = TopicsEvent.Loading 57 | when(val result = topicRepository.getTopics()) { 58 | is Resource.Success -> { 59 | val data = result.data 60 | if (data != null) { 61 | _topicsFlow.value = TopicsEvent.Success(result.data) 62 | } else { 63 | _topicsFlow.value = TopicsEvent.Failure("Failed to retrieve data from server") 64 | } 65 | } 66 | is Resource.Error -> _topicsFlow.value = TopicsEvent.Failure(result.msg!!) 67 | } 68 | } 69 | 70 | private fun searchTopicPhotos(id: String) { 71 | if (!isTopicTheSame(id)) { 72 | photosChannel.offer(id) 73 | state.set(CURRENT_QUERY, id) 74 | } 75 | } 76 | 77 | private fun isTopicTheSame(currentQuery: String) = 78 | state.get(CURRENT_QUERY).toString() == currentQuery 79 | 80 | fun onTopicSelected(id: String) { 81 | searchTopicPhotos(id) 82 | } 83 | 84 | fun onPhotoSelected(photo: Photo) = viewModelScope.launch { 85 | galleryEventsChannel.send(GalleryEvent.NavigateToPhotoDetailsFragment(photo)) 86 | } 87 | 88 | sealed class GalleryEvent { 89 | data class NavigateToPhotoDetailsFragment(val photo: Photo) : GalleryEvent() 90 | } 91 | 92 | sealed class TopicsEvent { 93 | data class Success(val topics: List) : TopicsEvent() 94 | data class Failure(val errorText: String) : TopicsEvent() 95 | object Loading : TopicsEvent() 96 | object Empty : TopicsEvent() 97 | } 98 | 99 | companion object { 100 | private const val CURRENT_QUERY = "current_query" 101 | private var DEFAULT_QUERY = "bo8jQKTaE0Y" 102 | } 103 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/ui/gallery/adapter/GalleryAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.ui.gallery.adapter 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.constraintlayout.widget.ConstraintSet 6 | import androidx.paging.PagingDataAdapter 7 | import androidx.recyclerview.widget.DiffUtil 8 | import androidx.recyclerview.widget.RecyclerView 9 | import com.ragvax.picttr.data.photo.model.Photo 10 | import com.ragvax.picttr.databinding.ItemPhotoBinding 11 | import com.ragvax.picttr.utils.loadGridPhotoUrl 12 | 13 | class GalleryAdapter( 14 | private val listener: OnItemClickListener 15 | ) : PagingDataAdapter(PhotoDiffCallback()) { 16 | 17 | inner class GalleryViewHolder( 18 | private val binding: ItemPhotoBinding 19 | ) : RecyclerView.ViewHolder(binding.root) { 20 | 21 | init { 22 | binding.parentItemPhotoConstraint.setOnClickListener { 23 | val position = bindingAdapterPosition 24 | if (position != RecyclerView.NO_POSITION) { 25 | val item = getItem(position) 26 | if (item != null) listener.onPhotoClick(item) 27 | } 28 | } 29 | } 30 | 31 | fun bind(photo: Photo) = with(binding) { 32 | itemImageView.loadGridPhotoUrl(photo.urls.small, photo.color) 33 | setImageDimensionRatio(binding, calculateImageDimensionRatio(photo)) 34 | } 35 | } 36 | 37 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GalleryViewHolder { 38 | return GalleryViewHolder( 39 | ItemPhotoBinding.inflate( 40 | LayoutInflater.from(parent.context), 41 | parent, 42 | false 43 | ) 44 | ) 45 | } 46 | 47 | override fun onBindViewHolder(holder: GalleryViewHolder, position: Int) { 48 | val photo = getItem(position) 49 | if (photo != null) { 50 | holder.bind(photo) 51 | } 52 | } 53 | 54 | interface OnItemClickListener { 55 | fun onPhotoClick(photo: Photo) 56 | } 57 | 58 | private class PhotoDiffCallback : DiffUtil.ItemCallback() { 59 | override fun areItemsTheSame(oldItem: Photo, newItem: Photo) = oldItem.id == newItem.id 60 | override fun areContentsTheSame(oldItem: Photo, newItem: Photo) = oldItem == newItem 61 | } 62 | 63 | private fun calculateImageDimensionRatio(photo: Photo): String { 64 | return if (photo.width.toFloat() / photo.height.toFloat() > 1.8) { 65 | String.format("4000:3000") 66 | } else { 67 | String.format("%d:%d", photo.width, photo.height) 68 | } 69 | } 70 | 71 | private fun setImageDimensionRatio(binding: ItemPhotoBinding, ratio: String) { 72 | val set = ConstraintSet() 73 | binding.apply { 74 | set.clone(parentItemPhotoConstraint) 75 | set.setDimensionRatio(itemImageView.id, ratio) 76 | set.applyTo(parentItemPhotoConstraint) 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/ui/gallery/adapter/GalleryLoadStateAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.ui.gallery.adapter 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.core.view.isVisible 6 | import androidx.paging.LoadState 7 | import androidx.paging.LoadStateAdapter 8 | import androidx.recyclerview.widget.RecyclerView 9 | import androidx.recyclerview.widget.StaggeredGridLayoutManager 10 | import com.ragvax.picttr.databinding.GalleryLoadStateHeaderFooterBinding 11 | 12 | class GalleryLoadStateAdapter(private val retry: () -> Unit) : 13 | LoadStateAdapter() { 14 | 15 | override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): LoadStateViewHolder { 16 | return LoadStateViewHolder( 17 | GalleryLoadStateHeaderFooterBinding.inflate( 18 | LayoutInflater.from(parent.context), 19 | parent, 20 | false 21 | ) 22 | ) 23 | } 24 | 25 | override fun onBindViewHolder(holder: LoadStateViewHolder, loadState: LoadState) { 26 | holder.bind(loadState) 27 | val layoutParams = holder.itemView.layoutParams as StaggeredGridLayoutManager.LayoutParams 28 | layoutParams.isFullSpan = true 29 | } 30 | 31 | inner class LoadStateViewHolder(private val binding: GalleryLoadStateHeaderFooterBinding) : 32 | RecyclerView.ViewHolder(binding.root) { 33 | 34 | init { 35 | binding.btnRetry.setOnClickListener{ 36 | retry.invoke() 37 | } 38 | } 39 | 40 | fun bind(loadState: LoadState) { 41 | binding.apply { 42 | progressCircular.isVisible = loadState is LoadState.Loading 43 | btnRetry.isVisible = loadState !is LoadState.Loading 44 | tvErrorMessage.isVisible = loadState !is LoadState.Loading 45 | } 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/ui/gallery/adapter/GalleryTopicsAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.ui.gallery.adapter 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.recyclerview.widget.RecyclerView 6 | import com.ragvax.picttr.data.topic.model.Topic 7 | import com.ragvax.picttr.databinding.ItemGalleryTagBinding 8 | 9 | class GalleryTopicsAdapter( 10 | private val topics: List, 11 | private val listener: OnItemClickListener, 12 | ) : RecyclerView.Adapter() { 13 | 14 | inner class ViewHolder(private val binding: ItemGalleryTagBinding) 15 | : RecyclerView.ViewHolder(binding.root) { 16 | 17 | init { 18 | binding.root.setOnClickListener { 19 | val position = bindingAdapterPosition 20 | if (position != RecyclerView.NO_POSITION) { 21 | val item = topics[position].id 22 | listener.onItemClick(item) 23 | } 24 | } 25 | } 26 | 27 | fun bind(recommendationText: String) { 28 | binding.tvItem.text = recommendationText 29 | } 30 | } 31 | 32 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 33 | return ViewHolder( 34 | ItemGalleryTagBinding.inflate( 35 | LayoutInflater.from(parent.context), 36 | parent, 37 | false 38 | ) 39 | ) 40 | } 41 | 42 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 43 | val recommendationText = topics[position].title 44 | holder.bind(recommendationText) 45 | } 46 | 47 | override fun getItemCount() = topics.size 48 | 49 | interface OnItemClickListener { 50 | fun onItemClick(id: String) 51 | } 52 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/ui/photodetails/PhotoDetailsFragment.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.ui.photodetails 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.core.view.isVisible 6 | import androidx.fragment.app.Fragment 7 | import androidx.fragment.app.viewModels 8 | import androidx.lifecycle.lifecycleScope 9 | import androidx.navigation.fragment.findNavController 10 | import androidx.navigation.fragment.navArgs 11 | import com.ragvax.picttr.R 12 | import com.ragvax.picttr.data.photo.model.Location 13 | import com.ragvax.picttr.data.photo.model.Photo 14 | import com.ragvax.picttr.databinding.FragmentPhotoDetailsBinding 15 | import com.ragvax.picttr.utils.collectWhileStarted 16 | import com.ragvax.picttr.utils.loadPhotoUrlWithThumbnail 17 | import com.ragvax.picttr.utils.openLocationInMaps 18 | import com.ragvax.picttr.utils.toPrettyString 19 | import dagger.hilt.android.AndroidEntryPoint 20 | import kotlinx.coroutines.flow.collectLatest 21 | 22 | @AndroidEntryPoint 23 | class PhotoDetailsFragment : Fragment(R.layout.fragment_photo_details) { 24 | private val viewModel: PhotoDetailsViewModel by viewModels() 25 | private val args: PhotoDetailsFragmentArgs by navArgs() 26 | private var _binding: FragmentPhotoDetailsBinding? = null 27 | private val binding get() = _binding!! 28 | 29 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 30 | super.onViewCreated(view, savedInstanceState) 31 | _binding = FragmentPhotoDetailsBinding.bind(view) 32 | val photo = args.photo 33 | getPhotoDetails(photo.id) 34 | 35 | initView(photo) 36 | observeViewModel() 37 | } 38 | 39 | private fun getPhotoDetails(id: String) { 40 | viewModel.getPhotoDetails(id) 41 | } 42 | 43 | private fun observeViewModel() { 44 | viewLifecycleOwner.lifecycleScope.launchWhenStarted { 45 | viewModel.photoDetails.collectLatest { 46 | when (it) { 47 | is PhotoDetailsViewModel.PhotoDetails.Success -> { 48 | bindDetails(it.photoDetails) 49 | setDetailsVisibility(true) 50 | } 51 | is PhotoDetailsViewModel.PhotoDetails.Empty -> { 52 | setDetailsVisibility(false) 53 | } 54 | } 55 | } 56 | } 57 | 58 | viewModel.photoDetailsEvent.collectWhileStarted(viewLifecycleOwner) { event -> 59 | when (event) { 60 | is PhotoDetailsViewModel.PhotoDetailsEvent.NavigateToPhotoZoom -> { 61 | val action = PhotoDetailsFragmentDirections.actionDetailsFragmentToPhotoZoomFragment(event.photo) 62 | findNavController().navigate(action) 63 | } 64 | is PhotoDetailsViewModel.PhotoDetailsEvent.NavigateToUserProfile -> { 65 | val action = PhotoDetailsFragmentDirections.actionDetailsFragmentToProfileFragment(event.user!!) 66 | findNavController().navigate(action) 67 | } 68 | is PhotoDetailsViewModel.PhotoDetailsEvent.NavigateToMaps -> { 69 | requireContext().openLocationInMaps(event.location) 70 | } 71 | } 72 | } 73 | } 74 | 75 | private fun initView(photo: Photo) = with(binding) { 76 | ivPhoto.loadPhotoUrlWithThumbnail(photo.urls.full, photo.urls.small, photo.color) 77 | ivPhoto.setOnClickListener { viewModel.onPhotoClick(photo) } 78 | tvUserUsername.text = photo.user?.name 79 | tvUserUsername.setOnClickListener { viewModel.onUserClick(photo.user) } 80 | tvImageDescription.text = photo.description ?: "No description" 81 | } 82 | 83 | private fun setLocationString(location: Location?): String { 84 | return when { 85 | location?.title != null -> { 86 | location.title 87 | } 88 | location?.name != null -> { 89 | location.name 90 | } 91 | location?.city != null && location.country != null -> { 92 | getString(R.string.location_template, location.city, location.country) 93 | } 94 | location?.city != null && location.country == null -> { 95 | location.city 96 | } 97 | location?.city == null && location?.country != null -> { 98 | location.country 99 | } 100 | else -> { 101 | "No Location" 102 | } 103 | } 104 | } 105 | 106 | private fun bindDetails(photo: Photo) = with(binding) { 107 | photo.location.let { location -> 108 | val locationString = setLocationString(location) 109 | tvLocation.text = locationString 110 | tvLocation.setOnClickListener { viewModel.onLocationClick(locationString) } 111 | } 112 | tvItemLikes.text = (photo.likes ?: 0).toPrettyString() 113 | tvItemDownloads.text = (photo.downloads ?: 0).toPrettyString() 114 | tvItemViews.text = (photo.views ?: 0).toPrettyString() 115 | 116 | tvItemCamera.text = photo.exif?.model ?: "Unknown" 117 | tvItemAperture.text = photo.exif?.aperture ?: "Unknown" 118 | tvItemFocalLength.text = photo.exif?.focal_length ?: "Unknown" 119 | tvItemShutter.text = photo.exif?.exposure_time ?: "Unknown" 120 | tvItemIso.text = photo.exif?.iso?.toString() ?: "Unknown" 121 | tvItemDimension.text = getString(R.string.image_size_template, photo.width, photo.height) 122 | } 123 | 124 | private fun setDetailsVisibility(boolean: Boolean) = with(binding) { 125 | tvTitleLikes.isVisible = boolean 126 | tvTitleDownloads.isVisible = boolean 127 | tvTitleViews.isVisible = boolean 128 | tvItemLikes.isVisible = boolean 129 | tvItemDownloads.isVisible = boolean 130 | tvItemViews.isVisible = boolean 131 | 132 | tvTitleCamera.isVisible = boolean 133 | tvTitleAperture.isVisible = boolean 134 | tvTitleFocalLength.isVisible = boolean 135 | tvTitleShutter.isVisible = boolean 136 | tvTitleIso.isVisible = boolean 137 | tvTitleDimension.isVisible = boolean 138 | tvItemCamera.isVisible = boolean 139 | tvItemAperture.isVisible = boolean 140 | tvItemFocalLength.isVisible = boolean 141 | tvItemShutter.isVisible = boolean 142 | tvItemIso.isVisible = boolean 143 | tvItemDimension.isVisible = boolean 144 | } 145 | 146 | override fun onDestroy() { 147 | super.onDestroy() 148 | _binding = null 149 | } 150 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/ui/photodetails/PhotoDetailsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.ui.photodetails 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.ragvax.picttr.data.photo.model.Photo 6 | import com.ragvax.picttr.data.user.model.User 7 | import com.ragvax.picttr.domain.photo.PhotoRepository 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.channels.Channel 11 | import kotlinx.coroutines.flow.MutableStateFlow 12 | import kotlinx.coroutines.flow.StateFlow 13 | import kotlinx.coroutines.flow.receiveAsFlow 14 | import kotlinx.coroutines.launch 15 | import javax.inject.Inject 16 | 17 | @HiltViewModel 18 | class PhotoDetailsViewModel @Inject constructor( 19 | private val repository: PhotoRepository 20 | ) : ViewModel() { 21 | 22 | private val _photoDetails = MutableStateFlow(PhotoDetails.Empty) 23 | val photoDetails: StateFlow = _photoDetails 24 | 25 | private val photoDetailsEventChannel = Channel(Channel.CONFLATED) 26 | val photoDetailsEvent = photoDetailsEventChannel.receiveAsFlow() 27 | 28 | fun getPhotoDetails(id: String) = viewModelScope.launch(Dispatchers.IO) { 29 | val response = repository.getPhotoDetails(id) 30 | val result = response.body() 31 | if (response.isSuccessful && result != null) { 32 | _photoDetails.value = PhotoDetails.Success(result) 33 | } else { 34 | _photoDetails.value = PhotoDetails.Empty 35 | } 36 | } 37 | 38 | fun onPhotoClick(photo: Photo) = viewModelScope.launch { 39 | photoDetailsEventChannel.send(PhotoDetailsEvent.NavigateToPhotoZoom(photo)) 40 | } 41 | 42 | fun onUserClick(user: User?) = viewModelScope.launch { 43 | photoDetailsEventChannel.send(PhotoDetailsEvent.NavigateToUserProfile(user)) 44 | } 45 | 46 | fun onLocationClick(location: String?) = viewModelScope.launch { 47 | photoDetailsEventChannel.send(PhotoDetailsEvent.NavigateToMaps(location)) 48 | } 49 | 50 | sealed class PhotoDetails { 51 | data class Success(val photoDetails: Photo) : PhotoDetails() 52 | object Empty : PhotoDetails() 53 | } 54 | 55 | sealed class PhotoDetailsEvent { 56 | data class NavigateToPhotoZoom(val photo: Photo) : PhotoDetailsEvent() 57 | data class NavigateToUserProfile(val user: User?) : PhotoDetailsEvent() 58 | data class NavigateToMaps(val location: String?) : PhotoDetailsEvent() 59 | } 60 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/ui/photozoom/PhotoZoomFragment.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.ui.photozoom 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.fragment.app.Fragment 6 | import androidx.fragment.app.viewModels 7 | import androidx.navigation.fragment.findNavController 8 | import androidx.navigation.fragment.navArgs 9 | import com.ragvax.picttr.R 10 | import com.ragvax.picttr.data.photo.model.Photo 11 | import com.ragvax.picttr.databinding.FragmentPhotoZoomBinding 12 | import com.ragvax.picttr.utils.collectWhileStarted 13 | import com.ragvax.picttr.utils.loadPhotoUrlWithThumbnail 14 | import com.ragvax.picttr.utils.loadProfilePicture 15 | import dagger.hilt.android.AndroidEntryPoint 16 | 17 | @AndroidEntryPoint 18 | class PhotoZoomFragment : Fragment(R.layout.fragment_photo_zoom) { 19 | private val viewModel: PhotoZoomViewModel by viewModels() 20 | private val args: PhotoZoomFragmentArgs by navArgs() 21 | 22 | private var _binding: FragmentPhotoZoomBinding? = null 23 | private val binding get() = _binding!! 24 | 25 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 26 | super.onViewCreated(view, savedInstanceState) 27 | _binding = FragmentPhotoZoomBinding.bind(view) 28 | val photo = args.photo 29 | 30 | initView(photo) 31 | observeViewModel() 32 | } 33 | 34 | private fun initView( 35 | photo: Photo, 36 | ) { 37 | binding.apply { 38 | ivPhoto.loadPhotoUrlWithThumbnail(photo.urls.full, photo.urls.regular, photo.color,false) 39 | ivProfilePicture.loadProfilePicture(photo.user!!) 40 | tvUserUsername.text = photo.user.name 41 | tvUserUsername.setOnClickListener { 42 | viewModel.onUserClick(photo.user) 43 | } 44 | tvImageSize.text = getString( 45 | R.string.image_size_template, 46 | photo.width.toString(), 47 | photo.height.toString() 48 | ) 49 | } 50 | } 51 | 52 | private fun observeViewModel() { 53 | viewModel.photoZoomEvent.collectWhileStarted(viewLifecycleOwner) { event -> 54 | when (event) { 55 | is PhotoZoomViewModel.PhotoZoomEvent.NavigateToUserProfile -> { 56 | val action = PhotoZoomFragmentDirections.actionPhotoZoomFragmentToProfileFragment(event.user!!) 57 | findNavController().navigate(action) 58 | } 59 | } 60 | } 61 | } 62 | 63 | override fun onDestroy() { 64 | super.onDestroy() 65 | _binding = null 66 | } 67 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/ui/photozoom/PhotoZoomViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.ui.photozoom 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.ragvax.picttr.data.user.model.User 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import kotlinx.coroutines.channels.Channel 8 | import kotlinx.coroutines.flow.receiveAsFlow 9 | import kotlinx.coroutines.launch 10 | import javax.inject.Inject 11 | 12 | @HiltViewModel 13 | class PhotoZoomViewModel @Inject constructor() : ViewModel() { 14 | 15 | private val photoZoomEventChannel = Channel(Channel.CONFLATED) 16 | val photoZoomEvent = photoZoomEventChannel.receiveAsFlow() 17 | 18 | fun onUserClick(user: User?) = viewModelScope.launch { 19 | photoZoomEventChannel.send(PhotoZoomEvent.NavigateToUserProfile(user)) 20 | } 21 | 22 | sealed class PhotoZoomEvent { 23 | data class NavigateToUserProfile(val user: User?) : PhotoZoomEvent() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/ui/profile/ProfileFragment.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.ui.profile 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | import android.os.Bundle 6 | import android.view.Menu 7 | import android.view.MenuInflater 8 | import android.view.MenuItem 9 | import android.view.View 10 | import androidx.fragment.app.Fragment 11 | import androidx.fragment.app.viewModels 12 | import androidx.navigation.fragment.navArgs 13 | import com.ragvax.picttr.R 14 | import com.ragvax.picttr.data.user.model.User 15 | import com.ragvax.picttr.databinding.FragmentProfileBinding 16 | import com.ragvax.picttr.utils.* 17 | import dagger.hilt.android.AndroidEntryPoint 18 | 19 | @AndroidEntryPoint 20 | class ProfileFragment : Fragment(R.layout.fragment_profile) { 21 | private val viewModel: ProfileViewModel by viewModels() 22 | private val args: ProfileFragmentArgs by navArgs() 23 | private var _binding: FragmentProfileBinding? = null 24 | private val binding get() = _binding!! 25 | 26 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 27 | super.onViewCreated(view, savedInstanceState) 28 | _binding = FragmentProfileBinding.bind(view) 29 | val user = args.user 30 | 31 | initView(user) 32 | observeViewModel() 33 | setHasOptionsMenu(true) 34 | } 35 | 36 | private fun initView(user: User) { 37 | binding.apply { 38 | ivProfilePicture.loadProfilePicture(user) 39 | tvName.text = user.name 40 | if (user.location != null) { 41 | tvLocation.text = user.location 42 | tvLocation.setOnClickListener { viewModel.onLocationClick(user.location) } 43 | } else { 44 | tvLocation.hide() 45 | } 46 | if (user.bio != null) tvBio.text = user.bio else tvBio.hide() 47 | tvItemPhotos.text = (user.total_photos ?: 0).toPrettyString() 48 | tvItemLikes.text = (user.total_likes ?: 0).toPrettyString() 49 | tvItemCollection.text = (user.total_collections ?: 0).toPrettyString() 50 | } 51 | } 52 | 53 | private fun observeViewModel() { 54 | viewModel.profileEvent.collectWhileStarted(viewLifecycleOwner) { event -> 55 | when (event) { 56 | is ProfileViewModel.ProfileEvent.NavigateToMaps -> { 57 | requireContext().openLocationInMaps(event.location) 58 | } 59 | is ProfileViewModel.ProfileEvent.NavigateToBrowser -> { 60 | requireContext().openProfileInBrowser(event.links) 61 | } 62 | } 63 | } 64 | } 65 | 66 | override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { 67 | super.onCreateOptionsMenu(menu, inflater) 68 | inflater.inflate(R.menu.menu_profile, menu) 69 | } 70 | 71 | override fun onOptionsItemSelected(item: MenuItem): Boolean { 72 | return when (item.itemId) { 73 | R.id.action_open_in_browser -> { 74 | if (args.user.links == null) { 75 | true 76 | } else { 77 | viewModel.onOpenInBrowser(args.user.links!!) 78 | true 79 | } 80 | } 81 | else -> super.onOptionsItemSelected(item) 82 | } 83 | } 84 | override fun onDestroy() { 85 | super.onDestroy() 86 | _binding = null 87 | } 88 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/ui/profile/ProfileViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.ui.profile 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.ragvax.picttr.data.user.model.Links 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import kotlinx.coroutines.channels.Channel 8 | import kotlinx.coroutines.flow.receiveAsFlow 9 | import kotlinx.coroutines.launch 10 | import javax.inject.Inject 11 | 12 | @HiltViewModel 13 | class ProfileViewModel @Inject constructor() : ViewModel() { 14 | 15 | private val profileEventChannel = Channel(Channel.CONFLATED) 16 | val profileEvent = profileEventChannel.receiveAsFlow() 17 | 18 | fun onLocationClick(location: String?) = viewModelScope.launch { 19 | profileEventChannel.send(ProfileEvent.NavigateToMaps(location)) 20 | } 21 | 22 | fun onOpenInBrowser(links: Links) = viewModelScope.launch { 23 | profileEventChannel.send(ProfileEvent.NavigateToBrowser(links)) 24 | } 25 | 26 | sealed class ProfileEvent { 27 | data class NavigateToMaps(val location: String?) : ProfileEvent() 28 | data class NavigateToBrowser(val links: Links?) : ProfileEvent() 29 | } 30 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/utils/Constant.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.utils 2 | 3 | // TODO: PUT COMMON CONSTANT IN THIS FILE -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/utils/ContextExt.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.utils 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.content.pm.PackageManager 6 | import android.net.Uri 7 | import com.ragvax.picttr.data.user.model.Links 8 | 9 | fun Context.openLocationInMaps(location: String?) { 10 | val gmmIntentUri = Uri.parse("geo:0,0?q=${Uri.encode(location)}") 11 | val intent = Intent(Intent.ACTION_VIEW, gmmIntentUri) 12 | val mapsPackageName = "com.google.android.apps.maps" 13 | val mapsIsInstalled = try { 14 | packageManager.getApplicationInfo(mapsPackageName, 0).enabled 15 | } catch (e: PackageManager.NameNotFoundException) { 16 | false 17 | } 18 | if (mapsIsInstalled) intent.setPackage(mapsPackageName) 19 | if (intent.resolveActivity(packageManager) != null) { 20 | startActivity(intent) 21 | } 22 | } 23 | 24 | fun Context.openProfileInBrowser(links: Links?) { 25 | val uri = Uri.parse("${links?.html}?utm_source=Picttr&utm_medium=referral") 26 | val intent = Intent(Intent.ACTION_VIEW, uri) 27 | startActivity(intent) 28 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/utils/DpToPxConverter.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.utils 2 | 3 | import android.content.Context 4 | import android.util.TypedValue 5 | 6 | fun Int.dpToPx(context: Context): Int = TypedValue.applyDimension( 7 | TypedValue.COMPLEX_UNIT_DIP, this.toFloat(), context.resources.displayMetrics 8 | ).toInt() -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/utils/FlowObserver.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.utils 2 | 3 | import androidx.lifecycle.DefaultLifecycleObserver 4 | import androidx.lifecycle.LifecycleOwner 5 | import androidx.lifecycle.lifecycleScope 6 | import kotlinx.coroutines.Job 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.collect 9 | import kotlinx.coroutines.launch 10 | 11 | inline fun Flow.collectWhileStarted( 12 | lifecycleOwner: LifecycleOwner, 13 | noinline action: suspend (T) -> Unit 14 | ) { 15 | object : DefaultLifecycleObserver { 16 | private var job: Job? = null 17 | 18 | init { 19 | lifecycleOwner.lifecycle.addObserver(this) 20 | } 21 | 22 | override fun onStart(owner: LifecycleOwner) { 23 | job = owner.lifecycleScope.launch { 24 | collect { action(it) } 25 | } 26 | } 27 | 28 | override fun onStop(owner: LifecycleOwner) { 29 | job?.cancel() 30 | job = null 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/utils/ImageViewExt.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.utils 2 | 3 | import android.graphics.Color 4 | import android.graphics.drawable.ColorDrawable 5 | import android.widget.ImageView 6 | import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions 7 | import com.ragvax.picttr.GlideApp 8 | import com.ragvax.picttr.data.user.model.User 9 | 10 | fun ImageView.loadPhotoUrlWithThumbnail( 11 | url: String, 12 | thumbnailUrl: String, 13 | color: String?, 14 | centerCrop: Boolean = true, 15 | ) { 16 | color?.let { background = ColorDrawable(Color.parseColor(it)) } 17 | GlideApp.with(context) 18 | .load(url) 19 | .thumbnail( 20 | if (centerCrop) { 21 | GlideApp.with(context).load(thumbnailUrl).centerCrop() 22 | } else { 23 | GlideApp.with(context).load(thumbnailUrl).thumbnail(0.05f) 24 | } 25 | ) 26 | .into(this) 27 | } 28 | 29 | fun ImageView.loadGridPhotoUrl( 30 | url: String, 31 | color: String?, 32 | ) { 33 | color?.let { background = ColorDrawable(Color.parseColor(it)) } 34 | GlideApp.with(context) 35 | .load(url) 36 | .thumbnail(0.05f) 37 | .centerCrop() 38 | .transition(DrawableTransitionOptions.withCrossFade()) 39 | .into(this) 40 | } 41 | 42 | fun ImageView.loadProfilePicture(user: User) { 43 | loadProfilePicture(user.profile_image?.large) 44 | } 45 | 46 | fun ImageView.loadProfilePicture(url: String?) { 47 | GlideApp.with(context) 48 | .load(url) 49 | .circleCrop() 50 | .transition(DrawableTransitionOptions.withCrossFade()) 51 | .into(this) 52 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/utils/NetworkConnectivity.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.utils 2 | 3 | import android.content.Context 4 | import android.net.ConnectivityManager 5 | import android.net.Network 6 | import android.net.NetworkCapabilities 7 | import android.net.NetworkInfo 8 | import android.os.Build 9 | import androidx.annotation.RequiresApi 10 | import javax.inject.Inject 11 | 12 | @RequiresApi(Build.VERSION_CODES.LOLLIPOP) 13 | class Network @Inject constructor( 14 | context: Context 15 | ) : NetworkConnectivity, 16 | ConnectivityManager.NetworkCallback() { 17 | 18 | private val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager 19 | private var isOnline = false 20 | 21 | init { 22 | if (Build.VERSION.SDK_INT >= 24) { 23 | cm.registerDefaultNetworkCallback(this) 24 | } 25 | } 26 | 27 | override fun getNetworkInfo(): NetworkInfo? { 28 | return cm.activeNetworkInfo 29 | } 30 | 31 | override fun isConnected(): Boolean { 32 | if (Build.VERSION.SDK_INT >= 24) { 33 | return isOnline 34 | } 35 | val info = getNetworkInfo() 36 | return info != null && info.isConnected 37 | } 38 | 39 | override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { 40 | isOnline = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) 41 | } 42 | 43 | override fun onLost(network: Network) { 44 | isOnline = false 45 | } 46 | } 47 | 48 | interface NetworkConnectivity { 49 | fun getNetworkInfo(): NetworkInfo? 50 | fun isConnected(): Boolean 51 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/utils/NumberExt.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.utils 2 | 3 | import kotlin.math.ln 4 | import kotlin.math.pow 5 | 6 | fun Int.toPrettyString(): String { 7 | if (this < 1000) return this.toString() 8 | val exp = (ln(this.toDouble()) / ln(1000.0)).toInt() 9 | return String.format("%.1f%c", this / 1000.0.pow(exp.toDouble()), "KMBTPE"[exp - 1]) 10 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/utils/Resource.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.utils 2 | 3 | sealed class Resource(val data: T?, val msg: String?) { 4 | class Success(data: T) : Resource(data, null) 5 | class Error(message: String?) : Resource(null, message) 6 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ragvax/picttr/utils/ViewExt.kt: -------------------------------------------------------------------------------- 1 | package com.ragvax.picttr.utils 2 | 3 | import android.view.View 4 | 5 | fun View.show() { visibility = View.VISIBLE } 6 | 7 | fun View.hide() { visibility = View.GONE } -------------------------------------------------------------------------------- /app/src/main/res/drawable/gradient_dark_to_transparent.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 8 | 11 | 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_round_public_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 13 | 14 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/res/layout/content_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 15 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_gallery.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 22 | 23 | 30 | 31 | 41 | 42 | 55 | 56 | 67 | 68 | 81 | 82 | 93 | 94 | 100 | 101 | 107 | 108 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_photo_details.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 14 | 15 | 25 | 26 | 37 | 38 | 39 | 48 | 49 | 59 | 60 | 70 | 71 | 76 | 77 | 86 | 87 | 95 | 96 | 105 | 106 | 114 | 115 | 124 | 125 | 133 | 134 | 139 | 140 | 149 | 150 | 161 | 162 | 171 | 172 | 183 | 184 | 194 | 195 | 206 | 207 | 216 | 217 | 228 | 229 | 239 | 240 | 251 | 252 | 261 | 262 | 273 | 274 | 279 | 280 | 286 | 287 | 293 | 294 | 295 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_photo_zoom.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 14 | 15 | 21 | 22 | 32 | 33 | 45 | 46 | 56 | 57 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_profile.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 17 | 18 | 27 | 28 | 36 | 37 | 47 | 48 | 61 | 62 | 75 | 76 | 86 | 87 | 98 | 99 | 109 | 110 | 121 | 122 | 128 | 129 | 135 | 136 | -------------------------------------------------------------------------------- /app/src/main/res/layout/gallery_load_state_header_footer.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 17 | 18 | 28 | 29 |