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'