Repository: ZahraHeydari/Android-Clean-Architecture-MVVM-Dagger-RX Branch: master Commit: 3987da95ff87 Files: 74 Total size: 83.2 KB Directory structure: gitextract_m2wrp2zp/ ├── .gitignore ├── README.md ├── app/ │ ├── build.gradle │ ├── proguard-rules.pro │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── android/ │ │ │ └── artgallery/ │ │ │ └── MainApplication.kt │ │ └── res/ │ │ ├── mipmap-anydpi-v26/ │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ └── values/ │ │ └── styles.xml │ └── test/ │ └── java/ │ └── com/ │ └── android/ │ └── artgallery/ │ └── ExampleUnitTest.kt ├── build.gradle ├── data/ │ ├── build.gradle │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── com/ │ │ └── android/ │ │ └── data/ │ │ ├── di/ │ │ │ ├── DatabaseModule.kt │ │ │ └── NetworkModule.kt │ │ ├── mapper/ │ │ │ └── EntityMapper.kt │ │ ├── repository/ │ │ │ ├── AlbumRepositoryImp.kt │ │ │ └── PhotoRepositoryImp.kt │ │ └── source/ │ │ ├── local/ │ │ │ ├── AppDatabase.kt │ │ │ ├── dao/ │ │ │ │ └── PhotoDao.kt │ │ │ └── entity/ │ │ │ └── PhotoEntity.kt │ │ └── remote/ │ │ └── RetrofitService.kt │ └── test/ │ └── java/ │ └── com/ │ └── android/ │ └── data/ │ ├── TestUtil.kt │ └── source/ │ └── local/ │ └── dao/ │ └── PhotoDaoTest.kt ├── domain/ │ ├── build.gradle │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── com/ │ │ └── android/ │ │ └── domain/ │ │ ├── model/ │ │ │ ├── Album.kt │ │ │ └── Photo.kt │ │ ├── repository/ │ │ │ ├── AlbumRepository.kt │ │ │ └── PhotoRepository.kt │ │ └── usecase/ │ │ ├── GetAlbumsUseCase.kt │ │ ├── GetPhotoDetailUseCase.kt │ │ ├── GetPhotosUseCase.kt │ │ └── base/ │ │ ├── SingleUseCase.kt │ │ └── UseCase.kt │ └── test/ │ └── java/ │ └── com/ │ └── android/ │ └── domain/ │ └── ExampleUnitTest.kt ├── gradle/ │ └── wrapper/ │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── presentation/ │ ├── build.gradle │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── android/ │ │ │ └── presentation/ │ │ │ ├── Extensions.kt │ │ │ ├── album/ │ │ │ │ ├── AlbumsAdapter.kt │ │ │ │ ├── AlbumsFragment.kt │ │ │ │ └── AlbumsViewModel.kt │ │ │ ├── detailphoto/ │ │ │ │ ├── PhotoDetailFragment.kt │ │ │ │ └── PhotoDetailViewModel.kt │ │ │ ├── gallery/ │ │ │ │ └── GalleryActivity.kt │ │ │ └── photo/ │ │ │ ├── PhotosAdapter.kt │ │ │ ├── PhotosFragment.kt │ │ │ └── PhotosViewModel.kt │ │ └── res/ │ │ ├── drawable/ │ │ │ └── ic_launcher_background.xml │ │ ├── drawable-nodpi/ │ │ │ ├── ic_launcher_background.xml │ │ │ ├── ic_paint_vector.xml │ │ │ ├── ic_star_empty_white_vector.xml │ │ │ └── ic_star_full_vector.xml │ │ ├── drawable-v24/ │ │ │ └── ic_launcher_foreground.xml │ │ ├── layout/ │ │ │ ├── activity_gallery.xml │ │ │ ├── fragment_albums.xml │ │ │ ├── fragment_photo_detail.xml │ │ │ ├── fragment_photos.xml │ │ │ ├── holder_album.xml │ │ │ └── holder_photo.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── mipmap-anydpi-v33/ │ │ │ └── ic_launcher.xml │ │ ├── values/ │ │ │ ├── colors.xml │ │ │ ├── dimens.xml │ │ │ ├── strings.xml │ │ │ └── themes.xml │ │ └── values-night/ │ │ └── themes.xml │ └── test/ │ └── java/ │ └── com/ │ └── android/ │ └── presentation/ │ └── ExampleUnitTest.kt └── settings.gradle ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ *.iml .gradle /local.properties /.idea/caches /.idea/libraries /.idea/modules.xml /.idea/workspace.xml /.idea/navEditor.xml /.idea/assetWizardSettings.xml .DS_Store /build /captures .externalNativeBuild .cxx # AndroidStudio 3 .idea/caches/build_file_checksums.ser .idea/modules.xml # AndroidStudio Patch !/gradle/wrapper/gradle-wrapper.jar gen-external-apklibs output.json # Directory-based project format .idea/ # mpeltonen/sbt-idea plugin .idea_modules/ # User-specific configurations .idea/caches/ .idea/libraries/ .idea/shelf/ .idea/.name .idea/compiler.xml .idea/copyright/profiles_settings.xml .idea/encodings.xml .idea/misc.xml .idea/scopes/scope_settings.xml .idea/vcs.xml .idea/jsLibraryMappings.xml .idea/datasources.xml .idea/dataSources.ids .idea/sqlDataSources.xml .idea/dynamic.xml .idea/uiDesigner.xml # virtual machine crash logs hs_err_pid* # Generated files bin/ gen/ out/ # Built application files *.apk *.ap_ *.aab # Package Files *.jar *.nar *.zip *.tar.gz *.rar *.war *.ear # lint lint/intermediates/ lint/generated/ lint/outputs/ lint/tmp/ # OS-specific files .DS_Store? ._* .Spotlight-V100 .Trashes ehthumbs.db Thumbs.db # Files for the ART/Dalvik VM *.dex # Temporary files which can be created if a process still has a handle open of a deleted file .fuse_hidden* # KDE directory preferences .directory # .nfs files are created when an open file is removed but is still being accessed .nfs* # Mac OS .AppleDouble .LSOverride # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk ================================================ FILE: README.md ================================================ # ArtGallery A sample android app that shows how to use ViewModels and Room together with RxJava & Dagger2, in Kotlin by Clean Architecture. ### Implemented by Clean Architecture The following diagram shows the structure of this project with 3 layers: - Presentation - Domain - Data


