#2 Code challenge Ana Sekuloski

Uzavrená
ana.sekuloski chce zlúčiť 16 revíziu z vetvy ana.sekuloski/code-challenge do vetvy LiveLike/master
31 zmenil súbory, kde vykonal 699 pridanie a 34 odobranie
  1. 1 6
      .gitignore
  2. 16 5
      app/build.gradle
  3. 3 1
      app/src/main/AndroidManifest.xml
  4. 29 0
      app/src/main/java/com/livelike/livelikeandroidchallenge/AppTestActivity.kt
  5. 24 0
      app/src/main/java/com/livelike/livelikeandroidchallenge/FlickrServiceTestViewModel.kt
  6. 0 11
      app/src/main/java/com/livelike/livelikeandroidchallenge/MainActivity.kt
  7. 2 2
      app/src/main/res/layout/activity_main.xml
  8. 2 2
      build.gradle
  9. 27 6
      flickersearchlibrary/build.gradle
  10. 8 0
      flickersearchlibrary/src/main/AndroidManifest.xml
  11. 49 0
      flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/Flickr.kt
  12. 34 0
      flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/api/FlickrApi.kt
  13. 29 0
      flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/api/model/Photo.kt
  14. 16 0
      flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/api/model/PhotosPage.kt
  15. 11 0
      flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/api/model/response/ApiResponse.kt
  16. 9 0
      flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/api/model/response/FlickrResponse.kt
  17. 33 0
      flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/api/utils/ApiManager.kt
  18. 25 0
      flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/api/utils/Constants.kt
  19. 7 0
      flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/api/utils/PhotoUrlUtil.kt
  20. 44 0
      flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/di/Dependencies.kt
  21. 29 0
      flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/service/FlickrService.kt
  22. 48 0
      flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/service/logic/FlickrServiceLogic.kt
  23. 89 0
      flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/ui/FlickrPhotosActivity.kt
  24. 52 0
      flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/ui/FlickrPhotosViewModel.kt
  25. 55 0
      flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/ui/adapter/FlickrPhotosAdapter.kt
  26. 12 0
      flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/ui/adapter/PhotoItemsDiffCallback.kt
  27. 3 0
      flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/ui/model/PhotoItem.kt
  28. 29 0
      flickersearchlibrary/src/main/res/layout/activity_flickr_photos.xml
  29. 8 0
      flickersearchlibrary/src/main/res/layout/layout_photo_item.xml
  30. 4 0
      flickersearchlibrary/src/main/res/values/strings.xml
  31. 1 1
      gradle/wrapper/gradle-wrapper.properties

+ 1 - 6
.gitignore

@@ -1,12 +1,7 @@
 *.iml
 .gradle
 /local.properties
-/.idea/caches
-/.idea/libraries
-/.idea/modules.xml
-/.idea/workspace.xml
-/.idea/navEditor.xml
-/.idea/assetWizardSettings.xml
+/.idea
 .DS_Store
 /build
 /captures

+ 16 - 5
app/build.gradle

