├── .gitignore ├── README.md ├── app ├── build.gradle ├── proguard-rules-moshi-kotlin.pro ├── proguard-rules-moshi.pro ├── proguard-rules-retrofit.pro ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── ataulm │ │ └── artcollector │ │ ├── ApplicationComponent.kt │ │ ├── ArtCollectorApplication.kt │ │ ├── DataObserver.kt │ │ ├── DeepLinkActivity.kt │ │ ├── EventObserver.kt │ │ ├── HarvardArtMuseumApi.kt │ │ ├── Navigation.kt │ │ └── gallery │ │ ├── GalleryComponent.kt │ │ ├── GalleryModule.kt │ │ ├── data │ │ └── AndroidGalleryRepository.kt │ │ ├── domain │ │ └── GetGalleryUseCase.kt │ │ └── ui │ │ ├── GalleryActivity.kt │ │ ├── GalleryAdapter.kt │ │ ├── GallerySpacingItemDecoration.kt │ │ ├── GalleryViewModel.kt │ │ ├── GalleryViewModelFactory.kt │ │ └── UiPainting.kt │ └── res │ ├── drawable-hdpi │ └── ic_launcher.png │ ├── drawable-mdpi │ └── ic_launcher.png │ ├── drawable-xhdpi │ └── ic_launcher.png │ ├── drawable-xxhdpi │ └── ic_launcher.png │ ├── layout │ ├── activity_gallery.xml │ └── itemview_painting.xml │ └── values │ ├── dimens.xml │ ├── strings-shared-elements.xml │ ├── strings.xml │ └── styles.xml ├── artist ├── build.gradle └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── ataulm │ │ └── artcollector │ │ └── artist │ │ ├── ArtistComponent.kt │ │ ├── ArtistModule.kt │ │ ├── data │ │ └── AndroidArtistRepository.kt │ │ ├── domain │ │ ├── ArtistId.kt │ │ ├── ArtistRepository.kt │ │ ├── GetArtistGalleryUseCase.kt │ │ └── GetArtistUseCase.kt │ │ └── ui │ │ ├── ArtistActivity.kt │ │ ├── ArtistAdapter.kt │ │ ├── ArtistViewModel.kt │ │ └── ArtistViewModelFactory.kt │ └── res │ ├── layout │ ├── activity_artist.xml │ └── itemview_artist_painting.xml │ └── values │ └── strings.xml ├── build.gradle ├── debug.keystore ├── dependencies.gradle ├── domain ├── build.gradle └── src │ └── main │ └── java │ └── com │ └── ataulm │ └── artcollector │ ├── Artist.kt │ ├── Gallery.kt │ ├── Painting.kt │ └── datanotdomain │ └── GalleryRepository.kt ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── painting ├── build.gradle └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── ataulm │ │ └── artcollector │ │ └── painting │ │ ├── PaintingComponent.kt │ │ ├── PaintingModule.kt │ │ ├── data │ │ └── AndroidPaintingRepository.kt │ │ ├── domain │ │ ├── GetPaintingUseCase.kt │ │ ├── PaintingId.kt │ │ └── PaintingRepository.kt │ │ └── ui │ │ ├── PaintingActivity.kt │ │ ├── PaintingViewModel.kt │ │ └── PaintingViewModelFactory.kt │ └── res │ ├── layout │ └── activity_painting.xml │ └── values │ ├── colors.xml │ └── strings.xml └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /.idea/workspace.xml 3 | /.idea/libraries 4 | *.iml 5 | .DS_Store 6 | 7 | # Built application files 8 | *.apk 9 | *.ap_ 10 | 11 | # Files for the Dalvik VM 12 | *.dex 13 | 14 | # Java class files 15 | *.class 16 | 17 | # Generated files 18 | bin/ 19 | gen/ 20 | 21 | # Gradle files 22 | .gradle/ 23 | build/ 24 | 25 | # Local configuration file (sdk path, etc) 26 | local.properties 27 | 28 | # Proguard folder generated by Eclipse 29 | proguard/ 30 | 31 | # Log Files 32 | *.log 33 | 34 | harvard.properties 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | art collector 2 | ============= 3 | 4 | art collector displays a selection of paintings from the [Harvard Art Museum API](https://github.com/harvardartmuseums/api-docs). 5 | 6 | We need three screens: 7 | 8 | - `/` showing a collection of paintings 9 | - `/{person_id}` showing the collection of paintings by the given artist 10 | - `/{person_id}/{object_id}` showing a single painting 11 | 12 | ## Why 13 | 14 | The aim is to develop a small app so I can learn about: 15 | 16 | - Coroutines 17 | - Dagger 2 18 | - Room (or perhaps Realm, then Realm to Room migration) 19 | - Dynamic feature modules 20 | 21 | I'll keep meaningful changes restricted to PRs, and will try to keep them small and well documented. Please comment on the PRs if you have any questions/suggestions. 22 | 23 | ## Building the app 24 | 25 | To build the app, you'll need to add a Harvard Art Museums api key to Gradle properties. 26 | 27 | e.g. create `~/.gradle/gradle.properties` and stick this in there: 28 | 29 | ``` 30 | harvard_art_museums_api_key = 123abc456def 31 | ``` 32 | 33 | [Thanks for the name.](https://github.com/florina-muntenescu) 34 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | apply plugin: 'kotlin-kapt' 5 | 6 | android { 7 | compileOptions { 8 | sourceCompatibility JavaVersion.VERSION_1_8 9 | targetCompatibility JavaVersion.VERSION_1_8 10 | } 11 | 12 | compileSdkVersion versions.androidSdk.compile 13 | 14 | defaultConfig { 15 | applicationId 'com.ataulm.artcollector' 16 | minSdkVersion versions.androidSdk.min 17 | targetSdkVersion versions.androidSdk.target 18 | versionCode 1 19 | versionName '1.0' 20 | 21 | testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' 22 | buildConfigField 'String', 'HARVARD_KEY', "\"${harvard_art_museums_api_key}\"" 23 | } 24 | 25 | signingConfigs { 26 | debug { 27 | storeFile file('../debug.keystore') 28 | storePassword 'android' 29 | keyAlias 'androiddebugkey' 30 | keyPassword 'android' 31 | } 32 | } 33 | 34 | buildTypes { 35 | debug { 36 | signingConfig signingConfigs.debug 37 | } 38 | release { 39 | minifyEnabled true 40 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 41 | 'proguard-rules.pro', 42 | 'proguard-rules-moshi.pro', 43 | 'proguard-rules-moshi-kotlin.pro', 44 | 'proguard-rules-retrofit.pro' 45 | } 46 | } 47 | 48 | dynamicFeatures = [':artist', ':painting'] 49 | } 50 | 51 | dependencies { 52 | api project(':domain') 53 | api libraries.androidxAppCompat 54 | api libraries.androidxConstraintLayout 55 | api libraries.androidxLegacySupportV13 56 | api libraries.androidxLifecycleExtensions 57 | api libraries.androidxMaterial 58 | api libraries.androidxRecyclerView 59 | api libraries.dagger 60 | api libraries.glide 61 | api libraries.kotlinCoroutinesAndroid 62 | api libraries.kotlinCoroutinesCore 63 | api libraries.kotlinStdLib 64 | api libraries.photoView 65 | 66 | implementation libraries.androidxPagingRuntime 67 | debugImplementation libraries.chuck 68 | releaseImplementation libraries.chuckNoOp 69 | implementation libraries.moshi 70 | implementation libraries.retrofit 71 | implementation libraries.retrofitMoshi 72 | 73 | kapt libraries.daggerAndroid 74 | kapt libraries.daggerCompiler 75 | kapt libraries.moshiKotlinCodegen 76 | 77 | androidTestImplementation libraries.androidxTestRules 78 | androidTestImplementation libraries.androidxTestRunner 79 | androidTestImplementation libraries.androidxEspressoCore 80 | 81 | testImplementation libraries.googleTruth 82 | testImplementation libraries.jUnit 83 | testImplementation libraries.mockitoCore 84 | testImplementation libraries.mockitoKotlin 85 | } 86 | -------------------------------------------------------------------------------- /app/proguard-rules-moshi-kotlin.pro: -------------------------------------------------------------------------------- 1 | -keep class kotlin.reflect.jvm.internal.impl.builtins.BuiltInsLoaderImpl 2 | 3 | -keepclassmembers class kotlin.Metadata { 4 | public ; 5 | } 6 | -------------------------------------------------------------------------------- /app/proguard-rules-moshi.pro: -------------------------------------------------------------------------------- 1 | # JSR 305 annotations are for embedding nullability information. 2 | -dontwarn javax.annotation.** 3 | 4 | -keepclasseswithmembers class * { 5 | @com.squareup.moshi.* ; 6 | } 7 | 8 | -keep @com.squareup.moshi.JsonQualifier interface * 9 | 10 | # Enum field names are used by the integrated EnumJsonAdapter. 11 | # Annotate enums with @JsonClass(generateAdapter = false) to use them with Moshi. 12 | -keepclassmembers @com.squareup.moshi.JsonClass class * extends java.lang.Enum { 13 | ; 14 | } 15 | 16 | # The name of @JsonClass types is used to look up the generated adapter. 17 | -keepnames @com.squareup.moshi.JsonClass class * 18 | 19 | # Retain generated JsonAdapters if annotated type is retained. 20 | -if @com.squareup.moshi.JsonClass class * 21 | -keep class <1>JsonAdapter { 22 | (...); 23 | ; 24 | } 25 | -------------------------------------------------------------------------------- /app/proguard-rules-retrofit.pro: -------------------------------------------------------------------------------- 1 | # https://github.com/square/retrofit/blob/master/retrofit/src/main/resources/META-INF/proguard/retrofit2.pro 2 | 3 | # Retrofit does reflection on generic parameters and InnerClass is required to use Signature. 4 | -keepattributes Signature, InnerClasses 5 | 6 | # Retain service method parameters when optimizing. 7 | -keepclassmembers,allowshrinking,allowobfuscation interface * { 8 | @retrofit2.http.* ; 9 | } 10 | 11 | # Ignore annotation used for build tooling. 12 | -dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement 13 | 14 | # Ignore JSR 305 annotations for embedding nullability information. 15 | -dontwarn javax.annotation.** 16 | 17 | # Guarded by a NoClassDefFoundError try/catch and only used when on the classpath. 18 | -dontwarn kotlin.Unit 19 | 20 | # Top-level functions that can only be used by Kotlin. 21 | -dontwarn retrofit2.-KotlinExtensions 22 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # By default, the flags in this file are appended to flags specified 3 | # in /Users/ataulm/dev/env/android-sdk-macosx/tools/proguard/proguard-android.txt 4 | # You can edit the include path and order by changing the proguardFiles 5 | # directive in build.gradle. 6 | # 7 | # For more details, see 8 | # http://developer.android.com/guide/developing/tools/proguard.html 9 | 10 | # Add any project specific keep options here: 11 | 12 | # If your project uses WebView with JS, uncomment the following 13 | # and specify the fully qualified class name to the JavaScript interface 14 | # class: 15 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 16 | # public *; 17 | #} 18 | 19 | # Support libraries 20 | -dontwarn android.support.** 21 | -keep class android.support.v4.** { *; } 22 | -keep interface android.support.v4.app.** { *; } 23 | -keep public class * extends android.support.v4.** -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /app/src/main/java/com/ataulm/artcollector/ApplicationComponent.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector 2 | 3 | import com.bumptech.glide.Glide 4 | import com.bumptech.glide.RequestManager 5 | import com.readystatesoftware.chuck.ChuckInterceptor 6 | import dagger.BindsInstance 7 | import dagger.Component 8 | import dagger.Module 9 | import dagger.Provides 10 | import okhttp3.OkHttpClient 11 | import retrofit2.Retrofit 12 | import retrofit2.converter.moshi.MoshiConverterFactory 13 | 14 | @Component(modules = [ApplicationModule::class]) 15 | interface ApplicationComponent { 16 | 17 | fun glideRequestManager(): RequestManager 18 | 19 | fun harvardApiMuseumApi(): HarvardArtMuseumApi 20 | 21 | @Component.Builder 22 | interface Builder { 23 | 24 | @BindsInstance 25 | fun application(application: ArtCollectorApplication): Builder 26 | 27 | fun build(): ApplicationComponent 28 | } 29 | } 30 | 31 | @Module 32 | object ApplicationModule { 33 | 34 | @JvmStatic 35 | @Provides 36 | fun glideRequestManager(application: ArtCollectorApplication): RequestManager = Glide.with(application) 37 | 38 | @JvmStatic 39 | @Provides 40 | fun harvardArtMuseumApi(application: ArtCollectorApplication): HarvardArtMuseumApi { 41 | return Retrofit.Builder() 42 | .client(OkHttpClient.Builder() 43 | .addNetworkInterceptor(AddApiKeyQueryParameterInterceptor(BuildConfig.HARVARD_KEY)) 44 | .addInterceptor(ChuckInterceptor(application)) 45 | .build()) 46 | .baseUrl(HarvardArtMuseumApi.ENDPOINT) 47 | .addConverterFactory(MoshiConverterFactory.create()) 48 | .build() 49 | .create(HarvardArtMuseumApi::class.java) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/src/main/java/com/ataulm/artcollector/ArtCollectorApplication.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | 6 | class ArtCollectorApplication : Application() { 7 | 8 | private val component: ApplicationComponent by lazy { 9 | DaggerApplicationComponent 10 | .builder() 11 | .application(this) 12 | .build() 13 | } 14 | 15 | companion object { 16 | 17 | fun component(context: Context) = 18 | (context.applicationContext as ArtCollectorApplication).component 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/ataulm/artcollector/DataObserver.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector 2 | 3 | import androidx.lifecycle.Observer 4 | 5 | class DataObserver(private val handleData: (T) -> Unit) : Observer { 6 | 7 | override fun onChanged(data: T?) { 8 | if (data == null) { 9 | throw IllegalArgumentException("data should never be null") 10 | } 11 | handleData(data) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/ataulm/artcollector/DeepLinkActivity.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | 6 | private const val ARTIST = 0 7 | private const val PAINTING = 1 8 | 9 | /** 10 | * Supports http/https: 11 | * 12 | * - art-collector.ataulm.com 13 | * - art-collector.ataulm.com/{artist-id} 14 | * - art-collector.ataulm.com/{artist-id}/{painting-id} 15 | */ 16 | class DeepLinkActivity : AppCompatActivity() { 17 | 18 | override fun onCreate(savedInstanceState: Bundle?) { 19 | super.onCreate(savedInstanceState) 20 | val uri = getIntentUri() 21 | val segments = uri.pathSegments 22 | val intent = when (uri.pathSegments.size) { 23 | 2 -> paintingIntent(segments[ARTIST], segments[PAINTING]) 24 | 1 -> artistGalleryIntent(segments[ARTIST]) 25 | else -> galleryIntent() 26 | } 27 | startActivity(intent) 28 | finish() 29 | } 30 | 31 | private fun getIntentUri() = intent.data!! // only way to open this activity 32 | } 33 | -------------------------------------------------------------------------------- /app/src/main/java/com/ataulm/artcollector/EventObserver.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector 2 | 3 | import androidx.lifecycle.Observer 4 | 5 | class EventObserver(private val handleEvent: (T) -> Unit) : Observer> { 6 | 7 | override fun onChanged(event: Event?) { 8 | event?.value()?.let { handleEvent(it) } 9 | } 10 | } 11 | 12 | class Event(private val value: T) { 13 | 14 | private var delivered = false 15 | 16 | fun value() = if (delivered) { 17 | null 18 | } else { 19 | delivered = true 20 | value 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/com/ataulm/artcollector/HarvardArtMuseumApi.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | import kotlinx.coroutines.Deferred 6 | import okhttp3.Interceptor 7 | import okhttp3.Response 8 | import retrofit2.http.GET 9 | import retrofit2.http.Path 10 | import retrofit2.http.Query 11 | 12 | interface HarvardArtMuseumApi { 13 | 14 | @GET("object?$PAINTINGS_&$WITH_IMAGES_&$WITH_ARTIST_AND_ACCESS_TO_IMAGES_&$INC_FIELDS") 15 | suspend fun gallery( 16 | @Query("size") pageSize: Int, 17 | @Query("page") page: Int? = null 18 | ): ApiPaintingsResponse 19 | 20 | @GET("person") 21 | suspend fun artist(@Query("q") qValue: String): ApiPersonResponse 22 | 23 | @GET("object?$PAINTINGS_&$WITH_IMAGES_&$INC_FIELDS") 24 | suspend fun artistGallery(@Query("person") artistId: String): ApiPaintingsResponse 25 | 26 | @GET("object/{object_id}?$INC_FIELDS") 27 | suspend fun painting(@Path("object_id") id: String): ApiObjectRecord 28 | 29 | companion object { 30 | 31 | const val ENDPOINT = "https://api.harvardartmuseums.org" 32 | private const val PAINTINGS_ = "classification=26" 33 | private const val WITH_IMAGES_ = "hasimage=1" 34 | private const val WITH_ARTIST_AND_ACCESS_TO_IMAGES_ = "q=people.role:Artist AND imagepermissionlevel:0" 35 | private const val INC_FIELDS = "fields=id,title,description,primaryimageurl,people,url,creditline" 36 | } 37 | } 38 | 39 | internal class AddApiKeyQueryParameterInterceptor(private val apiKey: String) : Interceptor { 40 | 41 | override fun intercept(chain: Interceptor.Chain): Response { 42 | val url = chain.request().url().newBuilder() 43 | .addQueryParameter("apikey", apiKey) 44 | .build() 45 | val request = chain.request().newBuilder().url(url).build() 46 | return chain.proceed(request) 47 | } 48 | } 49 | 50 | @JsonClass(generateAdapter = true) 51 | data class ApiPaintingsResponse( 52 | @Json(name = "info") val info: ApiInfo, 53 | @Json(name = "records") val records: List 54 | ) 55 | 56 | @JsonClass(generateAdapter = true) 57 | data class ApiPersonResponse( 58 | @Json(name = "info") val info: ApiInfo, 59 | @Json(name = "records") val records: List 60 | ) 61 | 62 | @JsonClass(generateAdapter = true) 63 | data class ApiInfo( 64 | @Json(name = "totalrecordsperquery") val totalRecordsPerQuery: Int, 65 | @Json(name = "totalrecords") val totalRecords: Int, 66 | @Json(name = "pages") val pages: Int, 67 | @Json(name = "page") val page: Int 68 | ) 69 | 70 | @JsonClass(generateAdapter = true) 71 | data class ApiPersonRecord( 72 | @Json(name = "personid") val personId: Int, 73 | @Json(name = "displayname") val displayName: String 74 | ) 75 | 76 | @JsonClass(generateAdapter = true) 77 | data class ApiObjectRecord( 78 | @Json(name = "id") val id: Int, 79 | @Json(name = "title") val title: String, 80 | @Json(name = "description") val description: String?, 81 | @Json(name = "url") val url: String, 82 | @Json(name = "creditline") val creditLine: String?, 83 | @Json(name = "primaryimageurl") val primaryImageUrl: String, 84 | @Json(name = "people") val people: List 85 | ) 86 | 87 | @JsonClass(generateAdapter = true) 88 | data class ApiPerson( 89 | @Json(name = "personid") val personId: Int, 90 | @Json(name = "name") val name: String, 91 | @Json(name = "role") val role: String 92 | ) 93 | -------------------------------------------------------------------------------- /app/src/main/java/com/ataulm/artcollector/Navigation.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | 6 | private const val SCHEME = "https" 7 | private const val AUTHORITY = "art-collector.ataulm.com" 8 | private const val GALLERY = "${BuildConfig.APPLICATION_ID}.gallery.ui.GalleryActivity" 9 | private const val ARTIST_GALLERY = "${BuildConfig.APPLICATION_ID}.artist.ui.ArtistActivity" 10 | private const val PAINTING = "${BuildConfig.APPLICATION_ID}.painting.ui.PaintingActivity" 11 | 12 | private const val INTENT_EXTRA_IMAGE_URL = "${BuildConfig.APPLICATION_ID}.IMAGE_URL" 13 | fun Intent.imageUrl(): String? = getStringExtra(INTENT_EXTRA_IMAGE_URL) 14 | 15 | fun webIntent(webUrl: String): Intent = Intent(Intent.ACTION_VIEW).setData(Uri.parse(webUrl)) 16 | 17 | fun galleryIntent() = intent(GALLERY) 18 | 19 | fun artistGalleryIntent(artistId: String): Intent { 20 | val uri = Uri.Builder() 21 | .scheme(SCHEME) 22 | .authority(AUTHORITY) 23 | .path(artistId) 24 | .build() 25 | 26 | return intent(ARTIST_GALLERY, uri) 27 | } 28 | 29 | fun paintingIntent(artistId: String, paintingId: String, imageUrl: String? = null): Intent { 30 | val uri = Uri.Builder() 31 | .scheme(SCHEME) 32 | .authority(AUTHORITY) 33 | .path("$artistId/$paintingId") 34 | .build() 35 | 36 | return intent(PAINTING, uri) 37 | .putExtra(INTENT_EXTRA_IMAGE_URL, imageUrl) 38 | } 39 | 40 | private fun intent(componentName: String, uri: Uri? = null): Intent { 41 | return Intent(Intent.ACTION_VIEW) 42 | .setClassName(BuildConfig.APPLICATION_ID, componentName) 43 | .setData(uri) 44 | } 45 | -------------------------------------------------------------------------------- /app/src/main/java/com/ataulm/artcollector/gallery/GalleryComponent.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.gallery 2 | 3 | import com.ataulm.artcollector.ApplicationComponent 4 | import com.ataulm.artcollector.ArtCollectorApplication 5 | import com.ataulm.artcollector.gallery.ui.GalleryActivity 6 | import dagger.BindsInstance 7 | import dagger.Component 8 | 9 | @Component(modules = [GalleryModule::class], dependencies = [ApplicationComponent::class]) 10 | internal interface PaintingsComponent { 11 | 12 | fun inject(activity: GalleryActivity) 13 | 14 | @Component.Builder 15 | interface Builder { 16 | 17 | @BindsInstance 18 | fun activity(activity: GalleryActivity): Builder 19 | 20 | fun withParent(component: ApplicationComponent): Builder 21 | 22 | fun build(): PaintingsComponent 23 | } 24 | } 25 | 26 | internal fun GalleryActivity.injectDependencies() { 27 | DaggerPaintingsComponent.builder() 28 | .withParent(ArtCollectorApplication.component(this)) 29 | .activity(this) 30 | .build() 31 | .inject(this) 32 | } 33 | -------------------------------------------------------------------------------- /app/src/main/java/com/ataulm/artcollector/gallery/GalleryModule.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.gallery 2 | 3 | import androidx.lifecycle.ViewModelProviders 4 | import com.ataulm.artcollector.datanotdomain.GalleryRepository 5 | import com.ataulm.artcollector.gallery.data.AndroidGalleryRepository 6 | import com.ataulm.artcollector.gallery.ui.GalleryActivity 7 | import com.ataulm.artcollector.gallery.ui.GalleryViewModel 8 | import com.ataulm.artcollector.gallery.ui.GalleryViewModelFactory 9 | import dagger.Module 10 | import dagger.Provides 11 | 12 | @Module 13 | internal object GalleryModule { 14 | 15 | @JvmStatic 16 | @Provides 17 | fun paintingsRepository(galleryRepository: AndroidGalleryRepository): GalleryRepository { 18 | return galleryRepository 19 | } 20 | 21 | @JvmStatic 22 | @Provides 23 | fun viewModel(activity: GalleryActivity, viewModelFactory: GalleryViewModelFactory) = 24 | ViewModelProviders.of(activity, viewModelFactory).get(GalleryViewModel::class.java) 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/java/com/ataulm/artcollector/gallery/data/AndroidGalleryRepository.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.gallery.data 2 | 3 | import androidx.paging.PagingSource 4 | import com.ataulm.artcollector.ApiObjectRecord 5 | import com.ataulm.artcollector.Artist 6 | import com.ataulm.artcollector.HarvardArtMuseumApi 7 | import com.ataulm.artcollector.Painting 8 | import com.ataulm.artcollector.datanotdomain.GalleryRepository 9 | import javax.inject.Inject 10 | 11 | internal class AndroidGalleryRepository @Inject constructor( 12 | private val harvardArtMuseumApi: HarvardArtMuseumApi 13 | ) : GalleryRepository { 14 | 15 | override fun gallery(): PagingSource { 16 | return GalleryPagingSource(harvardArtMuseumApi) 17 | } 18 | } 19 | 20 | private class GalleryPagingSource(private val harvardArtMuseumApi: HarvardArtMuseumApi) 21 | : PagingSource() { 22 | 23 | override suspend fun load(params: LoadParams): LoadResult { 24 | val result = harvardArtMuseumApi.gallery(page = params.key, pageSize = params.loadSize) 25 | // TODO: handle errors 26 | return LoadResult.Page( 27 | data = result.records.map { it.toPainting() }, 28 | prevKey = if (result.info.page == 1) null else result.info.page - 1, 29 | nextKey = if (result.info.page == result.info.pages) null else result.info.page + 1 30 | ) 31 | } 32 | 33 | private fun ApiObjectRecord.toPainting(): Painting { 34 | val apiPerson = people.first() 35 | return Painting( 36 | id.toString(), 37 | title, 38 | url, 39 | description, 40 | creditLine, 41 | primaryImageUrl, 42 | Artist(apiPerson.personId.toString(), apiPerson.name) 43 | ) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/src/main/java/com/ataulm/artcollector/gallery/domain/GetGalleryUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.gallery.domain 2 | 3 | import com.ataulm.artcollector.datanotdomain.GalleryRepository 4 | import javax.inject.Inject 5 | 6 | internal class GetGalleryUseCase @Inject constructor( 7 | private val repository: GalleryRepository 8 | ) { 9 | 10 | operator fun invoke() = repository.gallery() 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/ataulm/artcollector/gallery/ui/GalleryActivity.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.gallery.ui 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import androidx.core.app.ActivityOptionsCompat 6 | import androidx.core.util.Pair 7 | import androidx.lifecycle.lifecycleScope 8 | import com.ataulm.artcollector.EventObserver 9 | import com.ataulm.artcollector.R 10 | import com.ataulm.artcollector.artistGalleryIntent 11 | import com.ataulm.artcollector.gallery.injectDependencies 12 | import com.ataulm.artcollector.paintingIntent 13 | import com.bumptech.glide.RequestManager 14 | import kotlinx.android.synthetic.main.activity_gallery.* 15 | import kotlinx.coroutines.flow.collectLatest 16 | import kotlinx.coroutines.launch 17 | import javax.inject.Inject 18 | 19 | class GalleryActivity : AppCompatActivity() { 20 | 21 | @Inject 22 | internal lateinit var viewModel: GalleryViewModel 23 | 24 | @Inject 25 | internal lateinit var glideRequestManager: RequestManager 26 | 27 | override fun onCreate(savedInstanceState: Bundle?) { 28 | injectDependencies() 29 | super.onCreate(savedInstanceState) 30 | setContentView(R.layout.activity_gallery) 31 | 32 | val adapter = GalleryAdapter(glideRequestManager) 33 | recyclerView.adapter = adapter 34 | recyclerView.addItemDecoration(GallerySpacingItemDecoration(resources)) 35 | lifecycleScope.launch { 36 | viewModel.pagedGallery().collectLatest { adapter.submitData(it) } 37 | } 38 | 39 | viewModel.events.observe(this, EventObserver { command -> 40 | when (command) { 41 | is NavigateToArtistGallery -> navigateToArtistGallery(command) 42 | is NavigateToPainting -> navigateToPainting(command) 43 | } 44 | }) 45 | } 46 | 47 | private fun navigateToArtistGallery(it: NavigateToArtistGallery) { 48 | val intent = artistGalleryIntent(it.artistId) 49 | startActivity(intent) 50 | } 51 | 52 | private fun navigateToPainting(command: NavigateToPainting) { 53 | val paintingIntent = paintingIntent(command.artistId, command.paintingId, command.imageUrl) 54 | val options = ActivityOptionsCompat.makeSceneTransitionAnimation(this, Pair(command.view, getString(R.string.shared_element_painting))) 55 | startActivity(paintingIntent, options.toBundle()) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/src/main/java/com/ataulm/artcollector/gallery/ui/GalleryAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.gallery.ui 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import androidx.paging.PagingDataAdapter 7 | import androidx.recyclerview.widget.DiffUtil 8 | import androidx.recyclerview.widget.ListAdapter 9 | import androidx.recyclerview.widget.RecyclerView 10 | import com.ataulm.artcollector.R 11 | import com.bumptech.glide.RequestManager 12 | import kotlinx.android.synthetic.main.itemview_painting.view.* 13 | 14 | internal class GalleryAdapter(private val glideRequestManager: RequestManager) 15 | : PagingDataAdapter(PaintingDiffer) { 16 | 17 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PaintingViewHolder { 18 | val view = LayoutInflater.from(parent.context).inflate(R.layout.itemview_painting, parent, false) 19 | return PaintingViewHolder(glideRequestManager, view) 20 | } 21 | 22 | override fun onBindViewHolder(viewHolder: PaintingViewHolder, position: Int) { 23 | // the item might be null while loading - TODO: placeholders 24 | getItem(position)?.let { viewHolder.bind(it) } 25 | } 26 | 27 | object PaintingDiffer : DiffUtil.ItemCallback() { 28 | override fun areItemsTheSame(oldItem: UiPainting, newItem: UiPainting) = oldItem.id == newItem.id 29 | override fun areContentsTheSame(oldItem: UiPainting, newItem: UiPainting) = oldItem == newItem 30 | } 31 | 32 | internal class PaintingViewHolder( 33 | private val glideRequestManager: RequestManager, 34 | view: View 35 | ) : RecyclerView.ViewHolder(view) { 36 | 37 | fun bind(item: UiPainting) { 38 | itemView.setOnClickListener { item.onClickPainting(itemView.imageView) } 39 | itemView.artistTextView.text = item.artistName 40 | itemView.artistTextView.setOnClickListener { item.onClickArtist() } 41 | glideRequestManager 42 | .load(item.imageUrl) 43 | .into(itemView.imageView) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/src/main/java/com/ataulm/artcollector/gallery/ui/GallerySpacingItemDecoration.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.gallery.ui 2 | 3 | import android.content.res.Resources 4 | import android.graphics.Rect 5 | import android.view.View 6 | import androidx.recyclerview.widget.RecyclerView 7 | import com.ataulm.artcollector.R 8 | 9 | class GallerySpacingItemDecoration(resources: Resources) : RecyclerView.ItemDecoration() { 10 | 11 | private val offsetPx: Int = resources.getDimensionPixelSize(R.dimen.gallery_spacing_half) 12 | 13 | override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { 14 | super.getItemOffsets(outRect, view, parent, state) 15 | outRect.set(offsetPx, offsetPx, offsetPx, offsetPx) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/com/ataulm/artcollector/gallery/ui/GalleryViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.gallery.ui 2 | 3 | import android.view.View 4 | import androidx.lifecycle.LiveData 5 | import androidx.lifecycle.MutableLiveData 6 | import androidx.lifecycle.ViewModel 7 | import androidx.paging.* 8 | import com.ataulm.artcollector.Event 9 | import com.ataulm.artcollector.Painting 10 | import com.ataulm.artcollector.gallery.domain.GetGalleryUseCase 11 | import kotlinx.coroutines.CoroutineScope 12 | import kotlinx.coroutines.Job 13 | import kotlinx.coroutines.flow.Flow 14 | import kotlinx.coroutines.flow.map 15 | import javax.inject.Inject 16 | 17 | internal class GalleryViewModel @Inject constructor( 18 | private val getGallery: GetGalleryUseCase 19 | ) : ViewModel() { 20 | 21 | private val _events = MutableLiveData>() 22 | val events: LiveData> 23 | get() = _events 24 | 25 | private val parentJob = Job() 26 | private val coroutineScope = CoroutineScope(parentJob) 27 | 28 | fun pagedGallery(): Flow> { 29 | val pagingSource = getGallery() 30 | val pager = Pager(config = PagingConfig(pageSize = 9)) { pagingSource } 31 | return pager.flow.map { paintingPagingData: PagingData -> 32 | paintingPagingData.map { it.toUiModel() } 33 | // TODO: we can remove some of this boilerplate 34 | // https://medium.com/androiddevelopers/easy-coroutines-in-android-viewmodelscope-25bffb605471 35 | }.cachedIn(coroutineScope) 36 | } 37 | 38 | private fun Painting.toUiModel() = UiPainting( 39 | id = id, 40 | title = title, 41 | imageUrl = imageUrl, 42 | artistId = artist.id, 43 | artistName = artist.name, 44 | onClickPainting = { 45 | onClick( 46 | artistId = artist.id, 47 | paintingId = id, 48 | imageUrl = imageUrl, 49 | view = it 50 | ) 51 | }, 52 | onClickArtist = { onClickArtist(artist.id) } 53 | ) 54 | 55 | private fun onClick( 56 | artistId: String, 57 | paintingId: String, 58 | imageUrl: String, 59 | view: View 60 | ) { 61 | _events.value = Event(NavigateToPainting(artistId, paintingId, imageUrl, view)) 62 | } 63 | 64 | private fun onClickArtist(artistId: String) { 65 | _events.value = Event(NavigateToArtistGallery(artistId)) 66 | } 67 | 68 | override fun onCleared() { 69 | super.onCleared() 70 | parentJob.cancel() 71 | } 72 | } 73 | 74 | internal sealed class NavigateCommand 75 | internal data class NavigateToPainting( 76 | val artistId: String, 77 | val paintingId: String, 78 | val imageUrl: String, 79 | val view: View 80 | ) : NavigateCommand() 81 | 82 | internal data class NavigateToArtistGallery(val artistId: String) : NavigateCommand() 83 | -------------------------------------------------------------------------------- /app/src/main/java/com/ataulm/artcollector/gallery/ui/GalleryViewModelFactory.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.gallery.ui 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import com.ataulm.artcollector.gallery.domain.GetGalleryUseCase 6 | import javax.inject.Inject 7 | 8 | internal class GalleryViewModelFactory @Inject constructor( 9 | private val getGalleryUseCase: GetGalleryUseCase 10 | ) : ViewModelProvider.Factory { 11 | 12 | @Suppress("UNCHECKED_CAST") 13 | override fun create(modelClass: Class) = 14 | GalleryViewModel(getGalleryUseCase) as T 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/ataulm/artcollector/gallery/ui/UiPainting.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.gallery.ui 2 | 3 | import android.view.View 4 | 5 | internal data class UiPainting( 6 | val id: String, 7 | val title: String, 8 | val imageUrl: String, 9 | val artistId: String, 10 | val artistName: String, 11 | val onClickPainting: (View) -> Unit, 12 | val onClickArtist: () -> Unit 13 | ) 14 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ataulm/art-collector/902da0700e4f3934a1edcad80205718d79744d11/app/src/main/res/drawable-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ataulm/art-collector/902da0700e4f3934a1edcad80205718d79744d11/app/src/main/res/drawable-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ataulm/art-collector/902da0700e4f3934a1edcad80205718d79744d11/app/src/main/res/drawable-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ataulm/art-collector/902da0700e4f3934a1edcad80205718d79744d11/app/src/main/res/drawable-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_gallery.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/layout/itemview_painting.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 15 | 16 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8dp 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings-shared-elements.xml: -------------------------------------------------------------------------------- 1 | 2 | painting 3 | 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | art collector 3 | 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 11 | 12 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /artist/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.dynamic-feature' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | apply plugin: 'kotlin-kapt' 5 | 6 | android { 7 | compileOptions { 8 | sourceCompatibility JavaVersion.VERSION_1_8 9 | targetCompatibility JavaVersion.VERSION_1_8 10 | } 11 | compileSdkVersion versions.androidSdk.compile 12 | defaultConfig { 13 | minSdkVersion versions.androidSdk.min 14 | targetSdkVersion versions.androidSdk.target 15 | } 16 | } 17 | 18 | dependencies { 19 | implementation project(':app') 20 | 21 | kapt libraries.daggerCompiler 22 | } 23 | -------------------------------------------------------------------------------- /artist/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /artist/src/main/java/com/ataulm/artcollector/artist/ArtistComponent.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.artist 2 | 3 | import com.ataulm.artcollector.ApplicationComponent 4 | import com.ataulm.artcollector.ArtCollectorApplication 5 | import com.ataulm.artcollector.artist.domain.ArtistId 6 | import com.ataulm.artcollector.artist.ui.ArtistActivity 7 | import dagger.BindsInstance 8 | import dagger.Component 9 | 10 | @Component(modules = [ArtistModule::class], dependencies = [ApplicationComponent::class]) 11 | internal interface ArtistComponent { 12 | 13 | fun inject(activity: ArtistActivity) 14 | 15 | @Component.Builder 16 | interface Builder { 17 | 18 | @BindsInstance 19 | fun activity(activity: ArtistActivity): Builder 20 | 21 | fun withParent(component: ApplicationComponent): Builder 22 | 23 | @BindsInstance 24 | fun with(artistId: ArtistId): Builder 25 | 26 | fun build(): ArtistComponent 27 | } 28 | } 29 | 30 | internal fun ArtistActivity.injectDependencies(artistId: ArtistId) = DaggerArtistComponent.builder() 31 | .withParent(ArtCollectorApplication.component(this)) 32 | .activity(this) 33 | .with(artistId) 34 | .build() 35 | .inject(this) 36 | -------------------------------------------------------------------------------- /artist/src/main/java/com/ataulm/artcollector/artist/ArtistModule.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.artist 2 | 3 | import androidx.lifecycle.ViewModelProviders 4 | import com.ataulm.artcollector.artist.data.AndroidArtistRepository 5 | import com.ataulm.artcollector.artist.domain.ArtistRepository 6 | import com.ataulm.artcollector.artist.ui.ArtistActivity 7 | import com.ataulm.artcollector.artist.ui.ArtistViewModel 8 | import com.ataulm.artcollector.artist.ui.ArtistViewModelFactory 9 | import dagger.Module 10 | import dagger.Provides 11 | 12 | @Module 13 | internal object ArtistModule { 14 | 15 | @JvmStatic 16 | @Provides 17 | fun artistRepository(artistRepository: AndroidArtistRepository): ArtistRepository { 18 | return artistRepository 19 | } 20 | 21 | @JvmStatic 22 | @Provides 23 | fun viewModel(activity: ArtistActivity, viewModelFactory: ArtistViewModelFactory) = 24 | ViewModelProviders.of(activity, viewModelFactory).get(ArtistViewModel::class.java) 25 | } 26 | -------------------------------------------------------------------------------- /artist/src/main/java/com/ataulm/artcollector/artist/data/AndroidArtistRepository.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.artist.data 2 | 3 | import com.ataulm.artcollector.ApiObjectRecord 4 | import com.ataulm.artcollector.ApiPerson 5 | import com.ataulm.artcollector.ApiPersonRecord 6 | import com.ataulm.artcollector.HarvardArtMuseumApi 7 | import com.ataulm.artcollector.artist.domain.ArtistId 8 | import com.ataulm.artcollector.artist.domain.ArtistRepository 9 | import com.ataulm.artcollector.Artist 10 | import com.ataulm.artcollector.Gallery 11 | import com.ataulm.artcollector.Painting 12 | import javax.inject.Inject 13 | 14 | internal class AndroidArtistRepository @Inject constructor( 15 | private val harvardArtMuseumApi: HarvardArtMuseumApi, 16 | private val artistId: ArtistId 17 | ) : ArtistRepository { 18 | 19 | override suspend fun artist(): Artist { 20 | val qValue = "personid:${artistId.value}" 21 | return harvardArtMuseumApi.artist(qValue).records.first().toArtist() 22 | } 23 | 24 | override suspend fun artistGallery(): Gallery { 25 | val paintings = harvardArtMuseumApi.artistGallery(artistId.value).records 26 | .map { it.toPainting() } 27 | return Gallery(paintings) 28 | } 29 | 30 | private fun ApiObjectRecord.toPainting(): Painting { 31 | val apiPerson = people.first() 32 | return Painting( 33 | id.toString(), 34 | title, 35 | url, 36 | description, 37 | creditLine, 38 | primaryImageUrl, 39 | apiPerson.toArtist() 40 | ) 41 | } 42 | 43 | private fun ApiPerson.toArtist() = Artist(personId.toString(), name) 44 | private fun ApiPersonRecord.toArtist() = Artist(personId.toString(), displayName) 45 | } 46 | -------------------------------------------------------------------------------- /artist/src/main/java/com/ataulm/artcollector/artist/domain/ArtistId.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.artist.domain 2 | 3 | internal data class ArtistId(val value: String) 4 | -------------------------------------------------------------------------------- /artist/src/main/java/com/ataulm/artcollector/artist/domain/ArtistRepository.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.artist.domain 2 | 3 | import com.ataulm.artcollector.Artist 4 | import com.ataulm.artcollector.Gallery 5 | 6 | internal interface ArtistRepository { 7 | 8 | suspend fun artist(): Artist 9 | 10 | suspend fun artistGallery(): Gallery 11 | } 12 | -------------------------------------------------------------------------------- /artist/src/main/java/com/ataulm/artcollector/artist/domain/GetArtistGalleryUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.artist.domain 2 | 3 | import javax.inject.Inject 4 | 5 | internal class GetArtistGalleryUseCase @Inject constructor( 6 | private val repository: ArtistRepository 7 | ) { 8 | 9 | suspend operator fun invoke() = repository.artistGallery() 10 | } 11 | -------------------------------------------------------------------------------- /artist/src/main/java/com/ataulm/artcollector/artist/domain/GetArtistUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.artist.domain 2 | 3 | import javax.inject.Inject 4 | 5 | internal class GetArtistUseCase @Inject constructor( 6 | private val repository: ArtistRepository 7 | ) { 8 | 9 | suspend operator fun invoke() = repository.artist() 10 | } 11 | -------------------------------------------------------------------------------- /artist/src/main/java/com/ataulm/artcollector/artist/ui/ArtistActivity.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.artist.ui 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.appcompat.app.AppCompatActivity 6 | import androidx.core.app.ActivityOptionsCompat 7 | import androidx.core.util.Pair 8 | import androidx.recyclerview.widget.RecyclerView 9 | import com.ataulm.artcollector.DataObserver 10 | import com.ataulm.artcollector.EventObserver 11 | import com.ataulm.artcollector.artist.R 12 | import com.ataulm.artcollector.artist.domain.ArtistId 13 | import com.ataulm.artcollector.artist.injectDependencies 14 | import com.ataulm.artcollector.gallery.ui.GallerySpacingItemDecoration 15 | import com.ataulm.artcollector.paintingIntent 16 | import com.bumptech.glide.RequestManager 17 | import kotlinx.android.synthetic.main.activity_artist.* 18 | import kotlinx.android.synthetic.main.itemview_artist_painting.view.* 19 | import javax.inject.Inject 20 | 21 | class ArtistActivity : AppCompatActivity() { 22 | 23 | @Inject 24 | internal lateinit var viewModel: ArtistViewModel 25 | 26 | @Inject 27 | internal lateinit var glideRequestManager: RequestManager 28 | 29 | override fun onCreate(savedInstanceState: Bundle?) { 30 | super.onCreate(savedInstanceState) 31 | setContentView(R.layout.activity_artist) 32 | 33 | val artistId = intent.data!!.pathSegments.last() 34 | injectDependencies(ArtistId(artistId)) 35 | 36 | val adapter = ArtistAdapter(glideRequestManager) { viewModel.onClick(it) } 37 | recyclerView.adapter = adapter 38 | recyclerView.addItemDecoration(GallerySpacingItemDecoration(resources)) 39 | 40 | viewModel.artistGallery.observe(this, DataObserver { artistGallery -> 41 | title = artistGallery.artist.name 42 | adapter.submitList(artistGallery.gallery) 43 | }) 44 | 45 | viewModel.events.observe(this, EventObserver { command -> 46 | val (painting, adapterPosition) = command.painting to command.adapterPosition 47 | val paintingIntent = paintingIntent(painting.artist.id, painting.id, painting.imageUrl) 48 | val options = ActivityOptionsCompat.makeSceneTransitionAnimation(this, recyclerView.sharedElements(adapterPosition)) 49 | startActivity(paintingIntent, options.toBundle()) 50 | }) 51 | } 52 | 53 | private fun RecyclerView.sharedElements(adapterPosition: Int): Pair { 54 | val itemView = layoutManager?.findViewByPosition(adapterPosition)!! 55 | return Pair(itemView.imageView as View, getString(com.ataulm.artcollector.R.string.shared_element_painting)) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /artist/src/main/java/com/ataulm/artcollector/artist/ui/ArtistAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.artist.ui 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import androidx.recyclerview.widget.DiffUtil 7 | import androidx.recyclerview.widget.ListAdapter 8 | import androidx.recyclerview.widget.RecyclerView 9 | import com.ataulm.artcollector.Painting 10 | import com.ataulm.artcollector.artist.R 11 | import com.bumptech.glide.RequestManager 12 | import kotlinx.android.synthetic.main.itemview_artist_painting.view.* 13 | 14 | internal class ArtistAdapter constructor( 15 | private val glideRequestManager: RequestManager, 16 | private val onClick: (Int) -> Unit 17 | ) : ListAdapter(PaintingDiffer) { 18 | 19 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PaintingViewHolder { 20 | val view = LayoutInflater.from(parent.context).inflate(R.layout.itemview_artist_painting, parent, false) 21 | return PaintingViewHolder(glideRequestManager, onClick, view) 22 | } 23 | 24 | override fun onBindViewHolder(viewHolder: PaintingViewHolder, position: Int) = viewHolder.bind(getItem(position)) 25 | 26 | object PaintingDiffer : DiffUtil.ItemCallback() { 27 | override fun areItemsTheSame(oldItem: Painting, newItem: Painting) = oldItem.id == newItem.id 28 | override fun areContentsTheSame(oldItem: Painting, newItem: Painting) = oldItem == newItem 29 | } 30 | 31 | internal class PaintingViewHolder( 32 | private val glideRequestManager: RequestManager, 33 | private val onClick: (Int) -> Unit, 34 | view: View 35 | ) : RecyclerView.ViewHolder(view) { 36 | 37 | fun bind(item: Painting) { 38 | itemView.setOnClickListener { onClick(adapterPosition) } 39 | glideRequestManager.load(item.imageUrl).into(itemView.imageView) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /artist/src/main/java/com/ataulm/artcollector/artist/ui/ArtistViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.artist.ui 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.MediatorLiveData 5 | import androidx.lifecycle.MutableLiveData 6 | import androidx.lifecycle.ViewModel 7 | import com.ataulm.artcollector.Event 8 | import com.ataulm.artcollector.artist.domain.GetArtistGalleryUseCase 9 | import com.ataulm.artcollector.artist.domain.GetArtistUseCase 10 | import com.ataulm.artcollector.Artist 11 | import com.ataulm.artcollector.Gallery 12 | import com.ataulm.artcollector.Painting 13 | import kotlinx.coroutines.* 14 | import javax.inject.Inject 15 | 16 | internal class ArtistViewModel @Inject constructor( 17 | private val getArtist: GetArtistUseCase, 18 | private val getArtistGallery: GetArtistGalleryUseCase 19 | ) : ViewModel() { 20 | 21 | private val _artist = MutableLiveData() 22 | private val _gallery = MutableLiveData() 23 | 24 | private val _artistGallery = ArtistGalleryMediatorLiveData() 25 | val artistGallery: LiveData = _artistGallery 26 | 27 | private val _events = MutableLiveData>() 28 | val events: LiveData> 29 | get() = _events 30 | 31 | private val parentJob = Job() 32 | private val coroutineScope = CoroutineScope(parentJob) 33 | 34 | init { 35 | _artistGallery.addSource(_artist) { it?.let { _artistGallery.update(it) } } 36 | _artistGallery.addSource(_gallery) { it?.let { _artistGallery.update(it) } } 37 | 38 | coroutineScope.launch(Dispatchers.IO) { 39 | val artist = getArtist() 40 | withContext(Dispatchers.Main) { _artist.value = artist } 41 | } 42 | 43 | coroutineScope.launch(Dispatchers.IO) { 44 | val gallery = getArtistGallery() 45 | withContext(Dispatchers.Main) { _gallery.value = gallery } 46 | } 47 | } 48 | 49 | fun onClick(adapterPosition: Int) { 50 | val painting = _gallery.value!![adapterPosition] 51 | _events.value = Event(NavigateToPainting(painting, adapterPosition)) 52 | } 53 | 54 | override fun onCleared() { 55 | super.onCleared() 56 | parentJob.cancel() 57 | } 58 | } 59 | 60 | internal data class NavigateToPainting(val painting: Painting, val adapterPosition: Int) 61 | 62 | internal data class ArtistGallery(val artist: Artist, val gallery: Gallery) 63 | 64 | private class ArtistGalleryMediatorLiveData : MediatorLiveData() { 65 | 66 | private var artist: Artist? = null 67 | private var gallery: Gallery? = null 68 | 69 | fun update(artist: Artist) { 70 | this.artist = artist 71 | onUpdate() 72 | } 73 | 74 | fun update(gallery: Gallery) { 75 | this.gallery = gallery 76 | onUpdate() 77 | } 78 | 79 | private fun onUpdate() { 80 | val nonNullArtist = artist ?: return 81 | val nonNullGallery = gallery ?: return 82 | value = ArtistGallery(nonNullArtist, nonNullGallery) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /artist/src/main/java/com/ataulm/artcollector/artist/ui/ArtistViewModelFactory.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.artist.ui 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import com.ataulm.artcollector.artist.domain.GetArtistGalleryUseCase 6 | import com.ataulm.artcollector.artist.domain.GetArtistUseCase 7 | import javax.inject.Inject 8 | 9 | internal class ArtistViewModelFactory @Inject constructor( 10 | private val artistUseCase: GetArtistUseCase, 11 | private val artistGalleryUseCase: GetArtistGalleryUseCase 12 | ) : ViewModelProvider.Factory { 13 | 14 | @Suppress("UNCHECKED_CAST") 15 | override fun create(modelClass: Class) = 16 | ArtistViewModel(artistUseCase, artistGalleryUseCase) as T 17 | } 18 | -------------------------------------------------------------------------------- /artist/src/main/res/layout/activity_artist.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | -------------------------------------------------------------------------------- /artist/src/main/res/layout/itemview_artist_painting.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | -------------------------------------------------------------------------------- /artist/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Artist 3 | 4 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | apply from: 'dependencies.gradle' 3 | repositories { 4 | google() 5 | mavenCentral() 6 | } 7 | dependencies { 8 | classpath libraries.build.androidGradle 9 | classpath libraries.build.kotlinGradle 10 | } 11 | } 12 | 13 | allprojects { 14 | repositories { 15 | google() 16 | mavenCentral() 17 | maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' } 18 | maven { url "https://jitpack.io" } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /debug.keystore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ataulm/art-collector/902da0700e4f3934a1edcad80205718d79744d11/debug.keystore -------------------------------------------------------------------------------- /dependencies.gradle: -------------------------------------------------------------------------------- 1 | ext { 2 | versions = [ 3 | androidSdk : [ 4 | compile: 30, 5 | min : 24, 6 | target : 30 7 | ], 8 | chuck : '1.1.0', 9 | dagger : '2.16', 10 | kotlin : '1.4.30', 11 | kotlinCoroutines: '1.4.2', 12 | moshi : '1.11.0', 13 | paging : '3.0.0-alpha03' 14 | ] 15 | 16 | libraries = [ 17 | build : [ 18 | androidGradle : 'com.android.tools.build:gradle:7.0.0-alpha05', 19 | kotlinGradle : "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}" 20 | ], 21 | androidxAppCompat : 'androidx.appcompat:appcompat:1.0.2', 22 | androidxConstraintLayout : 'androidx.constraintlayout:constraintlayout:2.0.0-alpha2', 23 | androidxEspressoCore : 'androidx.test.espresso:espresso-core:3.1.0', 24 | androidxLegacySupportV13 : 'androidx.legacy:legacy-support-v13:1.0.0', 25 | androidxLifecycleExtensions: 'androidx.lifecycle:lifecycle-extensions:2.0.0', 26 | androidxMaterial : 'com.google.android.material:material:1.1.0-alpha01', 27 | androidxPagingCommon : "androidx.paging:paging-common:${versions.paging}", 28 | androidxPagingRuntime : "androidx.paging:paging-runtime:${versions.paging}", 29 | androidxRecyclerView : 'androidx.recyclerview:recyclerview:1.0.0', 30 | androidxTestRules : 'androidx.test:rules:1.1.0', 31 | androidxTestRunner : 'androidx.test:runner:1.1.0', 32 | chuck : "com.readystatesoftware.chuck:library:${versions.chuck}", 33 | chuckNoOp : "com.readystatesoftware.chuck:library-no-op:${versions.chuck}", 34 | dagger : "com.google.dagger:dagger:${versions.dagger}", 35 | daggerAndroid : "com.google.dagger:dagger-android-processor:${versions.dagger}", 36 | daggerCompiler : "com.google.dagger:dagger-compiler:${versions.dagger}", 37 | glide : 'com.github.bumptech.glide:glide:4.8.0', 38 | googleTruth : 'com.google.truth:truth:0.42', 39 | jUnit : 'junit:junit:4.12', 40 | kotlinStdLib : "org.jetbrains.kotlin:kotlin-stdlib:${versions.kotlin}", 41 | kotlinCoroutinesAndroid : "org.jetbrains.kotlinx:kotlinx-coroutines-android:${versions.kotlinCoroutines}", 42 | kotlinCoroutinesCore : "org.jetbrains.kotlinx:kotlinx-coroutines-core:${versions.kotlinCoroutines}", 43 | mockitoCore : 'org.mockito:mockito-core:2.19.1', 44 | mockitoKotlin : 'com.nhaarman:mockito-kotlin-kt1.1:1.5.0', 45 | moshi : "com.squareup.moshi:moshi:${versions.moshi}", 46 | moshiKotlinCodegen : "com.squareup.moshi:moshi-kotlin-codegen:${versions.moshi}", 47 | photoView : 'com.github.chrisbanes:PhotoView:2.0.0', 48 | retrofit : 'com.squareup.retrofit2:retrofit:2.8.1', 49 | retrofitMoshi : 'com.squareup.retrofit2:converter-moshi:2.4.0' 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /domain/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'java-library' 3 | id 'kotlin' 4 | } 5 | 6 | java { 7 | sourceCompatibility = JavaVersion.VERSION_1_8 8 | targetCompatibility = JavaVersion.VERSION_1_8 9 | } 10 | 11 | dependencies { 12 | implementation libraries.kotlinStdLib 13 | api libraries.androidxPagingCommon 14 | } 15 | -------------------------------------------------------------------------------- /domain/src/main/java/com/ataulm/artcollector/Artist.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector 2 | 3 | data class Artist(val id: String, val name: String) 4 | -------------------------------------------------------------------------------- /domain/src/main/java/com/ataulm/artcollector/Gallery.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector 2 | 3 | class Gallery(collection: Collection) : ArrayList(collection) 4 | -------------------------------------------------------------------------------- /domain/src/main/java/com/ataulm/artcollector/Painting.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector 2 | 3 | data class Painting( 4 | val id: String, 5 | val title: String, 6 | val webUrl: String, 7 | val description: String?, 8 | val creditLine: String?, 9 | val imageUrl: String, 10 | val artist: Artist 11 | ) 12 | -------------------------------------------------------------------------------- /domain/src/main/java/com/ataulm/artcollector/datanotdomain/GalleryRepository.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.datanotdomain 2 | 3 | import androidx.paging.PagingSource 4 | import com.ataulm.artcollector.Painting 5 | 6 | interface GalleryRepository { 7 | 8 | fun gallery(): PagingSource 9 | } 10 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | 3 | # IDE (e.g. Android Studio) users: 4 | # Gradle settings configured through the IDE *will override* 5 | # any settings specified in this file. 6 | 7 | # For more details on how to configure your build environment visit 8 | # http://www.gradle.org/docs/current/userguide/build_environment.html 9 | 10 | # Specifies the JVM arguments used for the daemon process. 11 | # The setting is particularly useful for tweaking memory settings. 12 | # Default value: -Xmx10248m -XX:MaxPermSize=256m 13 | # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 14 | 15 | # When configured, Gradle will run in incubating parallel mode. 16 | # This option should only be used with decoupled projects. More details, visit 17 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 18 | # org.gradle.parallel=true 19 | android.enableJetifier=true 20 | android.useAndroidX=true 21 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ataulm/art-collector/902da0700e4f3934a1edcad80205718d79744d11/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sat Feb 06 19:44:34 GMT 2021 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.8-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn ( ) { 37 | echo "$*" 38 | } 39 | 40 | die ( ) { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules 158 | function splitJvmOpts() { 159 | JVM_OPTS=("$@") 160 | } 161 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS 162 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" 163 | 164 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" 165 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | if "%@eval[2+2]" == "4" goto 4NT_args 53 | 54 | :win9xME_args 55 | @rem Slurp the command line arguments. 56 | set CMD_LINE_ARGS= 57 | set _SKIP=2 58 | 59 | :win9xME_args_slurp 60 | if "x%~1" == "x" goto execute 61 | 62 | set CMD_LINE_ARGS=%* 63 | goto execute 64 | 65 | :4NT_args 66 | @rem Get arguments from the 4NT Shell from JP Software 67 | set CMD_LINE_ARGS=%$ 68 | 69 | :execute 70 | @rem Setup the command line 71 | 72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if "%ERRORLEVEL%"=="0" goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 85 | exit /b 1 86 | 87 | :mainEnd 88 | if "%OS%"=="Windows_NT" endlocal 89 | 90 | :omega 91 | -------------------------------------------------------------------------------- /painting/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.dynamic-feature' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | apply plugin: 'kotlin-kapt' 5 | 6 | android { 7 | compileOptions { 8 | sourceCompatibility JavaVersion.VERSION_1_8 9 | targetCompatibility JavaVersion.VERSION_1_8 10 | } 11 | compileSdkVersion versions.androidSdk.compile 12 | defaultConfig { 13 | minSdkVersion versions.androidSdk.min 14 | targetSdkVersion versions.androidSdk.target 15 | } 16 | } 17 | 18 | dependencies { 19 | implementation project(':app') 20 | 21 | kapt libraries.daggerCompiler 22 | } 23 | -------------------------------------------------------------------------------- /painting/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /painting/src/main/java/com/ataulm/artcollector/painting/PaintingComponent.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.painting 2 | 3 | import com.ataulm.artcollector.ApplicationComponent 4 | import com.ataulm.artcollector.ArtCollectorApplication 5 | import com.ataulm.artcollector.painting.domain.PaintingId 6 | import com.ataulm.artcollector.painting.ui.PaintingActivity 7 | import dagger.BindsInstance 8 | import dagger.Component 9 | 10 | @Component(modules = [PaintingModule::class], dependencies = [ApplicationComponent::class]) 11 | internal interface PaintingComponent { 12 | 13 | fun inject(activity: PaintingActivity) 14 | 15 | @Component.Builder 16 | interface Builder { 17 | 18 | @BindsInstance 19 | fun activity(activity: PaintingActivity): Builder 20 | 21 | fun withParent(component: ApplicationComponent): Builder 22 | 23 | @BindsInstance 24 | fun with(paintingId: PaintingId): Builder 25 | 26 | fun build(): PaintingComponent 27 | } 28 | } 29 | 30 | internal fun PaintingActivity.injectDependencies(paintingId: PaintingId) { 31 | DaggerPaintingComponent.builder() 32 | .withParent(ArtCollectorApplication.component(this)) 33 | .with(paintingId) 34 | .activity(this) 35 | .build() 36 | .inject(this) 37 | } 38 | -------------------------------------------------------------------------------- /painting/src/main/java/com/ataulm/artcollector/painting/PaintingModule.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.painting 2 | 3 | import androidx.lifecycle.ViewModelProviders 4 | import com.ataulm.artcollector.painting.data.AndroidPaintingRepository 5 | import com.ataulm.artcollector.painting.domain.PaintingRepository 6 | import com.ataulm.artcollector.painting.ui.PaintingActivity 7 | import com.ataulm.artcollector.painting.ui.PaintingViewModel 8 | import com.ataulm.artcollector.painting.ui.PaintingViewModelFactory 9 | import dagger.Module 10 | import dagger.Provides 11 | 12 | @Module 13 | internal object PaintingModule { 14 | 15 | @JvmStatic 16 | @Provides 17 | fun paintingRepository(paintingRepository: AndroidPaintingRepository): PaintingRepository { 18 | return paintingRepository 19 | } 20 | 21 | @JvmStatic 22 | @Provides 23 | fun viewModel(activity: PaintingActivity, viewModelFactory: PaintingViewModelFactory) = 24 | ViewModelProviders.of(activity, viewModelFactory).get(PaintingViewModel::class.java) 25 | } 26 | -------------------------------------------------------------------------------- /painting/src/main/java/com/ataulm/artcollector/painting/data/AndroidPaintingRepository.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.painting.data 2 | 3 | import com.ataulm.artcollector.ApiObjectRecord 4 | import com.ataulm.artcollector.HarvardArtMuseumApi 5 | import com.ataulm.artcollector.Artist 6 | import com.ataulm.artcollector.Painting 7 | import com.ataulm.artcollector.painting.domain.PaintingId 8 | import com.ataulm.artcollector.painting.domain.PaintingRepository 9 | import javax.inject.Inject 10 | 11 | internal class AndroidPaintingRepository @Inject constructor( 12 | private val harvardArtMuseumApi: HarvardArtMuseumApi, 13 | private val paintingId: PaintingId 14 | ) : PaintingRepository { 15 | 16 | override suspend fun painting(): Painting { 17 | return harvardArtMuseumApi.painting(paintingId.value).toPainting() 18 | } 19 | 20 | private fun ApiObjectRecord.toPainting(): Painting { 21 | val apiPerson = people.first() 22 | return Painting( 23 | id.toString(), 24 | title, 25 | url, 26 | description, 27 | creditLine, 28 | primaryImageUrl, 29 | Artist(apiPerson.personId.toString(), apiPerson.name) 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /painting/src/main/java/com/ataulm/artcollector/painting/domain/GetPaintingUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.painting.domain 2 | 3 | import javax.inject.Inject 4 | 5 | internal class GetPaintingUseCase @Inject constructor( 6 | private val repository: PaintingRepository 7 | ) { 8 | 9 | suspend operator fun invoke() = repository.painting() 10 | } 11 | -------------------------------------------------------------------------------- /painting/src/main/java/com/ataulm/artcollector/painting/domain/PaintingId.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.painting.domain 2 | 3 | internal data class PaintingId(val value: String) 4 | -------------------------------------------------------------------------------- /painting/src/main/java/com/ataulm/artcollector/painting/domain/PaintingRepository.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.painting.domain 2 | 3 | import com.ataulm.artcollector.Painting 4 | 5 | internal interface PaintingRepository { 6 | 7 | suspend fun painting(): Painting 8 | } 9 | -------------------------------------------------------------------------------- /painting/src/main/java/com/ataulm/artcollector/painting/ui/PaintingActivity.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.painting.ui 2 | 3 | import android.content.Intent 4 | import android.graphics.drawable.Drawable 5 | import android.os.Bundle 6 | import android.view.View 7 | import androidx.appcompat.app.AppCompatActivity 8 | import com.ataulm.artcollector.DataObserver 9 | import com.ataulm.artcollector.Painting 10 | import com.ataulm.artcollector.imageUrl 11 | import com.ataulm.artcollector.painting.R 12 | import com.ataulm.artcollector.painting.domain.PaintingId 13 | import com.ataulm.artcollector.painting.injectDependencies 14 | import com.ataulm.artcollector.webIntent 15 | import com.bumptech.glide.RequestManager 16 | import com.bumptech.glide.load.DataSource 17 | import com.bumptech.glide.load.engine.GlideException 18 | import com.bumptech.glide.request.RequestListener 19 | import com.bumptech.glide.request.target.Target 20 | import kotlinx.android.synthetic.main.activity_painting.* 21 | import javax.inject.Inject 22 | 23 | class PaintingActivity : AppCompatActivity() { 24 | 25 | @Inject 26 | internal lateinit var viewModel: PaintingViewModel 27 | 28 | @Inject 29 | internal lateinit var glideRequestManager: RequestManager 30 | 31 | override fun onCreate(savedInstanceState: Bundle?) { 32 | super.onCreate(savedInstanceState) 33 | setContentView(R.layout.activity_painting) 34 | 35 | val paintingId = intent.data!!.pathSegments.last() 36 | injectDependencies(PaintingId(paintingId)) 37 | 38 | postponeEnterTransition() 39 | 40 | intent.loadImageIfAvailable() 41 | viewModel.painting.observe(this, DataObserver { painting -> 42 | title = painting.title 43 | titleArtistTextView.text = getString(R.string.painting_title_artist, painting.title, painting.artist.name) 44 | creditLineTextView.text = painting.creditLine?.let { getString(R.string.painting_credit, it) } 45 | ?: getString(R.string.painting_credit_fallback) 46 | creditLineTextView.setOnClickListener { startActivity(webIntent(painting.webUrl)) } 47 | informationContainer.visibility = View.VISIBLE 48 | painting.loadImageIfDifferent() 49 | }) 50 | 51 | imageView.setOnClickListener { 52 | if (informationContainer.visibility == View.VISIBLE) { 53 | informationContainer.visibility = View.GONE 54 | } else { 55 | informationContainer.visibility = View.VISIBLE 56 | } 57 | } 58 | } 59 | 60 | private fun Intent.loadImageIfAvailable() { 61 | imageUrl()?.let { 62 | glideRequestManager.load(it) 63 | .listener(startTransitionRequestListener) 64 | .into(imageView) 65 | } 66 | } 67 | 68 | private fun Painting.loadImageIfDifferent() { 69 | if (imageUrl != intent.imageUrl()) { 70 | glideRequestManager.clear(imageView) 71 | glideRequestManager.load(imageUrl) 72 | .listener(startTransitionRequestListener) 73 | .into(imageView) 74 | } 75 | } 76 | 77 | private val startTransitionRequestListener = object : RequestListener { 78 | override fun onLoadFailed(e: GlideException?, model: Any?, target: Target?, isFirstResource: Boolean): Boolean { 79 | startPostponedEnterTransition() 80 | return false 81 | } 82 | 83 | override fun onResourceReady(resource: Drawable?, model: Any?, target: Target?, dataSource: DataSource?, isFirstResource: Boolean): Boolean { 84 | startPostponedEnterTransition() 85 | return false 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /painting/src/main/java/com/ataulm/artcollector/painting/ui/PaintingViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.painting.ui 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.MutableLiveData 5 | import androidx.lifecycle.ViewModel 6 | import com.ataulm.artcollector.Painting 7 | import com.ataulm.artcollector.painting.domain.GetPaintingUseCase 8 | import kotlinx.coroutines.* 9 | import javax.inject.Inject 10 | 11 | internal class PaintingViewModel @Inject constructor( 12 | private val getPainting: GetPaintingUseCase 13 | ) : ViewModel() { 14 | 15 | private val _painting = MutableLiveData() 16 | val painting: LiveData = _painting 17 | 18 | private val parentJob = Job() 19 | private val coroutineScope = CoroutineScope(parentJob) 20 | 21 | init { 22 | coroutineScope.launch(Dispatchers.IO) { 23 | val paintings = getPainting() 24 | withContext(Dispatchers.Main) { _painting.value = paintings } 25 | } 26 | } 27 | 28 | override fun onCleared() { 29 | super.onCleared() 30 | parentJob.cancel() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /painting/src/main/java/com/ataulm/artcollector/painting/ui/PaintingViewModelFactory.kt: -------------------------------------------------------------------------------- 1 | package com.ataulm.artcollector.painting.ui 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import com.ataulm.artcollector.painting.domain.GetPaintingUseCase 6 | import javax.inject.Inject 7 | 8 | internal class PaintingViewModelFactory @Inject constructor( 9 | private val paintingUseCase: GetPaintingUseCase 10 | ) : ViewModelProvider.Factory { 11 | 12 | @Suppress("UNCHECKED_CAST") 13 | override fun create(modelClass: Class) = 14 | PaintingViewModel(paintingUseCase) as T 15 | } 16 | -------------------------------------------------------------------------------- /painting/src/main/res/layout/activity_painting.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 20 | 21 | 33 | 34 | 44 | 45 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /painting/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #1877aa 4 | 5 | -------------------------------------------------------------------------------- /painting/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Painting 3 | %1$s // %2$s 4 | Credit: %s 5 | Credit: Harvard Art Museum 6 | 7 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | include ':artist' 3 | include ':domain' 4 | include ':painting' 5 | --------------------------------------------------------------------------------