### Communication between layers 1. UI calls method from ViewModel. 2. ViewModel executes Use case. 3. Use case combines data from Album and Photo Repositories. 4. Each Repository returns data from a Data Source (Cached or Remote). 5. Information flows back to the UI where we display the list of posts. ### Scenario Used https://jsonplaceholder.typicode.com/ as a public api to generate fake data for testing At a glance: - Created a list of Album - In the Item of each Album, showed Album name. - When user taps on Album, new page will be shown which includes list of photos. - when user taps on photo, show image bigger through transition. - Were Written tests to completely cover Exceptions/Expectations - And: - Supported orientation change - Supported offline mode ================================================ FILE: app/build.gradle ================================================ plugins { id 'com.android.application' id 'kotlin-android' id 'kotlin-kapt' id 'dagger.hilt.android.plugin' } android { compileSdkVersion 33 defaultConfig { applicationId "com.android.artgallery" minSdkVersion 21 targetSdkVersion 33 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" multiDexEnabled = true vectorDrawables.useSupportLibrary = true } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } testOptions { unitTests { includeAndroidResources = true } } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = '1.8' } } dependencies { implementation project(':data') implementation project(':domain') implementation project(':presentation') implementation 'androidx.multidex:multidex:2.0.1' implementation 'androidx.core:core-ktx:1.10.1' implementation "com.google.dagger:hilt-android:2.44" kapt "com.google.dagger:hilt-android-compiler:2.44" testImplementation 'junit:junit:4.13.2' } ================================================ FILE: app/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/java/com/android/artgallery/MainApplication.kt ================================================ package com.android.artgallery import android.app.Application import android.content.Context import androidx.multidex.MultiDex import dagger.hilt.android.HiltAndroidApp @HiltAndroidApp class MainApplication : Application() { override fun onCreate() { super.onCreate() MultiDex.install(this) } override fun attachBaseContext(base: Context) { super.attachBaseContext(base) MultiDex.install(this) } } ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml ================================================ ================================================ FILE: app/src/main/res/values/styles.xml ================================================ ================================================ FILE: app/src/test/java/com/android/artgallery/ExampleUnitTest.kt ================================================ package com.android.artgallery import org.junit.Assert.assertEquals import org.junit.Test /** * Example local unit test, which will execute on the development machine (host). * * See [testing documentation](http://d.android.com/tools/testing). */ class ExampleUnitTest { @Test fun addition_isCorrect() { assertEquals(4, 2 + 2) } } ================================================ FILE: build.gradle ================================================ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { ext.kotlin_version = '1.7.21' repositories { google() mavenCentral() maven { url "https://maven.google.com" } maven { url 'https://jitpack.io' } } dependencies { classpath 'com.android.tools.build:gradle:7.2.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath 'com.google.dagger:hilt-android-gradle-plugin:2.38.1' } } allprojects { repositories { google() mavenCentral() maven { url "https://maven.google.com" } maven { url 'https://jitpack.io' } } } task clean(type: Delete) { delete rootProject.buildDir } ================================================ FILE: data/build.gradle ================================================ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' id 'dagger.hilt.android.plugin' id 'kotlin-kapt' } android { namespace 'com.android.data' compileSdkVersion 33 defaultConfig { minSdkVersion 21 targetSdkVersion 33 } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = '1.8' } } dependencies { implementation project(':domain') api 'com.squareup.retrofit2:retrofit:2.9.0' api 'com.squareup.retrofit2:converter-gson:2.9.0' api "com.squareup.retrofit2:adapter-rxjava2:2.9.0" api 'com.squareup.okhttp3:logging-interceptor:4.8.1' api 'com.squareup.okhttp3:okhttp:4.8.1' api 'com.google.code.gson:gson:2.9.1' implementation 'io.reactivex.rxjava2:rxandroid:2.1.1' implementation 'io.reactivex.rxjava2:rxjava:2.2.20' implementation 'androidx.room:room-runtime:2.5.2' implementation 'androidx.room:room-rxjava2:2.5.2' kapt 'androidx.room:room-compiler:2.5.2' implementation "com.google.dagger:hilt-android:2.44" kapt "com.google.dagger:hilt-android-compiler:2.44" implementation 'androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha03' testImplementation 'junit:junit:4.13.2' testImplementation 'io.mockk:mockk:1.12.0' } ================================================ FILE: data/src/main/AndroidManifest.xml ================================================ ================================================ FILE: data/src/main/java/com/android/data/di/DatabaseModule.kt ================================================ package com.android.data.di import android.app.Application import androidx.room.Room import com.android.data.source.local.AppDatabase import com.android.data.source.local.dao.PhotoDao import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @InstallIn(SingletonComponent::class) @Module class DatabaseModule { @Provides @Singleton internal fun provideAppDatabase(application: Application): com.android.data.source.local.AppDatabase { return Room.databaseBuilder( application, com.android.data.source.local.AppDatabase::class.java, com.android.data.source.local.AppDatabase.DB_NAME ).allowMainThreadQueries().build() } @Provides internal fun providePhotoDao(appDatabase: com.android.data.source.local.AppDatabase): com.android.data.source.local.dao.PhotoDao { return appDatabase.photoDao } } ================================================ FILE: data/src/main/java/com/android/data/di/NetworkModule.kt ================================================ package com.android.data.di import android.content.Context import android.net.ConnectivityManager import android.net.NetworkInfo import com.android.data.repository.AlbumRepositoryImp import com.android.data.repository.PhotoRepositoryImp import com.android.data.source.local.AppDatabase import com.android.data.source.remote.RetrofitService import com.android.domain.repository.AlbumRepository import com.android.domain.repository.PhotoRepository import com.google.gson.Gson import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import okhttp3.Cache import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory import retrofit2.converter.gson.GsonConverterFactory import java.util.concurrent.TimeUnit import javax.inject.Singleton const val BASE_URL = "https://jsonplaceholder.typicode.com/" @InstallIn(SingletonComponent::class) @Module class NetworkModule { @Provides @Singleton fun providesRetrofit( gsonConverterFactory: GsonConverterFactory, rxJava2CallAdapterFactory: RxJava2CallAdapterFactory, okHttpClient: OkHttpClient ): Retrofit { return Retrofit.Builder() .baseUrl(BASE_URL) .addConverterFactory(gsonConverterFactory) .addCallAdapterFactory(rxJava2CallAdapterFactory) .client(okHttpClient) .build() } @Provides @Singleton fun providesOkHttpClient( @ApplicationContext context: Context ): OkHttpClient { val cacheSize = (5 * 1024 * 1024).toLong() val mCache = Cache(context.cacheDir, cacheSize) val interceptor = HttpLoggingInterceptor() interceptor.level = HttpLoggingInterceptor.Level.BODY val client = OkHttpClient.Builder() .cache(mCache) // make your app offline-friendly without a database! .connectTimeout(60, TimeUnit.SECONDS) .writeTimeout(60, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) .addNetworkInterceptor(interceptor) .addInterceptor { chain -> var request = chain.request() /* If there is Internet, get the cache that was stored 5 seconds ago. * If the cache is older than 5 seconds, then discard it, * and indicate an error in fetching the response. * The 'max-age' attribute is responsible for this behavior. */ request = if (true) request.newBuilder() // make default to true till i figure out how to inject network status .header("Cache-Control", "public, max-age=" + 5).build() /*If there is no Internet, get the cache that was stored 7 days ago. * If the cache is older than 7 days, then discard it, * and indicate an error in fetching the response. * The 'max-stale' attribute is responsible for this behavior. * The 'only-if-cached' attribute indicates to not retrieve new data; fetch the cache only instead. */ else request.newBuilder().header( "Cache-Control", "public, only-if-cached, max-stale=" + 60 * 60 * 24 * 7 ).build() chain.proceed(request) } return client.build() } @Provides @Singleton fun providesGson(): Gson { return Gson() } @Provides @Singleton fun providesGsonConverterFactory(): GsonConverterFactory { return GsonConverterFactory.create() } @Provides @Singleton fun providesRxJavaCallAdapterFactory(): RxJava2CallAdapterFactory { return RxJava2CallAdapterFactory.create() } @Provides @Singleton fun provideIsNetworkAvailable(@ApplicationContext context: Context): Boolean { val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val activeNetwork: NetworkInfo? = connectivityManager.activeNetworkInfo return activeNetwork != null && activeNetwork.isConnected } @Singleton @Provides fun provideService(retrofit: Retrofit): RetrofitService { return retrofit.create(RetrofitService::class.java) } @Singleton @Provides fun provideAlbumRepository( retrofitService: RetrofitService ): AlbumRepository { return AlbumRepositoryImp(retrofitService) } @Singleton @Provides fun providePhotoRepository( appDatabase: AppDatabase, retrofitService: RetrofitService ): PhotoRepository { return PhotoRepositoryImp(appDatabase, retrofitService) } } ================================================ FILE: data/src/main/java/com/android/data/mapper/EntityMapper.kt ================================================ package com.android.data.mapper import com.android.data.source.local.entity.PhotoEntity import com.android.domain.model.Photo fun Photo.toEntity() = PhotoEntity( id = id, title = title, url = url, thumbnailUrl = thumbnailUrl ) ================================================ FILE: data/src/main/java/com/android/data/repository/AlbumRepositoryImp.kt ================================================ package com.android.data.repository import com.android.data.source.remote.RetrofitService import com.android.domain.model.Album import com.android.domain.repository.AlbumRepository import io.reactivex.Single /** * This repository is responsible for * fetching data[Album] from server or db * */ class AlbumRepositoryImp( private val retrofitService: RetrofitService ) : AlbumRepository { override fun getAlbums(): Single> { return retrofitService.getAlbums() } } ================================================ FILE: data/src/main/java/com/android/data/repository/PhotoRepositoryImp.kt ================================================ package com.android.data.repository import com.android.data.mapper.toEntity import com.android.data.source.local.AppDatabase import com.android.data.source.remote.RetrofitService import com.android.domain.model.Photo import com.android.domain.repository.PhotoRepository import io.reactivex.Single /** * This repository is responsible for * fetching data [photo] from server or db * */ class PhotoRepositoryImp( private val database: AppDatabase, private val retrofitService: RetrofitService ) : PhotoRepository { override fun isFavorite(photoId: Long): Boolean { return database.photoDao.loadOneByPhotoId(photoId) != null } override fun deletePhoto(photo: Photo) { database.photoDao.delete(photo.toEntity()) } override fun addPhoto(photo: Photo) { database.photoDao.insert(photo.toEntity()) } override fun getPhotoDetail(photoId: Long?): Single { return retrofitService.getPhotoDetail(photoId!!) } override fun getPhotos(albumId: Long?): Single> { return retrofitService.getPhotos(albumId!!) } } ================================================ FILE: data/src/main/java/com/android/data/source/local/AppDatabase.kt ================================================ package com.android.data.source.local import androidx.room.Database import androidx.room.RoomDatabase import com.android.data.source.local.dao.PhotoDao import com.android.data.source.local.entity.PhotoEntity /** * To manage data items that can be accessed, updated * & maintain relationships between them * */ @Database(entities = [PhotoEntity::class], version = 1, exportSchema = false) abstract class AppDatabase : RoomDatabase() { abstract val photoDao: PhotoDao companion object { const val DB_NAME = "ArtGalleryDatabase.db" } } ================================================ FILE: data/src/main/java/com/android/data/source/local/dao/PhotoDao.kt ================================================ package com.android.data.source.local.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import com.android.data.source.local.entity.PhotoEntity /** * it provides access to [Photo] underlying database * */ @Dao interface PhotoDao { @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(photo: PhotoEntity): Long @Query("SELECT * FROM Photo") fun loadAll(): MutableList @Delete fun delete(photo: PhotoEntity) @Query("DELETE FROM Photo") fun deleteAll() @Query("SELECT * FROM Photo where id = :photoId") fun loadOneByPhotoId(photoId: Long): PhotoEntity? @Query("SELECT * FROM Photo where title = :photoTitle") fun loadOneByPhotoTitle(photoTitle: String): PhotoEntity? @Update fun update(photo: PhotoEntity) } ================================================ FILE: data/src/main/java/com/android/data/source/local/entity/PhotoEntity.kt ================================================ package com.android.data.source.local.entity import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "Photo") data class PhotoEntity( @PrimaryKey var id: Long, var title: String, val url: String, val thumbnailUrl: String? ) ================================================ FILE: data/src/main/java/com/android/data/source/remote/RetrofitService.kt ================================================ package com.android.data.source.remote import com.android.domain.model.Album import com.android.domain.model.Photo import io.reactivex.Single import retrofit2.http.GET import retrofit2.http.Path interface RetrofitService { @GET("albums/") fun getAlbums(): Single> @GET("albums/{id}/photos") fun getPhotos(@Path("id") id: Long): Single> @GET("photos/{id}") fun getPhotoDetail(@Path("id") id: Long): Single } ================================================ FILE: data/src/test/java/com/android/data/TestUtil.kt ================================================ package com.android.data import com.android.data.source.local.entity.PhotoEntity object TestUtil { fun createPhoto(id: Long) = PhotoEntity( id = id, title = "", url = "", thumbnailUrl = "" ) fun makePhotoList(size: Int): MutableList { val list = ArrayList(size) list.forEach { it.title = "Photo ${list.indexOf(it)}" it.id = (list.indexOf(it) + 1).toLong() list.add(it) } return list } } ================================================ FILE: data/src/test/java/com/android/data/source/local/dao/PhotoDaoTest.kt ================================================ package com.android.data.source.local.dao import com.android.data.TestUtil import com.android.data.source.local.entity.PhotoEntity import io.mockk.every import io.mockk.mockk import org.hamcrest.CoreMatchers import org.hamcrest.MatcherAssert import org.junit.Assert import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.JUnit4 @RunWith(JUnit4::class) class PhotoDaoTest { private var photoDao = mockk() @Test fun isPhotoListEmpty() { every { photoDao.loadAll() } returns mutableListOf() Assert.assertEquals(0, photoDao.loadAll().size) } @Test fun insertPhoto() { val photoEntity: PhotoEntity = TestUtil.createPhoto(7) every { photoDao.insert(photoEntity) } returns 1L val insertedPhoto = photoDao.insert(photoEntity) Assert.assertNotNull(insertedPhoto) } @Test fun insertPhotoAndLoadByTitle() { val photoTitle = "Art" val photoEntity: PhotoEntity = TestUtil.createPhoto(1).apply { title = photoTitle } every { photoDao.insert(photoEntity) } returns 1L every { photoDao.loadOneByPhotoTitle(photoTitle) } returns photoEntity val photoLoadedByTitle = photoDao.loadOneByPhotoTitle(photoTitle) MatcherAssert.assertThat(photoLoadedByTitle, CoreMatchers.equalTo(photoEntity)) } @Test fun retrievesPhotos() { val photoEntities = TestUtil.makePhotoList(5) photoEntities.forEach { photoDao.insert(it) } every { photoDao.loadAll() } returns mutableListOf() val loadedPhotos = photoDao.loadAll() Assert.assertEquals(photoEntities, loadedPhotos) } @Test fun deletePhoto() { val photoId = 8L val photoEntity = TestUtil.createPhoto(photoId) every { photoDao.delete(photoEntity) } returns Unit photoDao.delete(photoEntity) every { photoDao.loadOneByPhotoId(photoId) } returns null val loadOneByPhotoId = photoDao.loadOneByPhotoId(photoId) Assert.assertNull(loadOneByPhotoId) } @Test fun deleteAllPhotos() { every { photoDao.deleteAll() } returns Unit photoDao.deleteAll() every { photoDao.loadAll() } returns mutableListOf() val loadedAllPhotos = photoDao.loadAll() assert(loadedAllPhotos.isEmpty()) } } ================================================ FILE: domain/build.gradle ================================================ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' id 'dagger.hilt.android.plugin' id 'kotlin-kapt' } android { namespace 'com.android.domain' compileSdkVersion 33 defaultConfig { minSdkVersion 21 targetSdkVersion 33 } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = '1.8' } } dependencies { api(platform("org.jetbrains.kotlin:kotlin-bom:1.8.0")) api 'androidx.appcompat:appcompat:1.6.1' implementation 'io.reactivex.rxjava2:rxandroid:2.1.1' implementation 'io.reactivex.rxjava2:rxjava:2.2.20' implementation "com.squareup.retrofit2:adapter-rxjava2:2.9.0" implementation "com.google.dagger:hilt-android:2.44" kapt "com.google.dagger:hilt-android-compiler:2.44" implementation 'androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha03' testImplementation 'junit:junit:4.13.2' } ================================================ FILE: domain/src/main/AndroidManifest.xml ================================================ ================================================ FILE: domain/src/main/java/com/android/domain/model/Album.kt ================================================ package com.android.domain.model data class Album(var userId: Long, var id: Long, var title: String) ================================================ FILE: domain/src/main/java/com/android/domain/model/Photo.kt ================================================ package com.android.domain.model data class Photo( var id: Long, var title: String, val url: String, val thumbnailUrl: String? ) { fun setName(text: String) { title = text } } ================================================ FILE: domain/src/main/java/com/android/domain/repository/AlbumRepository.kt ================================================ package com.android.domain.repository import com.android.domain.model.Album import io.reactivex.Single /** * To make an interaction between [AlbumRepositoryImp] & [GetAlbumsUseCase] * */ interface AlbumRepository { fun getAlbums(): Single> } ================================================ FILE: domain/src/main/java/com/android/domain/repository/PhotoRepository.kt ================================================ package com.android.domain.repository import com.android.domain.model.Photo import io.reactivex.Single /** * To make an interaction between [PhotoRepositoryImp] & [GetPhotosUseCase] * */ interface PhotoRepository { fun getPhotos(albumId: Long?): Single> fun getPhotoDetail(photoId: Long?): Single fun deletePhoto(photo: Photo) fun addPhoto(photo: Photo) fun isFavorite(photoId: Long): Boolean } ================================================ FILE: domain/src/main/java/com/android/domain/usecase/GetAlbumsUseCase.kt ================================================ package com.android.domain.usecase import com.android.domain.model.Album import com.android.domain.repository.AlbumRepository import com.android.domain.usecase.base.SingleUseCase import io.reactivex.Single import javax.inject.Inject /** * An interactor that calls the actual implementation of [AlbumViewModel](which is injected by DI) * it handles the response that returns data & * contains a list of actions, event steps */ class GetAlbumsUseCase @Inject constructor( private val repository: AlbumRepository ) : SingleUseCase>() { override fun buildUseCaseSingle(): Single> { return repository.getAlbums() } } ================================================ FILE: domain/src/main/java/com/android/domain/usecase/GetPhotoDetailUseCase.kt ================================================ package com.android.domain.usecase import com.android.domain.model.Photo import com.android.domain.repository.PhotoRepository import com.android.domain.usecase.base.SingleUseCase import io.reactivex.Single import javax.inject.Inject /** * An interactor that calls the actual implementation of [PhotoViewModel](which is injected by DI) * it handles the response that returns data & contains a list of actions, event steps */ class GetPhotoDetailUseCase @Inject constructor( private val repository: PhotoRepository ) : SingleUseCase() { private var photoId: Long? = null fun savePhotoId(id: Long) { photoId = id } override fun buildUseCaseSingle(): Single { return repository.getPhotoDetail(photoId) } fun deleteAsFavorite(photo: Photo) { repository.deletePhoto(photo) } fun addAsFavorite(photo: Photo) { repository.addPhoto(photo) } fun isFavorite(photoId: Long): Boolean { return repository.isFavorite(photoId) } } ================================================ FILE: domain/src/main/java/com/android/domain/usecase/GetPhotosUseCase.kt ================================================ package com.android.domain.usecase import com.android.domain.model.Photo import com.android.domain.repository.PhotoRepository import com.android.domain.usecase.base.SingleUseCase import io.reactivex.Single import javax.inject.Inject /** * An interactor that calls the actual implementation of [PhotosViewModel](which is injected by DI) * it handles the response that returns data & * contains a list of actions, event steps */ class GetPhotosUseCase @Inject constructor( private val repository: PhotoRepository ) : SingleUseCase>() { private var albumId: Long? = null fun saveAlbumId(id: Long) { albumId = id } override fun buildUseCaseSingle(): Single> { return repository.getPhotos(albumId) } } ================================================ FILE: domain/src/main/java/com/android/domain/usecase/base/SingleUseCase.kt ================================================ package com.android.domain.usecase.base import io.reactivex.Single import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.schedulers.Schedulers /** * This abstract class is shared among several closely related UseCase classes * that classes that extend this abstract class to use common methods & fields **/ abstract class SingleUseCase : UseCase() { internal abstract fun buildUseCaseSingle(): Single fun execute( onSuccess: ((t: T) -> Unit), onError: ((t: Throwable) -> Unit), onFinished: () -> Unit = {} ) { disposeLast() lastDisposable = buildUseCaseSingle() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .doAfterTerminate(onFinished) .subscribe(onSuccess, onError) lastDisposable?.let { compositeDisposable.add(it) } } } ================================================ FILE: domain/src/main/java/com/android/domain/usecase/base/UseCase.kt ================================================ package com.android.domain.usecase.base import io.reactivex.disposables.CompositeDisposable import io.reactivex.disposables.Disposable /** * This class is extended by SingleUseCase classes * to use common methods & fields **/ abstract class UseCase { protected var lastDisposable: Disposable? = null protected val compositeDisposable = CompositeDisposable() fun disposeLast() { lastDisposable?.let { if (!it.isDisposed) { it.dispose() } } } fun dispose() { compositeDisposable.clear() } } ================================================ FILE: domain/src/test/java/com/android/domain/ExampleUnitTest.kt ================================================ package com.android.domain import org.junit.Test import org.junit.Assert.* /** * Example local unit test, which will execute on the development machine (host). * * See [testing documentation](http://d.android.com/tools/testing). */ class ExampleUnitTest { @Test fun addition_isCorrect() { assertEquals(4, 2 + 2) } } ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ #Fri Jun 23 18:13:22 IRDT 2023 distributionBase=GRADLE_USER_HOME distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME ================================================ FILE: gradle.properties ================================================ # Project-wide Gradle settings. # IDE (e.g. Android Studio) users: # Gradle settings configured through the IDE *will override* # any settings specified in this file. # For more details on how to configure your build environment visit # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx1536m # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true android.useAndroidX=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official android.enableJetifier=true ================================================ FILE: gradlew ================================================ #!/usr/bin/env sh ############################################################################## ## ## Gradle start up script for UN*X ## ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link PRG="$0" # Need this for relative symlinks. while [ -h "$PRG" ] ; do ls=`ls -ld "$PRG"` link=`expr "$ls" : '.*-> \(.*\)$'` if expr "$link" : '/.*' > /dev/null; then PRG="$link" else PRG=`dirname "$PRG"`"/$link" fi done SAVED="`pwd`" cd "`dirname \"$PRG\"`/" >/dev/null APP_HOME="`pwd -P`" cd "$SAVED" >/dev/null APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS="" # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" warn () { echo "$*" } die () { echo echo "$*" echo exit 1 } # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "`uname`" in CYGWIN* ) cygwin=true ;; Darwin* ) darwin=true ;; MINGW* ) msys=true ;; NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD="$JAVA_HOME/jre/sh/java" else JAVACMD="$JAVA_HOME/bin/java" fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD="java" which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi # Increase the maximum file descriptors if we can. if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then MAX_FD_LIMIT=`ulimit -H -n` if [ $? -eq 0 ] ; then if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then MAX_FD="$MAX_FD_LIMIT" fi ulimit -n $MAX_FD if [ $? -ne 0 ] ; then warn "Could not set maximum file descriptor limit: $MAX_FD" fi else warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" fi fi # For Darwin, add options to specify how the application appears in the dock if $darwin; then GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" fi # For Cygwin, switch paths to Windows format before running java if $cygwin ; then APP_HOME=`cygpath --path --mixed "$APP_HOME"` CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` JAVACMD=`cygpath --unix "$JAVACMD"` # We build the pattern for arguments to be converted via cygpath ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` SEP="" for dir in $ROOTDIRSRAW ; do ROOTDIRS="$ROOTDIRS$SEP$dir" SEP="|" done OURCYGPATTERN="(^($ROOTDIRS))" # Add a user-defined pattern to the cygpath arguments if [ "$GRADLE_CYGPATTERN" != "" ] ; then OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" fi # Now convert the arguments - kludge to limit ourselves to /bin/sh i=0 for arg in "$@" ; do CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` else eval `echo args$i`="\"$arg\"" fi i=$((i+1)) done case $i in (0) set -- ;; (1) set -- "$args0" ;; (2) set -- "$args0" "$args1" ;; (3) set -- "$args0" "$args1" "$args2" ;; (4) set -- "$args0" "$args1" "$args2" "$args3" ;; (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; esac fi # Escape application args save () { for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done echo " " } APP_ARGS=$(save "$@") # Collect all arguments for the java command, following the shell quoting and substitution rules eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then cd "$(dirname "$0")" fi exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS= @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if "%ERRORLEVEL%" == "0" goto init echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto init echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :init @rem Get command-line arguments, handling Windows variants if not "%OS%" == "Windows_NT" goto win9xME_args :win9xME_args @rem Slurp the command line arguments. set CMD_LINE_ARGS= set _SKIP=2 :win9xME_args_slurp if "x%~1" == "x" goto execute set CMD_LINE_ARGS=%* :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% :end @rem End local scope for the variables with windows NT shell if "%ERRORLEVEL%"=="0" goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 exit /b 1 :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: presentation/build.gradle ================================================ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' id 'dagger.hilt.android.plugin' id 'kotlin-kapt' } android { namespace 'com.android.presentation' compileSdk 33 defaultConfig { minSdkVersion 21 targetSdkVersion 33 } buildFeatures { viewBinding true } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = '1.8' } } dependencies { implementation project(':domain') implementation 'androidx.core:core-ktx:1.10.1' implementation 'com.google.android.material:material:1.9.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation "androidx.fragment:fragment-ktx:1.6.0" implementation "io.coil-kt:coil:0.7.0" implementation "com.google.dagger:hilt-android:2.44" kapt "com.google.dagger:hilt-android-compiler:2.44" implementation 'androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha03' testImplementation 'junit:junit:4.13.2' } ================================================ FILE: presentation/src/main/AndroidManifest.xml ================================================ ================================================ FILE: presentation/src/main/java/com/android/presentation/Extensions.kt ================================================ package com.android.artgallery.presentation import android.view.View import android.widget.ImageView import android.widget.ProgressBar import coil.api.load import coil.decode.DataSource import coil.request.Request fun ImageView.loadImage(url: String?) = this.load(url) fun ImageView.loadImage(url: String, progressBar: ProgressBar) = this.load(url) { crossfade(true) listener(object : Request.Listener { override fun onSuccess(data: Any, source: DataSource) { super.onSuccess(data, source) progressBar.visibility = View.GONE } }) } ================================================ FILE: presentation/src/main/java/com/android/presentation/album/AlbumsAdapter.kt ================================================ package com.android.presentation.album import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.android.domain.model.Album import com.android.presentation.album.AlbumsAdapter.AlbumViewHolder import com.android.presentation.databinding.HolderAlbumBinding /** * This class is responsible for converting each data entry [Album] * into [AlbumViewHolder] that can then be added to the AdapterView. * */ internal class AlbumsAdapter(val onAlbumClick: (Album) -> Unit) : RecyclerView.Adapter() { private val albums: MutableList = ArrayList() /* * This method is called right when adapter is created & * is used to initialize ViewHolders * */ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val holderAlbumBinding = HolderAlbumBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return AlbumViewHolder(holderAlbumBinding) } /* It is called for each ViewHolder to bind it to the adapter & * This is where we pass data to ViewHolder * */ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { (holder as AlbumViewHolder).onBind(getItem(position)) } private fun getItem(position: Int): Album = albums[position] /* * This method returns the size of collection that contains the items we want to display * */ override fun getItemCount() = albums.size fun addData(list: List) { this.albums.clear() this.albums.addAll(list) notifyDataSetChanged() } inner class AlbumViewHolder(private val binding: HolderAlbumBinding) : RecyclerView.ViewHolder(binding.root) { fun onBind(album: Album) { binding.holderAlbumTextView.text = album.title itemView.setOnClickListener { onAlbumClick.invoke(album) } } } } ================================================ FILE: presentation/src/main/java/com/android/presentation/album/AlbumsFragment.kt ================================================ package com.android.presentation.album import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import com.android.presentation.R import com.android.presentation.databinding.FragmentAlbumsBinding import com.android.presentation.photo.PhotosFragment import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class AlbumsFragment : Fragment() { private lateinit var binding: FragmentAlbumsBinding private var adapter: AlbumsAdapter? = null private val viewModel: AlbumsViewModel by viewModels() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { binding = FragmentAlbumsBinding.inflate(inflater, container, false) adapter = AlbumsAdapter { navigateToPhotosPage(it) } binding.albumsRecyclerView.adapter = adapter viewModel.loadAlbums() return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) with(viewModel) { isLoad.observe(viewLifecycleOwner) { it?.let { visibility -> binding.albumsProgressBar.visibility = if (visibility) View.GONE else View.VISIBLE } } albumsReceivedLiveData.observe(viewLifecycleOwner) { it?.let { adapter?.addData(it) } } } } private fun navigateToPhotosPage(album: com.android.domain.model.Album) { activity?.supportFragmentManager?.beginTransaction()?.replace( R.id.gallery_container, PhotosFragment.newInstance(album.id), PhotosFragment.FRAGMENT_NAME )?.addToBackStack(PhotosFragment.FRAGMENT_NAME)?.commitAllowingStateLoss() } override fun onDetach() { super.onDetach() adapter = null } companion object { val FRAGMENT_NAME: String = AlbumsFragment::class.java.name @JvmStatic fun newInstance() = AlbumsFragment() } } ================================================ FILE: presentation/src/main/java/com/android/presentation/album/AlbumsViewModel.kt ================================================ package com.android.presentation.album import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.android.domain.model.Album import com.android.domain.usecase.GetAlbumsUseCase import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject /**To store & manage UI-related data in a lifecycle conscious way! * this class allows data to survive configuration changes such as screen rotation * by interacting with [GetAlbumsUseCase] * * */ @HiltViewModel class AlbumsViewModel @Inject constructor(private val getAlbumListUseCase: com.android.domain.usecase.GetAlbumsUseCase) : ViewModel() { val albumsReceivedLiveData = MutableLiveData>() val isLoad = MutableLiveData() val albumData = MutableLiveData() init { isLoad.value = false } val album: com.android.domain.model.Album? get() = albumData.value fun set(album: com.android.domain.model.Album) = run { albumData.value = album } fun loadAlbums() { getAlbumListUseCase.execute( onSuccess = { isLoad.value = true albumsReceivedLiveData.value = it }, onError = { it.printStackTrace() } ) } } ================================================ FILE: presentation/src/main/java/com/android/presentation/detailphoto/PhotoDetailFragment.kt ================================================ package com.android.presentation.detailphoto import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import com.android.artgallery.presentation.loadImage import com.android.presentation.R import com.android.presentation.databinding.FragmentPhotoDetailBinding import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class PhotoDetailFragment : Fragment() { private lateinit var binding: FragmentPhotoDetailBinding private val viewModel: PhotoDetailViewModel by viewModels() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { binding = FragmentPhotoDetailBinding.inflate(layoutInflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val photoId = arguments?.getLong(KEY_PHOTO_ID) ?: return with(viewModel) { getDetail(photoId) checkFavoriteStatus(photoId) photoData.observe(viewLifecycleOwner) { binding.detailTitleTextView.text = it?.title binding.detailToolbarImageView.loadImage(it?.url) } isFavorite.observe(viewLifecycleOwner) { it?.let { binding.detailFab.setImageResource( if (it) R.drawable.ic_star_full_vector else R.drawable.ic_star_empty_white_vector ) } } binding.detailFab.setOnClickListener { updateFavoriteStatus() } } } companion object { val FRAGMENT_NAME: String = PhotoDetailFragment::class.java.name private const val KEY_PHOTO_ID = "KEY_PHOTO_ID" fun newInstance(photoId: Long) = PhotoDetailFragment().apply { arguments = Bundle().apply { putLong(KEY_PHOTO_ID, photoId) } } } } ================================================ FILE: presentation/src/main/java/com/android/presentation/detailphoto/PhotoDetailViewModel.kt ================================================ package com.android.presentation.detailphoto import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.android.domain.model.Photo import com.android.domain.usecase.GetPhotoDetailUseCase import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject /** * A helper class for the UI controller that is responsible for * preparing data for [PhotoDetailFragment] * * @author ZARA * */ @HiltViewModel class PhotoDetailViewModel @Inject constructor( private val getPhotoDetailUseCase: com.android.domain.usecase.GetPhotoDetailUseCase ) : ViewModel() { val photoData = MutableLiveData() val isLoad = MutableLiveData() val isFavorite = MutableLiveData() init { isLoad.value = false } fun getDetail(id: Long?) { if (id == null) return getPhotoDetailUseCase.savePhotoId(id) getPhotoDetailUseCase.execute( onSuccess = { isLoad.value = true photoData.value = it }, onError = { it.printStackTrace() } ) } fun updateFavoriteStatus() { photoData.value?.let { photo -> if (isFavorite.value == true) { getPhotoDetailUseCase.deleteAsFavorite(photo) } else { getPhotoDetailUseCase.addAsFavorite(photo) } isFavorite.value?.let { isFavorite.value = !it } } } fun checkFavoriteStatus(photoId: Long) { isFavorite.value = getPhotoDetailUseCase.isFavorite(photoId) } } ================================================ FILE: presentation/src/main/java/com/android/presentation/gallery/GalleryActivity.kt ================================================ package com.android.presentation.gallery import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import com.android.presentation.R import com.android.presentation.album.AlbumsFragment import com.android.presentation.databinding.ActivityGalleryBinding import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class GalleryActivity : AppCompatActivity() { private lateinit var binding: ActivityGalleryBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityGalleryBinding.inflate(layoutInflater) setContentView(binding.root) if (savedInstanceState == null) { supportFragmentManager.beginTransaction().replace( R.id.gallery_container, AlbumsFragment.newInstance(), AlbumsFragment.FRAGMENT_NAME ).commitAllowingStateLoss() } } } ================================================ FILE: presentation/src/main/java/com/android/presentation/photo/PhotosAdapter.kt ================================================ package com.android.presentation.photo import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.android.domain.model.Photo import com.android.artgallery.presentation.loadImage import com.android.presentation.databinding.HolderPhotoBinding import com.android.presentation.photo.PhotosAdapter.PhotoViewHolder /** * [android.support.v7.widget.RecyclerView.Adapter] to adapt * [Photo] items into [RecyclerView] via [PhotoViewHolder] * */ internal class PhotosAdapter(val onPhotoClick: (Long) -> Unit) : RecyclerView.Adapter() { private val photos: MutableList = ArrayList() override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val holderPhotoBinding = HolderPhotoBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return PhotoViewHolder(holderPhotoBinding) } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { (holder as PhotoViewHolder).onBind(getItem(position)) } private fun getItem(position: Int): Photo = photos[position] override fun getItemCount(): Int = photos.size fun addData(list: List) { this.photos.clear() this.photos.addAll(list) notifyDataSetChanged() } inner class PhotoViewHolder(private val binding: HolderPhotoBinding) : RecyclerView.ViewHolder(binding.root) { fun onBind(photo: Photo) { with(binding) { photoProgressBar.visibility = View.VISIBLE photoTextView.text = photo.title photoImageView.loadImage(photo.url, binding.photoProgressBar) } itemView.setOnClickListener { onPhotoClick(photo.id) } } } } ================================================ FILE: presentation/src/main/java/com/android/presentation/photo/PhotosFragment.kt ================================================ package com.android.presentation.photo import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import com.android.presentation.R import com.android.presentation.databinding.FragmentPhotosBinding import com.android.presentation.detailphoto.PhotoDetailFragment import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class PhotosFragment : Fragment() { private lateinit var binding: FragmentPhotosBinding private var adapter: PhotosAdapter? = null private val viewModel: PhotosViewModel by viewModels() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { binding = FragmentPhotosBinding.inflate(inflater, container, false) adapter = PhotosAdapter { gotoDetailPageByPhotoId(it) } binding.photosRecyclerView.adapter = adapter viewModel.loadPhotos(arguments?.getLong(KEY_ALBUM_ID)) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) with(viewModel) { isLoad.observe(viewLifecycleOwner) { it?.let { visibility -> binding.photosProgressBar.visibility = if (visibility) View.GONE else View.VISIBLE } } photoListReceivedLiveData.observe(viewLifecycleOwner) { it?.let { adapter?.addData(it) } } } } private fun gotoDetailPageByPhotoId(id: Long) { activity?.supportFragmentManager?.beginTransaction()?.replace( R.id.gallery_container, PhotoDetailFragment.newInstance(id), PhotoDetailFragment.FRAGMENT_NAME )?.addToBackStack(PhotoDetailFragment.FRAGMENT_NAME)?.commitAllowingStateLoss() } override fun onDetach() { super.onDetach() adapter = null } companion object { val FRAGMENT_NAME: String = PhotosFragment::class.java.name private const val KEY_ALBUM_ID = "KEY_ALBUM_ID" @JvmStatic fun newInstance(id: Long) = PhotosFragment().apply { arguments = Bundle().apply { putLong(KEY_ALBUM_ID, id) } } } } ================================================ FILE: presentation/src/main/java/com/android/presentation/photo/PhotosViewModel.kt ================================================ package com.android.presentation.photo import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.android.domain.model.Photo import com.android.domain.usecase.GetPhotosUseCase import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject /**A helper class for the UI controller that is responsible for * preparing data for the UI [PhotosFragment] * * @author ZARA * */ @HiltViewModel class PhotosViewModel @Inject constructor( private val getPhotosUseCase: com.android.domain.usecase.GetPhotosUseCase ) : ViewModel() { val photoListReceivedLiveData = MutableLiveData>() val isLoad = MutableLiveData() init { isLoad.value = false } fun loadPhotos(id: Long?) { if (id == null) return getPhotosUseCase.saveAlbumId(id) getPhotosUseCase.execute( onSuccess = { isLoad.value = true photoListReceivedLiveData.value = it }, onError = { it.printStackTrace() } ) } } ================================================ FILE: presentation/src/main/res/drawable/ic_launcher_background.xml ================================================ ================================================ FILE: presentation/src/main/res/drawable-nodpi/ic_launcher_background.xml ================================================ ================================================ FILE: presentation/src/main/res/drawable-nodpi/ic_paint_vector.xml ================================================ ================================================ FILE: presentation/src/main/res/drawable-nodpi/ic_star_empty_white_vector.xml ================================================ ================================================ FILE: presentation/src/main/res/drawable-nodpi/ic_star_full_vector.xml ================================================ ================================================ FILE: presentation/src/main/res/drawable-v24/ic_launcher_foreground.xml ================================================ ================================================ FILE: presentation/src/main/res/layout/activity_gallery.xml ================================================ ================================================ FILE: presentation/src/main/res/layout/fragment_albums.xml ================================================ ================================================ FILE: presentation/src/main/res/layout/fragment_photo_detail.xml ================================================ ================================================ FILE: presentation/src/main/res/layout/fragment_photos.xml ================================================ ================================================ FILE: presentation/src/main/res/layout/holder_album.xml ================================================ ================================================ FILE: presentation/src/main/res/layout/holder_photo.xml ================================================ ================================================ FILE: presentation/src/main/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: presentation/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml ================================================ ================================================ FILE: presentation/src/main/res/mipmap-anydpi-v33/ic_launcher.xml ================================================ ================================================ FILE: presentation/src/main/res/values/colors.xml ================================================ #48a999 #004c40 #FFDE54 #0336FF #202020 #737373 #b9b9b9 #f4f4f4 #FFFFFF #ffc800 #eaeaea #5a000000 ================================================ FILE: presentation/src/main/res/values/dimens.xml ================================================ 16dp 112sp 56sp 45sp 34sp 24sp 20sp 16sp 15sp 14sp 13sp 12sp 14sp 160dp 180dp 140dp 260dp ================================================ FILE: presentation/src/main/res/values/strings.xml ================================================ ArtGallery ================================================ FILE: presentation/src/main/res/values/themes.xml ================================================ ================================================ FILE: presentation/src/main/res/values-night/themes.xml ================================================ ================================================ FILE: presentation/src/test/java/com/android/presentation/ExampleUnitTest.kt ================================================ package com.android.presentation import org.junit.Test import org.junit.Assert.* /** * Example local unit test, which will execute on the development machine (host). * * See [testing documentation](http://d.android.com/tools/testing). */ class ExampleUnitTest { @Test fun addition_isCorrect() { assertEquals(4, 2 + 2) } } ================================================ FILE: settings.gradle ================================================ include ':app' include ':domain' include ':data' include ':presentation'