@@ -4,7 +4,7 @@ plugins {
 }
 
 android {
-    compileSdk 30
+    compileSdk 31
 
     defaultConfig {
         applicationId "com.livelike.livelikeandroidchallenge"
@@ -29,15 +29,26 @@ android {
     kotlinOptions {
         jvmTarget = '1.8'
     }
+
+    viewBinding {
+        enabled = true
+    }
 }
 
 dependencies {
 
     implementation 'androidx.core:core-ktx:1.7.0'
-    implementation 'androidx.appcompat:appcompat:1.4.0'
-    implementation 'com.google.android.material:material:1.4.0'
-    implementation 'androidx.constraintlayout:constraintlayout:2.1.2'
-    testImplementation 'junit:junit:4.+'
+    implementation 'androidx.appcompat:appcompat:1.4.1'
+    implementation 'com.google.android.material:material:1.5.0'
+    implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
+
+    // View model lifecycle
+    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0"
+    implementation "androidx.fragment:fragment-ktx:1.4.1"
+
+    // Flickr search library
+    implementation project(path: ':flickersearchlibrary')
+
     androidTestImplementation 'androidx.test.ext:junit:1.1.3'
     androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
 }

+ 3 - 1
app/src/main/AndroidManifest.xml

@@ -2,6 +2,8 @@
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
     package="com.livelike.livelikeandroidchallenge">
 
+    <uses-permission android:name="android.permission.INTERNET" />
+
     <application
         android:allowBackup="true"
         android:icon="@mipmap/ic_launcher"
@@ -10,7 +12,7 @@
         android:supportsRtl="true"
         android:theme="@style/Theme.LiveLikeAndroidChallenge">
         <activity
-            android:name=".MainActivity"
+            android:name="com.livelike.flickersearchlibrary.ui.FlickrPhotosActivity"
             android:exported="true">
             <intent-filter>
                 <action android:name="android.intent.action.MAIN" />

+ 29 - 0
app/src/main/java/com/livelike/livelikeandroidchallenge/AppTestActivity.kt

@@ -0,0 +1,29 @@
+package com.livelike.livelikeandroidchallenge
+
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.lifecycleScope
+import com.livelike.livelikeandroidchallenge.databinding.ActivityMainBinding
+
+class AppTestActivity : AppCompatActivity() {
+
+    private val testViewModel: FlickrServiceTestViewModel by viewModels()
+
+    private val views: ActivityMainBinding by lazy {
+        ActivityMainBinding.inflate(layoutInflater)
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContentView(views.root)
+
+        testViewModel.photos.observe(this) { photosTitles ->
+            views.textView.text = photosTitles
+        }
+
+        lifecycleScope.launchWhenCreated {
+            testViewModel.getRecentPhotosTitles()
+        }
+    }
+}

+ 24 - 0
app/src/main/java/com/livelike/livelikeandroidchallenge/FlickrServiceTestViewModel.kt

@@ -0,0 +1,24 @@
+package com.livelike.livelikeandroidchallenge
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.livelike.flickersearchlibrary.Flickr
+import kotlinx.coroutines.launch
+
+class FlickrServiceTestViewModel : ViewModel() {
+
+    private val _photosTitles = MutableLiveData<String>()
+    val photos: LiveData<String> = _photosTitles
+
+    fun getRecentPhotosTitles() = viewModelScope.launch {
+        val flickrPhotos = Flickr.getRecentPhotos(10)
+        var photosTitles = "Recent photos titles:\n"
+        flickrPhotos.onEach { photoItem ->
+            photosTitles = photosTitles.plus(photoItem.title).plus('\n')
+        }
+        _photosTitles.postValue(photosTitles)
+    }
+
+}

+ 0 - 11
app/src/main/java/com/livelike/livelikeandroidchallenge/MainActivity.kt

@@ -1,11 +0,0 @@
-package com.livelike.livelikeandroidchallenge
-
-import androidx.appcompat.app.AppCompatActivity
-import android.os.Bundle
-
-class MainActivity : AppCompatActivity() {
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        setContentView(R.layout.activity_main)
-    }
-}

+ 2 - 2
app/src/main/res/layout/activity_main.xml

@@ -4,12 +4,12 @@
     xmlns:tools="http://schemas.android.com/tools"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
-    tools:context=".MainActivity">
+    tools:context=".AppTestActivity">
 
     <TextView
+        android:id="@+id/textView"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
-        android:text="Hello World!"
         app:layout_constraintBottom_toBottomOf="parent"
         app:layout_constraintLeft_toLeftOf="parent"
         app:layout_constraintRight_toRightOf="parent"

+ 2 - 2
build.gradle

@@ -5,8 +5,8 @@ buildscript {
         mavenCentral()
     }
     dependencies {
-        classpath "com.android.tools.build:gradle:7.0.3"
-        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.0"
+        classpath 'com.android.tools.build:gradle:7.1.0'
+        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10"
 
         // NOTE: Do not place your application dependencies here; they belong
         // in the individual module build.gradle files

+ 27 - 6
flickersearchlibrary/build.gradle

@@ -1,15 +1,14 @@
 plugins {
     id 'com.android.library'
+    id 'kotlin-android'
 }
 
 android {
-    compileSdk 30
+    compileSdk 31
 
     defaultConfig {
         minSdk 21
         targetSdk 30
-        versionCode 1
-        versionName "1.0"
 
         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
         consumerProguardFiles "consumer-rules.pro"
@@ -25,13 +24,35 @@ android {
         sourceCompatibility JavaVersion.VERSION_1_8
         targetCompatibility JavaVersion.VERSION_1_8
     }
+
+    viewBinding {
+        enabled = true
+    }
+
 }
 
 dependencies {
 
-    implementation 'androidx.appcompat:appcompat:1.4.0'
-    implementation 'com.google.android.material:material:1.4.0'
-    testImplementation 'junit:junit:4.+'
+    // Networking libraries (Retrofit)
+    implementation "com.squareup.retrofit2:retrofit:2.9.0"
+    implementation "com.squareup.retrofit2:converter-moshi:2.9.0"
+    implementation "com.squareup.moshi:moshi-kotlin:1.13.0"
+
+    // Koin for dependencies
+    implementation "io.insert-koin:koin-android:3.1.5"
+
+    // Android
+    implementation 'androidx.appcompat:appcompat:1.4.1'
+    implementation 'com.google.android.material:material:1.5.0'
+    implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
+
+    // View model lifecycle
+    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0"
+    implementation "androidx.fragment:fragment-ktx:1.4.1"
+
+    // Glide Image loading library
+    implementation "com.github.bumptech.glide:glide:4.12.0"
+
     androidTestImplementation 'androidx.test.ext:junit:1.1.3'
     androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
 }

+ 8 - 0
flickersearchlibrary/src/main/AndroidManifest.xml

@@ -2,4 +2,12 @@
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
     package="com.livelike.flickersearchlibrary">
 
+    <uses-permission android:name="android.permission.INTERNET" />
+
+    <application>
+        <activity
+            android:name=".ui.FlickrPhotosActivity"
+            android:exported="true" />
+    </application>
+
 </manifest>

+ 49 - 0
flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/Flickr.kt

@@ -0,0 +1,49 @@
+package com.livelike.flickersearchlibrary
+
+import com.livelike.flickersearchlibrary.api.model.Photo
+import com.livelike.flickersearchlibrary.di.Dependencies
+import com.livelike.flickersearchlibrary.service.FlickrService
+import org.koin.java.KoinJavaComponent.inject
+
+/**
+ * Defines Flickr service features.
+ */
+object Flickr {
+
+    init {
+        Dependencies.init()
+    }
+
+    private val flickrService: FlickrService by inject(FlickrService::class.java)
+
+    private const val DEFAULT_NUMBER_OF_PHOTOS_PER_PAGE = 20
+
+    /**
+     * Provides search results for given search term. [numOfPhotos] will be returned on given [page].
+     * By default 20 photos will be returned on the first page.
+     * If no search query is provided, empty results will be returned.
+     *
+     * @param searchQuery [String] the search term for which photo results should be returned.
+     * @param numOfPhotos [Int] number of photos to return.
+     * @param page [Int] the page where the photos should be contained.
+     * @return [List] of [Photo]s matching the given search term, or empty results if no term is provided.
+     */
+    suspend fun search(
+        searchQuery: String?, numOfPhotos: Int = DEFAULT_NUMBER_OF_PHOTOS_PER_PAGE,
+        page: Int = 1
+    ) = flickrService.search(searchQuery, numOfPhotos, page)
+
+    /**
+     * Retrieves [numOfPhotos] recently published photos on Flickr on the given [page].
+     * By default, 20 photos are returned, and the default page is the first page.
+     *
+     * @param numOfPhotos [Int] number of photos to return.
+     * @param page [Int] the page where photos should be contained.
+     * @return [List] of recently published photos on Flickr.
+     */
+    suspend fun getRecentPhotos(
+        numOfPhotos: Int = DEFAULT_NUMBER_OF_PHOTOS_PER_PAGE,
+        page: Int = 1
+    ) = flickrService.getRecentPhotos(numOfPhotos, page)
+
+}

+ 34 - 0
flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/api/FlickrApi.kt

@@ -0,0 +1,34 @@
+package com.livelike.flickersearchlibrary.api
+
+import com.livelike.flickersearchlibrary.api.model.response.FlickrResponse
+import com.livelike.flickersearchlibrary.api.utils.*
+import retrofit2.Response
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+/**
+ * API interface for Flickr services.
+ */
+internal interface FlickrApi {
+
+    @GET(".")
+    suspend fun search(
+        @Query(QUERY_TEXT) searchQuery: String,
+        @Query(QUERY_PER_PAGE) perPage: Int,
+        @Query(QUERY_PAGE) page: Int,
+        @Query(QUERY_METHOD) method: String = SEARCH_METHOD,
+        @Query(QUERY_API_KEY) apiKey: String = DEFAULT_API_KEY,
+        @Query(QUERY_FORMAT) format: String = JSON_FORMAT,
+        @Query(QUERY_NO_JSON_CALLBACK) noJsonCallback: Boolean = NO_JSON_CALLBACK,
+    ): Response<FlickrResponse>
+
+    @GET(".")
+    suspend fun getRecent(
+        @Query(QUERY_PER_PAGE) perPage: Int,
+        @Query(QUERY_PAGE) page: Int,
+        @Query(QUERY_METHOD) method: String = RECENT_IMAGES_METHOD,
+        @Query(QUERY_API_KEY) apiKey: String = DEFAULT_API_KEY,
+        @Query(QUERY_FORMAT) format: String = JSON_FORMAT,
+        @Query(QUERY_NO_JSON_CALLBACK) noJsonCallback: Boolean = NO_JSON_CALLBACK,
+    ): Response<FlickrResponse>
+}

+ 29 - 0
flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/api/model/Photo.kt

@@ -0,0 +1,29 @@
+package com.livelike.flickersearchlibrary.api.model
+
+import com.livelike.flickersearchlibrary.api.utils.createPhotoUrl
+import com.livelike.flickersearchlibrary.ui.model.PhotoItem
+import com.squareup.moshi.Json
+
+internal data class Photo(
+    @Json(name = "id")
+    val id: String,
+    @Json(name = "owner")
+    val owner: String,
+    @Json(name = "secret")
+    val secret: String,
+    @Json(name = "server")
+    val server: String,
+    @Json(name = "farm")
+    val farm: Int,
+    @Json(name = "title")
+    val title: String,
+    @Json(name = "ispublic")
+    val isPublic: Int,
+    @Json(name = "isfriend")
+    val isFriend: Int,
+    @Json(name = "isfamily")
+    val isFamily: Int
+) {
+    fun toPhotoItem() = PhotoItem(createPhotoUrl(server, id, secret), title)
+}
+

+ 16 - 0
flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/api/model/PhotosPage.kt

@@ -0,0 +1,16 @@
+package com.livelike.flickersearchlibrary.api.model
+
+import com.squareup.moshi.Json
+
+internal data class PhotosPage(
+    @Json(name = "page")
+    val page: Int,
+    @Json(name = "pages")
+    val pages: Int,
+    @Json(name = "perpage")
+    val perpage: Int,
+    @Json(name = "total")
+    val total: Int,
+    @Json(name = "photo")
+    val photos: List<Photo>
+)

+ 11 - 0
flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/api/model/response/ApiResponse.kt

@@ -0,0 +1,11 @@
+package com.livelike.flickersearchlibrary.api.model.response
+
+import com.livelike.flickersearchlibrary.api.model.Photo
+
+/**
+ * Represents possible states of API response.
+ */
+internal sealed class ApiResponse {
+    data class Success(val body: List<Photo>) : ApiResponse()
+    data class Error(val message: String) : ApiResponse()
+}

+ 9 - 0
flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/api/model/response/FlickrResponse.kt

@@ -0,0 +1,9 @@
+package com.livelike.flickersearchlibrary.api.model.response
+
+import com.livelike.flickersearchlibrary.api.model.PhotosPage
+import com.squareup.moshi.Json
+
+internal data class FlickrResponse(
+    @Json(name = "photos")
+    val photosPage: PhotosPage
+)

+ 33 - 0
flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/api/utils/ApiManager.kt

@@ -0,0 +1,33 @@
+package com.livelike.flickersearchlibrary.api.utils
+
+import android.util.Log
+import com.livelike.flickersearchlibrary.api.model.response.ApiResponse
+import com.livelike.flickersearchlibrary.api.model.response.FlickrResponse
+import retrofit2.Response
+
+/**
+ * Executes given Flickr API call and handles errors or exceptions.
+ *
+ * @return [ApiResponse] with state and data depending on executed API call.
+ */
+internal suspend fun executeApiCall(apiCall: suspend () -> Response<FlickrResponse>): ApiResponse {
+    return runCatching {
+        val response = apiCall()
+        if (response.isSuccessful) {
+            ApiResponse.Success(response.body()?.photosPage?.photos ?: emptyList())
+        } else {
+            throw Exception(
+                "Flickr API responded with error: ${
+                    response.errorBody().toString()
+                }"
+            )
+        }
+    }.onFailure { exception: Throwable ->
+        Log.d(
+            "FlickrApiManager",
+            "Exception occurred when executing API call: ${exception.message}"
+        )
+    }.getOrElse { exception: Throwable ->
+        ApiResponse.Error(exception.message.orEmpty())
+    }
+}

+ 25 - 0
flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/api/utils/Constants.kt

@@ -0,0 +1,25 @@
+package com.livelike.flickersearchlibrary.api.utils
+
+// Base URL for the Flickr API
+internal const val BASE_URL = "https://www.flickr.com/services/rest/"
+internal const val PHOTO_BASE_URL = "https://live.staticflickr.com/"
+
+// Constant values for the FlickrApi interface
+internal const val DEFAULT_API_KEY = "3976fde3792b699fbcda31f52e1cb306"
+internal const val JSON_FORMAT = "json"
+internal const val NO_JSON_CALLBACK = true
+
+// Constant Query keys for the Flickr API interface
+internal const val QUERY_API_KEY = "api_key"
+internal const val QUERY_FORMAT = "format"
+internal const val QUERY_NO_JSON_CALLBACK = "nojsoncallback"
+internal const val QUERY_METHOD = "method"
+
+// Constant values for methods supported in the library
+internal const val SEARCH_METHOD = "flickr.photos.search"
+internal const val RECENT_IMAGES_METHOD = "flickr.photos.getRecent"
+
+// Constant Query keys for arguments supported in the library
+internal const val QUERY_TEXT = "text"
+internal const val QUERY_PER_PAGE = "per_page"
+internal const val QUERY_PAGE = "page"

+ 7 - 0
flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/api/utils/PhotoUrlUtil.kt

@@ -0,0 +1,7 @@
+package com.livelike.flickersearchlibrary.api.utils
+
+/**
+ * Creates photo URL string from given parameters. The photo is with small size.
+ */
+internal fun createPhotoUrl(serverId: String, photoId: String, secret: String) =
+    "$PHOTO_BASE_URL/$serverId/${photoId}_${secret}_t.jpg"

+ 44 - 0
flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/di/Dependencies.kt

@@ -0,0 +1,44 @@
+package com.livelike.flickersearchlibrary.di
+
+import com.livelike.flickersearchlibrary.api.FlickrApi
+import com.livelike.flickersearchlibrary.api.utils.BASE_URL
+import com.livelike.flickersearchlibrary.service.FlickrService
+import com.livelike.flickersearchlibrary.service.logic.FlickrServiceLogic
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
+import org.koin.core.context.startKoin
+import org.koin.dsl.module
+import retrofit2.Retrofit
+import retrofit2.converter.moshi.MoshiConverterFactory
+
+/**
+ * Defines dependencies required by Flickr service features.
+ */
+internal object Dependencies {
+
+    private val flickrModule = module {
+
+        single<FlickrApi> {
+            val moshi = Moshi.Builder()
+                .add(KotlinJsonAdapterFactory())
+                .build()
+            val retrofit = Retrofit.Builder()
+                .baseUrl(BASE_URL)
+                .addConverterFactory(MoshiConverterFactory.create(moshi))
+                .build()
+            retrofit.create(FlickrApi::class.java)
+        }
+
+        single<FlickrService> { FlickrServiceLogic(get()) }
+
+    }
+
+    /**
+     * Initializes all dependencies needed for Flick service features.
+     */
+    internal fun init() {
+        startKoin {
+            modules(flickrModule)
+        }
+    }
+}

+ 29 - 0
flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/service/FlickrService.kt

@@ -0,0 +1,29 @@
+package com.livelike.flickersearchlibrary.service
+
+import com.livelike.flickersearchlibrary.ui.model.PhotoItem
+
+/**
+ * Interface for the service providing Flickr features.
+ */
+internal interface FlickrService {
+
+    /**
+     * Searches for photos and retrieves [List] of [PhotoItem]s. [numOfPhotos] will be returned on given [page].
+     * If no search query is provided, empty list will be returned.
+     *
+     * @param searchQuery [String] text query for searching photos.
+     * @param numOfPhotos [Int] number of photos to return.
+     * @param page [Int] the page where the photos should be contained.
+     * @return [List] of [PhotoItem]s matching given [searchQuery] or empty list if no query is provided.
+     */
+    suspend fun search(searchQuery: String?, numOfPhotos: Int, page: Int): List<PhotoItem>
+
+    /**
+     * Retrieves [numOfPhotos] recently published photos on Flickr on the given [page].
+     *
+     * @param numOfPhotos [Int] number of photos to return.
+     * @param page [Int] the page where the photos should be contained.
+     * @return [List] of recently published photos on Flickr.
+     */
+    suspend fun getRecentPhotos(numOfPhotos: Int, page: Int): List<PhotoItem>
+}

+ 48 - 0
flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/service/logic/FlickrServiceLogic.kt

@@ -0,0 +1,48 @@
+package com.livelike.flickersearchlibrary.service.logic
+
+import com.livelike.flickersearchlibrary.api.FlickrApi
+import com.livelike.flickersearchlibrary.api.model.response.ApiResponse
+import com.livelike.flickersearchlibrary.api.utils.executeApiCall
+import com.livelike.flickersearchlibrary.service.FlickrService
+import com.livelike.flickersearchlibrary.ui.model.PhotoItem
+
+/**
+ * Implementation of [FlickrService].
+ */
+internal class FlickrServiceLogic(private val api: FlickrApi) : FlickrService {
+
+    private val cachedPhotos = mutableListOf<PhotoItem>()
+
+    override suspend fun search(
+        searchQuery: String?,
+        numOfPhotos: Int,
+        page: Int
+    ): List<PhotoItem> {
+        return if (!searchQuery.isNullOrBlank()) {
+            val response = executeApiCall {
+                api.search(searchQuery, numOfPhotos, page)
+            }
+            if (response is ApiResponse.Success) {
+                val photoItems = response.body.map {
+                    it.toPhotoItem()
+                }
+                cachedPhotos.clear()
+                cachedPhotos.addAll(photoItems)
+            }
+
+            cachedPhotos
+        } else {
+            emptyList()
+        }
+    }
+
+    override suspend fun getRecentPhotos(numOfPhotos: Int, page: Int): List<PhotoItem> {
+        val response = executeApiCall {
+            api.getRecent(numOfPhotos, page)
+        }
+        return when (response) {
+            is ApiResponse.Success -> response.body.map { it.toPhotoItem() }
+            is ApiResponse.Error -> emptyList()
+        }
+    }
+}

+ 89 - 0
flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/ui/FlickrPhotosActivity.kt

@@ -0,0 +1,89 @@
+package com.livelike.flickersearchlibrary.ui
+
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.widget.SearchView
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.livelike.flickersearchlibrary.databinding.ActivityFlickrPhotosBinding
+import com.livelike.flickersearchlibrary.ui.adapter.FlickrPhotosAdapter
+
+class FlickrPhotosActivity : AppCompatActivity() {
+
+    private val viewModel: FlickrPhotosViewModel by viewModels()
+
+    private val views: ActivityFlickrPhotosBinding by lazy {
+        ActivityFlickrPhotosBinding.inflate(layoutInflater)
+    }
+    private val photosAdapter = FlickrPhotosAdapter()
+
+    private companion object {
+        private const val PHOTOS_DEFAULT_COLUMN_COUNT = 3
+        private const val BOTTOM_DIRECTION = 1
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContentView(views.root)
+        setupViews()
+        setupListeners()
+        setupObservers()
+        initPhotosList()
+    }
+
+    private fun setupViews() {
+        views.gridView.apply {
+            adapter = photosAdapter
+            layoutManager = GridLayoutManager(
+                this@FlickrPhotosActivity,
+                PHOTOS_DEFAULT_COLUMN_COUNT
+            )
+        }
+        views.searchView.requestFocus()
+    }
+
+    private fun setupListeners() {
+        views.searchView.setOnSearchClickListener {
+            search(views.searchView.query.toString())
+        }
+
+        views.searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
+            override fun onQueryTextSubmit(query: String?): Boolean {
+                search(query)
+                return true
+            }
+
+            override fun onQueryTextChange(newText: String?): Boolean {
+                return true
+            }
+        })
+
+        views.gridView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
+            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+                super.onScrolled(recyclerView, dx, dy)
+                if (!recyclerView.canScrollVertically(BOTTOM_DIRECTION)) {
+                    viewModel.loadMorePhotos()
+                }
+            }
+        })
+    }
+
+    private fun setupObservers() {
+        viewModel.photos.observe(this) {
+            photosAdapter.appendItems(it)
+        }
+    }
+
+    private fun initPhotosList() {
+        lifecycleScope.launchWhenCreated {
+            viewModel.getRecentPhotos()
+        }
+    }
+
+    private fun search(searchQuery: String?) {
+        photosAdapter.clearItems()
+        viewModel.searchPhotos(searchQuery)
+    }
+}

+ 52 - 0
flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/ui/FlickrPhotosViewModel.kt

@@ -0,0 +1,52 @@
+package com.livelike.flickersearchlibrary.ui
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.livelike.flickersearchlibrary.Flickr
+import com.livelike.flickersearchlibrary.ui.model.PhotoItem
+import kotlinx.coroutines.launch
+
+internal class FlickrPhotosViewModel : ViewModel() {
+
+    private val _photos = MutableLiveData<List<PhotoItem>>()
+    val photos: LiveData<List<PhotoItem>> = _photos
+
+    private lateinit var visiblePhotosState: PhotosState
+
+    fun getRecentPhotos() = viewModelScope.launch {
+        _photos.postValue(Flickr.getRecentPhotos())
+        visiblePhotosState = PhotosState(PhotoType.RECENT, 1, null)
+    }
+
+    fun searchPhotos(searchQuery: String?) = viewModelScope.launch {
+        _photos.postValue(Flickr.search(searchQuery))
+        visiblePhotosState = PhotosState(PhotoType.SEARCH, 1, searchQuery)
+    }
+
+    fun loadMorePhotos() = viewModelScope.launch {
+        val nextPage = visiblePhotosState.currentPage + 1
+        when (visiblePhotosState.type) {
+            PhotoType.RECENT -> _photos.postValue(Flickr.getRecentPhotos(page = nextPage))
+            PhotoType.SEARCH -> _photos.postValue(
+                Flickr.search(
+                    page = nextPage,
+                    searchQuery = visiblePhotosState.searchQuery
+                )
+            )
+        }
+        visiblePhotosState = visiblePhotosState.copy(currentPage = nextPage)
+    }
+
+    private data class PhotosState(
+        var type: PhotoType,
+        var currentPage: Int,
+        var searchQuery: String?
+    )
+
+    private enum class PhotoType {
+        RECENT,
+        SEARCH
+    }
+}

+ 55 - 0
flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/ui/adapter/FlickrPhotosAdapter.kt

@@ -0,0 +1,55 @@
+package com.livelike.flickersearchlibrary.ui.adapter
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.AsyncListDiffer
+import androidx.recyclerview.widget.RecyclerView
+import com.bumptech.glide.Glide
+import com.livelike.flickersearchlibrary.databinding.LayoutPhotoItemBinding
+import com.livelike.flickersearchlibrary.ui.model.PhotoItem
+
+class FlickrPhotosAdapter : RecyclerView.Adapter<FlickrPhotosAdapter.PhotoViewHolder>() {
+
+    private val itemsListDiffer = AsyncListDiffer(this, PhotoItemsDiffCallback())
+
+    fun appendItems(items: List<PhotoItem>) {
+        val currentList = itemsListDiffer.currentList.toMutableList()
+        currentList.addAll(items)
+        itemsListDiffer.submitList(currentList)
+    }
+
+    fun clearItems() {
+        itemsListDiffer.submitList(emptyList())
+    }
+
+    override fun onCreateViewHolder(
+        parent: ViewGroup,
+        viewType: Int
+    ): FlickrPhotosAdapter.PhotoViewHolder {
+        val view = LayoutPhotoItemBinding.inflate(
+            LayoutInflater.from(parent.context),
+            parent,
+            false
+        )
+        return PhotoViewHolder(view)
+    }
+
+    override fun onBindViewHolder(holder: FlickrPhotosAdapter.PhotoViewHolder, position: Int) {
+        val item = itemsListDiffer.currentList[position]
+        holder.bind(item)
+    }
+
+    override fun getItemCount() = itemsListDiffer.currentList.size
+
+    inner class PhotoViewHolder(private val view: LayoutPhotoItemBinding) :
+        RecyclerView.ViewHolder(view.root) {
+
+        fun bind(photoItem: PhotoItem) {
+            Glide
+                .with(view.root)
+                .load(photoItem.imageUrl)
+                .centerCrop()
+                .into(view.imageView)
+        }
+    }
+}

+ 12 - 0
flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/ui/adapter/PhotoItemsDiffCallback.kt

@@ -0,0 +1,12 @@
+package com.livelike.flickersearchlibrary.ui.adapter
+
+import androidx.recyclerview.widget.DiffUtil
+import com.livelike.flickersearchlibrary.ui.model.PhotoItem
+
+class PhotoItemsDiffCallback : DiffUtil.ItemCallback<PhotoItem>() {
+
+    override fun areItemsTheSame(oldItem: PhotoItem, newItem: PhotoItem) = oldItem == newItem
+
+    override fun areContentsTheSame(oldItem: PhotoItem, newItem: PhotoItem) =
+        oldItem.imageUrl == newItem.imageUrl && oldItem.title == newItem.title
+}

+ 3 - 0
flickersearchlibrary/src/main/java/com/livelike/flickersearchlibrary/ui/model/PhotoItem.kt

@@ -0,0 +1,3 @@
+package com.livelike.flickersearchlibrary.ui.model
+
+data class PhotoItem(val imageUrl: String, val title: String)

+ 29 - 0
flickersearchlibrary/src/main/res/layout/activity_flickr_photos.xml

@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".ui.FlickrPhotosActivity">
+
+    <androidx.appcompat.widget.SearchView
+        android:id="@+id/searchView"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        app:iconifiedByDefault="false"
+        app:layout_constraintBottom_toTopOf="@id/gridView"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:queryHint="@string/search_photos_text" />
+
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/gridView"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/searchView" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 8 - 0
flickersearchlibrary/src/main/res/layout/layout_photo_item.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/imageView"
+    android:layout_width="150dp"
+    android:layout_height="150dp"
+    android:contentDescription="@string/flickr_photo_content_description">
+
+</ImageView>

+ 4 - 0
flickersearchlibrary/src/main/res/values/strings.xml

@@ -0,0 +1,4 @@
+<resources>
+    <string name="search_photos_text">Search photos</string>
+    <string name="flickr_photo_content_description">Flickr photo</string>
+</resources>

+ 1 - 1
gradle/wrapper/gradle-wrapper.properties

@@ -1,6 +1,6 @@
 #Tue Dec 14 13:30:57 IST 2021
 distributionBase=GRADLE_USER_HOME
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
 distributionPath=wrapper/dists
 zipStorePath=wrapper/dists
 zipStoreBase=GRADLE_USER_HOME