diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 1bec35e..fc3105b 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,5 +1,8 @@ + + diff --git a/.idea/misc.xml b/.idea/misc.xml index af0bbdd..703e5d4 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -5,7 +5,7 @@ - + diff --git a/app/build.gradle b/app/build.gradle index aaaa8ef..aa01acd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,13 +5,19 @@ apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' android { - compileSdkVersion 28 + compileSdkVersion rootProject.ext.compileSdkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + defaultConfig { - applicationId "com.decathlon.android.apptest" - minSdkVersion 21 - targetSdkVersion 28 - versionCode 1 - versionName "1.0" + applicationId rootProject.ext.applicationId + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode rootProject.ext.appVersionCode + versionName rootProject.ext.appVersionName testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -23,12 +29,36 @@ android { } dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) - implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation 'androidx.appcompat:appcompat:1.0.2' - implementation 'androidx.core:core-ktx:1.0.2' - implementation 'androidx.constraintlayout:constraintlayout:1.1.3' - testImplementation 'junit:junit:4.12' - androidTestImplementation 'androidx.test:runner:1.1.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" + + //Androidx libraries + implementation "androidx.appcompat:appcompat:$appCompatVersion" + implementation "androidx.core:core-ktx:$coreKtxVersion" + implementation "androidx.constraintlayout:constraintlayout:$constraintLayoutVersion" + implementation "androidx.legacy:legacy-support-v4:$androidxLegacySupportV4Version" + implementation "androidx.recyclerview:recyclerview:$recyclerViewVersion" + + //Design Libraries + implementation "com.google.android.material:material:$materialVersion" + + //Network libraries + implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" + implementation "com.squareup.retrofit2:converter-moshi:$retrofitVersion" + implementation "com.squareup.moshi:moshi-kotlin:$moshiVersion" + implementation "com.squareup.moshi:moshi:$moshiVersion" + implementation "com.squareup.okhttp3:logging-interceptor:$okHttpVersion" + implementation "com.github.bumptech.glide:glide:$glideVersion" + annotationProcessor "com.github.bumptech.glide:compiler:$glideVersion" + + //Rx Libraries + implementation "io.reactivex.rxjava2:rxandroid:$rxAndroidVersion" + implementation "io.reactivex.rxjava2:rxjava:$rxJavaVersion" + implementation "com.jakewharton.rxbinding3:rxbinding:$rxBindingsVersion" + + //Test libraries + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "androidx.test:runner:$androidTestRunnerVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion" } diff --git a/app/src/androidTest/java/com/decathlon/android/apptest/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/decathlon/android/apptest/ExampleInstrumentedTest.kt index 2ef840e..c32c16d 100644 --- a/app/src/androidTest/java/com/decathlon/android/apptest/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/decathlon/android/apptest/ExampleInstrumentedTest.kt @@ -2,12 +2,10 @@ package com.decathlon.android.apptest import androidx.test.InstrumentationRegistry import androidx.test.runner.AndroidJUnit4 - +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* - /** * Instrumented test, which will execute on an Android device. * diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 24268c3..516f732 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,9 @@ + + + - + diff --git a/app/src/main/java/com/decathlon/android/apptest/common/base/BasePresenter.kt b/app/src/main/java/com/decathlon/android/apptest/common/base/BasePresenter.kt new file mode 100644 index 0000000..8f02e95 --- /dev/null +++ b/app/src/main/java/com/decathlon/android/apptest/common/base/BasePresenter.kt @@ -0,0 +1,5 @@ +package com.decathlon.android.apptest.common.base + +interface BasePresenter { + fun onDestroy() +} \ No newline at end of file diff --git a/app/src/main/java/com/decathlon/android/apptest/common/base/BaseView.kt b/app/src/main/java/com/decathlon/android/apptest/common/base/BaseView.kt new file mode 100644 index 0000000..a1c39da --- /dev/null +++ b/app/src/main/java/com/decathlon/android/apptest/common/base/BaseView.kt @@ -0,0 +1,5 @@ +package com.decathlon.android.apptest.common.base + +interface BaseView { + var presenter: T +} \ No newline at end of file diff --git a/app/src/main/java/com/decathlon/android/apptest/common/base/SingleUseCase.kt b/app/src/main/java/com/decathlon/android/apptest/common/base/SingleUseCase.kt new file mode 100644 index 0000000..48f801c --- /dev/null +++ b/app/src/main/java/com/decathlon/android/apptest/common/base/SingleUseCase.kt @@ -0,0 +1,51 @@ +package com.decathlon.android.apptest.common.base + +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.disposables.Disposable +import io.reactivex.observers.DisposableSingleObserver +import io.reactivex.schedulers.Schedulers + +/** + * Abstract class for a UseCase that returns an instance of a [Single]. + */ +abstract class SingleUseCase { + + private val disposables = CompositeDisposable() + + /** + * Builds a [Single] which will be used when the current [SingleUseCase] is executed. + */ + protected abstract fun buildUseCaseObservable(params: Params? = null): Single + + /** + * Executes the current use case. + * + * @param observer {@link DisposableSingleObserver} which will be listening to the single build + * by {@link #buildUseCaseObservable(Params)} ()} method. + * @param params Parameters (Optional) used to build/execute this use case. + */ + open fun execute(singleObserver: DisposableSingleObserver, params: Params? = null) { + val single = this.buildUseCaseObservable(params) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + addDisposable(single.subscribeWith(singleObserver)) + } + + /** + * Dispose from current [CompositeDisposable]. + */ + fun dispose() { + if (!disposables.isDisposed) { + disposables.dispose() + } + } + + /** + * Dispose from current [CompositeDisposable]. + */ + private fun addDisposable(disposable: Disposable) { + disposables.add(disposable) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/decathlon/android/apptest/common/base/UseCase.kt b/app/src/main/java/com/decathlon/android/apptest/common/base/UseCase.kt new file mode 100644 index 0000000..6967328 --- /dev/null +++ b/app/src/main/java/com/decathlon/android/apptest/common/base/UseCase.kt @@ -0,0 +1,51 @@ +package com.decathlon.android.apptest.common.base + +import io.reactivex.Observable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.disposables.Disposable +import io.reactivex.observers.DisposableObserver +import io.reactivex.schedulers.Schedulers + +/** + * Abstract class for a UseCase that returns an instance of a [Observable]. + */ +abstract class UseCase { + + private val disposables = CompositeDisposable() + + /** + * Builds an {@link Observable} which will be used when executing the current {@link UseCase}. + */ + abstract fun buildUseCaseObservable(params: Params?): Observable + + /** + * Executes the current use case. + * + * @param observer {@link DisposableObserver} which will be listening to the observable build + * by {@link #buildUseCaseObservable(Params)} ()} method. + * @param params Parameters (Optional) used to build/execute this use case. + */ + fun execute(observer: DisposableObserver, params: Params?) { + val observable = this.buildUseCaseObservable(params) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + addDisposable(observable.subscribeWith(observer)) + } + + /** + * Dispose from current [CompositeDisposable]. + */ + fun dispose() { + if (!disposables.isDisposed) { + disposables.dispose() + } + } + + /** + * Dispose from current [CompositeDisposable]. + */ + private fun addDisposable(disposable: Disposable) { + disposables.add(disposable) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/decathlon/android/apptest/common/exception/EmptyBodyException.kt b/app/src/main/java/com/decathlon/android/apptest/common/exception/EmptyBodyException.kt new file mode 100644 index 0000000..3114b0e --- /dev/null +++ b/app/src/main/java/com/decathlon/android/apptest/common/exception/EmptyBodyException.kt @@ -0,0 +1,3 @@ +package com.decathlon.android.apptest.common.exception + +class EmptyBodyException : Exception() \ No newline at end of file diff --git a/app/src/main/java/com/decathlon/android/apptest/common/exception/ForbiddenException.kt b/app/src/main/java/com/decathlon/android/apptest/common/exception/ForbiddenException.kt new file mode 100644 index 0000000..1b92005 --- /dev/null +++ b/app/src/main/java/com/decathlon/android/apptest/common/exception/ForbiddenException.kt @@ -0,0 +1,3 @@ +package com.decathlon.android.apptest.common.exception + +class ForbiddenException : Exception() \ No newline at end of file diff --git a/app/src/main/java/com/decathlon/android/apptest/common/exception/NoConnectivityException.kt b/app/src/main/java/com/decathlon/android/apptest/common/exception/NoConnectivityException.kt new file mode 100644 index 0000000..78c4a67 --- /dev/null +++ b/app/src/main/java/com/decathlon/android/apptest/common/exception/NoConnectivityException.kt @@ -0,0 +1,5 @@ +package com.decathlon.android.apptest.common.exception + +import java.io.IOException + +class NoConnectivityException : IOException() \ No newline at end of file diff --git a/app/src/main/java/com/decathlon/android/apptest/common/exception/UnknownErrorException.kt b/app/src/main/java/com/decathlon/android/apptest/common/exception/UnknownErrorException.kt new file mode 100644 index 0000000..f4ac227 --- /dev/null +++ b/app/src/main/java/com/decathlon/android/apptest/common/exception/UnknownErrorException.kt @@ -0,0 +1,3 @@ +package com.decathlon.android.apptest.common.exception + +class UnknownErrorException : Exception() \ No newline at end of file diff --git a/app/src/main/java/com/decathlon/android/apptest/common/network/ConnectivityInterceptor.kt b/app/src/main/java/com/decathlon/android/apptest/common/network/ConnectivityInterceptor.kt new file mode 100644 index 0000000..bdc855a --- /dev/null +++ b/app/src/main/java/com/decathlon/android/apptest/common/network/ConnectivityInterceptor.kt @@ -0,0 +1,19 @@ +package com.decathlon.android.apptest.common.network + +import android.content.Context +import com.decathlon.android.apptest.common.exception.NoConnectivityException +import okhttp3.Interceptor +import okhttp3.Response + +class ConnectivityInterceptor(private val context: Context) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + if (!NetworkUtil.isOnline(context)) { + throw NoConnectivityException() + } + + val builder = chain.request().newBuilder() + return chain.proceed(builder.build()) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/decathlon/android/apptest/common/network/NetworkConstants.kt b/app/src/main/java/com/decathlon/android/apptest/common/network/NetworkConstants.kt new file mode 100644 index 0000000..82e5f8a --- /dev/null +++ b/app/src/main/java/com/decathlon/android/apptest/common/network/NetworkConstants.kt @@ -0,0 +1,15 @@ +package com.decathlon.android.apptest.common.network + +object NetworkConstants { + + const val NETWORK_TIMEOUT_IN_SECONDS: Long = 50 + const val BASE_URL = "https://api.github.com" + const val ACCEPT_HEADER = "Accept" + const val REQUEST_GITHUB_V3_API = "application/vnd.github.mercy-preview+json" + const val PARAM_PAGE_NUMBER_KEY = "page" + const val PARAM_PAGE_NUMBER_VALUE = "1" + const val PARAM_PAGE_RESULT_KEY = "per_page" + const val PARAM_PAGE_RESULT_VALUE = "10" +} + + diff --git a/app/src/main/java/com/decathlon/android/apptest/common/network/NetworkUtil.kt b/app/src/main/java/com/decathlon/android/apptest/common/network/NetworkUtil.kt new file mode 100644 index 0000000..7605cfe --- /dev/null +++ b/app/src/main/java/com/decathlon/android/apptest/common/network/NetworkUtil.kt @@ -0,0 +1,17 @@ +package com.decathlon.android.apptest.common.network + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkInfo + +class NetworkUtil { + companion object { + fun isOnline(context: Context): Boolean { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) + return if (connectivityManager is ConnectivityManager) { + val networkInfo: NetworkInfo? = connectivityManager.activeNetworkInfo + networkInfo?.isConnected ?: false + } else false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/decathlon/android/apptest/common/usecase/SearchGitHubRepositories.kt b/app/src/main/java/com/decathlon/android/apptest/common/usecase/SearchGitHubRepositories.kt new file mode 100644 index 0000000..732e8ee --- /dev/null +++ b/app/src/main/java/com/decathlon/android/apptest/common/usecase/SearchGitHubRepositories.kt @@ -0,0 +1,13 @@ +package com.decathlon.android.apptest.common.usecase + +import com.decathlon.android.apptest.common.base.SingleUseCase +import com.decathlon.android.apptest.data.entity.search.SearchResult +import com.decathlon.android.apptest.data.repository.search.SearchRepository +import io.reactivex.Single + +class SearchGitHubRepositories(private val searchRepository: SearchRepository) : SingleUseCase() { + override fun buildUseCaseObservable(params: String?): Single { + return params?.let { searchRepository.searchOnGithub(it) } as Single + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/decathlon/android/apptest/data/ServiceGenerator.kt b/app/src/main/java/com/decathlon/android/apptest/data/ServiceGenerator.kt new file mode 100644 index 0000000..0f4c8e7 --- /dev/null +++ b/app/src/main/java/com/decathlon/android/apptest/data/ServiceGenerator.kt @@ -0,0 +1,55 @@ +package com.decathlon.android.apptest.data + +import android.content.Context +import com.decathlon.android.apptest.BuildConfig +import com.decathlon.android.apptest.common.network.ConnectivityInterceptor +import com.decathlon.android.apptest.common.network.NetworkConstants +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import java.util.concurrent.TimeUnit + +object ServiceGenerator { + private val httpLoggingInterceptor: HttpLoggingInterceptor + private val moshi: Moshi + + init { + moshi = Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build() + + httpLoggingInterceptor = HttpLoggingInterceptor() + httpLoggingInterceptor.level = + if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE + } + + fun createService(serviceClass: Class, context: Context): S { + val okHttpClient = OkHttpClient() + .newBuilder() + .addInterceptor { + val originalRequest = it.request() + val builder = originalRequest + .newBuilder() + .addHeader(NetworkConstants.ACCEPT_HEADER, NetworkConstants.REQUEST_GITHUB_V3_API) + val newRequest = builder.build() + it.proceed(newRequest) + } + .addInterceptor(httpLoggingInterceptor) + .addInterceptor(ConnectivityInterceptor(context)) + .connectTimeout(NetworkConstants.NETWORK_TIMEOUT_IN_SECONDS, TimeUnit.SECONDS) + .writeTimeout(NetworkConstants.NETWORK_TIMEOUT_IN_SECONDS, TimeUnit.SECONDS) + .readTimeout(NetworkConstants.NETWORK_TIMEOUT_IN_SECONDS, TimeUnit.SECONDS) + .build() + + val retrofit = Retrofit.Builder() + .baseUrl(NetworkConstants.BASE_URL) + .client(okHttpClient) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + + return retrofit.create(serviceClass) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/decathlon/android/apptest/data/entity/search/License.kt b/app/src/main/java/com/decathlon/android/apptest/data/entity/search/License.kt new file mode 100644 index 0000000..69f24dc --- /dev/null +++ b/app/src/main/java/com/decathlon/android/apptest/data/entity/search/License.kt @@ -0,0 +1,21 @@ +package com.decathlon.android.apptest.data.entity.search + +import com.squareup.moshi.Json + +data class License( + + @Json(name = "name") + val name: String? = null, + + @Json(name = "spdx_id") + val spdxId: String? = null, + + @Json(name = "key") + val key: String? = null, + + @Json(name = "url") + val url: String? = null, + + @Json(name = "node_id") + val nodeId: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/decathlon/android/apptest/data/entity/search/Owner.kt b/app/src/main/java/com/decathlon/android/apptest/data/entity/search/Owner.kt new file mode 100644 index 0000000..11bb683 --- /dev/null +++ b/app/src/main/java/com/decathlon/android/apptest/data/entity/search/Owner.kt @@ -0,0 +1,60 @@ +package com.decathlon.android.apptest.data.entity.search + +import com.squareup.moshi.Json + +data class Owner( + + @Json(name = "gists_url") + val gistsUrl: String? = null, + + @Json(name = "repos_url") + val reposUrl: String? = null, + + @Json(name = "following_url") + val followingUrl: String? = null, + + @Json(name = "starred_url") + val starredUrl: String? = null, + + @Json(name = "login") + val login: String? = null, + + @Json(name = "followers_url") + val followersUrl: String? = null, + + @Json(name = "type") + val type: String? = null, + + @Json(name = "url") + val url: String? = null, + + @Json(name = "subscriptions_url") + val subscriptionsUrl: String? = null, + + @Json(name = "received_events_url") + val receivedEventsUrl: String? = null, + + @Json(name = "avatar_url") + val avatarUrl: String? = null, + + @Json(name = "events_url") + val eventsUrl: String? = null, + + @Json(name = "html_url") + val htmlUrl: String? = null, + + @Json(name = "site_admin") + val siteAdmin: Boolean? = null, + + @Json(name = "id") + val id: Int? = null, + + @Json(name = "gravatar_id") + val gravatarId: String? = null, + + @Json(name = "node_id") + val nodeId: String? = null, + + @Json(name = "organizations_url") + val organizationsUrl: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/decathlon/android/apptest/data/entity/search/Repository.kt b/app/src/main/java/com/decathlon/android/apptest/data/entity/search/Repository.kt new file mode 100644 index 0000000..dd84994 --- /dev/null +++ b/app/src/main/java/com/decathlon/android/apptest/data/entity/search/Repository.kt @@ -0,0 +1,231 @@ +package com.decathlon.android.apptest.data.entity.search + +import com.squareup.moshi.Json + +data class Repository( + + @Json(name = "stargazers_count") + val stargazersCount: Int? = null, + + @Json(name = "pushed_at") + val pushedAt: String? = null, + + @Json(name = "subscription_url") + val subscriptionUrl: String? = null, + + @Json(name = "language") + val language: String? = null, + + @Json(name = "branches_url") + val branchesUrl: String? = null, + + @Json(name = "issue_comment_url") + val issueCommentUrl: String? = null, + + @Json(name = "labels_url") + val labelsUrl: String? = null, + + @Json(name = "score") + val score: Double? = null, + + @Json(name = "subscribers_url") + val subscribersUrl: String? = null, + + @Json(name = "releases_url") + val releasesUrl: String? = null, + + @Json(name = "svn_url") + val svnUrl: String? = null, + + @Json(name = "id") + val id: Int? = null, + + @Json(name = "forks") + val forks: Int? = null, + + @Json(name = "archive_url") + val archiveUrl: String? = null, + + @Json(name = "git_refs_url") + val gitRefsUrl: String? = null, + + @Json(name = "forks_url") + val forksUrl: String? = null, + + @Json(name = "statuses_url") + val statusesUrl: String? = null, + + @Json(name = "ssh_url") + val sshUrl: String? = null, + + @Json(name = "license") + val license: License? = null, + + @Json(name = "full_name") + val fullName: String? = null, + + @Json(name = "size") + val size: Int? = null, + + @Json(name = "languages_url") + val languagesUrl: String? = null, + + @Json(name = "html_url") + val htmlUrl: String? = null, + + @Json(name = "collaborators_url") + val collaboratorsUrl: String? = null, + + @Json(name = "clone_url") + val cloneUrl: String? = null, + + @Json(name = "name") + val name: String? = null, + + @Json(name = "pulls_url") + val pullsUrl: String? = null, + + @Json(name = "default_branch") + val defaultBranch: String? = null, + + @Json(name = "hooks_url") + val hooksUrl: String? = null, + + @Json(name = "trees_url") + val treesUrl: String? = null, + + @Json(name = "tags_url") + val tagsUrl: String? = null, + + @Json(name = "private") + val jsonMemberPrivate: Boolean? = null, + + @Json(name = "contributors_url") + val contributorsUrl: String? = null, + + @Json(name = "has_downloads") + val hasDownloads: Boolean? = null, + + @Json(name = "notifications_url") + val notificationsUrl: String? = null, + + @Json(name = "open_issues_count") + val openIssuesCount: Int? = null, + + @Json(name = "description") + val description: String? = null, + + @Json(name = "created_at") + val createdAt: String? = null, + + @Json(name = "watchers") + val watchers: Int? = null, + + @Json(name = "keys_url") + val keysUrl: String? = null, + + @Json(name = "deployments_url") + val deploymentsUrl: String? = null, + + @Json(name = "has_projects") + val hasProjects: Boolean? = null, + + @Json(name = "archived") + val archived: Boolean? = null, + + @Json(name = "has_wiki") + val hasWiki: Boolean? = null, + + @Json(name = "updated_at") + val updatedAt: String? = null, + + @Json(name = "comments_url") + val commentsUrl: String? = null, + + @Json(name = "stargazers_url") + val stargazersUrl: String? = null, + + @Json(name = "disabled") + val disabled: Boolean? = null, + + @Json(name = "git_url") + val gitUrl: String? = null, + + @Json(name = "has_pages") + val hasPages: Boolean? = null, + + @Json(name = "owner") + val owner: Owner? = null, + + @Json(name = "commits_url") + val commitsUrl: String? = null, + + @Json(name = "compare_url") + val compareUrl: String? = null, + + @Json(name = "git_commits_url") + val gitCommitsUrl: String? = null, + + @Json(name = "topics") + val topics: List? = null, + + @Json(name = "blobs_url") + val blobsUrl: String? = null, + + @Json(name = "git_tags_url") + val gitTagsUrl: String? = null, + + @Json(name = "merges_url") + val mergesUrl: String? = null, + + @Json(name = "downloads_url") + val downloadsUrl: String? = null, + + @Json(name = "has_issues") + val hasIssues: Boolean? = null, + + @Json(name = "url") + val url: String? = null, + + @Json(name = "contents_url") + val contentsUrl: String? = null, + + @Json(name = "mirror_url") + val mirrorUrl: Any? = null, + + @Json(name = "milestones_url") + val milestonesUrl: String? = null, + + @Json(name = "teams_url") + val teamsUrl: String? = null, + + @Json(name = "fork") + val fork: Boolean? = null, + + @Json(name = "issues_url") + val issuesUrl: String? = null, + + @Json(name = "events_url") + val eventsUrl: String? = null, + + @Json(name = "issue_events_url") + val issueEventsUrl: String? = null, + + @Json(name = "assignees_url") + val assigneesUrl: String? = null, + + @Json(name = "open_issues") + val openIssues: Int? = null, + + @Json(name = "watchers_count") + val watchersCount: Int? = null, + + @Json(name = "node_id") + val nodeId: String? = null, + + @Json(name = "homepage") + val homepage: String? = null, + + @Json(name = "forks_count") + val forksCount: Int? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/decathlon/android/apptest/data/entity/search/SearchResult.kt b/app/src/main/java/com/decathlon/android/apptest/data/entity/search/SearchResult.kt new file mode 100644 index 0000000..26f2d20 --- /dev/null +++ b/app/src/main/java/com/decathlon/android/apptest/data/entity/search/SearchResult.kt @@ -0,0 +1,15 @@ +package com.decathlon.android.apptest.data.entity.search + +import com.squareup.moshi.Json + +data class SearchResult( + + @Json(name = "total_count") + val totalCount: Int? = null, + + @Json(name = "incomplete_results") + val incompleteResults: Boolean? = null, + + @Json(name = "items") + val items: List? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/decathlon/android/apptest/data/repository/search/RemoteSearchRepository.kt b/app/src/main/java/com/decathlon/android/apptest/data/repository/search/RemoteSearchRepository.kt new file mode 100644 index 0000000..d798c77 --- /dev/null +++ b/app/src/main/java/com/decathlon/android/apptest/data/repository/search/RemoteSearchRepository.kt @@ -0,0 +1,51 @@ +package com.decathlon.android.apptest.data.repository.search + +import android.content.Context +import com.decathlon.android.apptest.common.exception.EmptyBodyException +import com.decathlon.android.apptest.common.exception.ForbiddenException +import com.decathlon.android.apptest.common.exception.UnknownErrorException +import com.decathlon.android.apptest.common.network.NetworkConstants +import com.decathlon.android.apptest.data.ServiceGenerator +import com.decathlon.android.apptest.data.entity.search.SearchResult +import io.reactivex.Single +import java.security.InvalidParameterException +import java.util.* + +class RemoteSearchRepository(private val context: Context?) : SearchRepository { + override fun searchOnGithub(query: String): Single { + return Single.create { + + val additionalParams = HashMap() + additionalParams[NetworkConstants.PARAM_PAGE_NUMBER_KEY] = NetworkConstants.PARAM_PAGE_NUMBER_VALUE + additionalParams[NetworkConstants.PARAM_PAGE_RESULT_KEY] = NetworkConstants.PARAM_PAGE_RESULT_VALUE + + if (context == null) { + return@create it.onError(InvalidParameterException()) + } + + val searchService = ServiceGenerator.createService(SearchService::class.java, context) + val searchRepositories = searchService.searchRepositories(query, additionalParams) + + try { + val response = searchRepositories.execute() + when (response.code()) { + in 200..300 -> { + val searchResult = response.body() + if (searchResult != null) { + return@create it.onSuccess(searchResult) + } else { + return@create it.onError(EmptyBodyException()) + } + } + 403 -> { + return@create it.onError(ForbiddenException()) + } + } + it.onError(UnknownErrorException()) + } catch (e: Exception) { + it.onError(e) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/decathlon/android/apptest/data/repository/search/SearchRepository.kt b/app/src/main/java/com/decathlon/android/apptest/data/repository/search/SearchRepository.kt new file mode 100644 index 0000000..4ca92f1 --- /dev/null +++ b/app/src/main/java/com/decathlon/android/apptest/data/repository/search/SearchRepository.kt @@ -0,0 +1,8 @@ +package com.decathlon.android.apptest.data.repository.search + +import com.decathlon.android.apptest.data.entity.search.SearchResult +import io.reactivex.Single + +interface SearchRepository { + fun searchOnGithub(query: String): Single +} \ No newline at end of file diff --git a/app/src/main/java/com/decathlon/android/apptest/data/repository/search/SearchService.kt b/app/src/main/java/com/decathlon/android/apptest/data/repository/search/SearchService.kt new file mode 100644 index 0000000..98347fb --- /dev/null +++ b/app/src/main/java/com/decathlon/android/apptest/data/repository/search/SearchService.kt @@ -0,0 +1,17 @@ +package com.decathlon.android.apptest.data.repository.search + +import com.decathlon.android.apptest.data.entity.search.SearchResult +import retrofit2.Call +import retrofit2.http.GET +import retrofit2.http.Query +import retrofit2.http.QueryMap + +interface SearchService { + @GET("/search/repositories") + fun searchRepositories( + @Query( + "q", + encoded = true + ) query: String, @QueryMap additionalParams: Map + ): Call +} \ No newline at end of file diff --git a/app/src/main/java/com/decathlon/android/apptest/GithubSearchActivity.kt b/app/src/main/java/com/decathlon/android/apptest/githubsearch/GithubSearchActivity.kt similarity index 50% rename from app/src/main/java/com/decathlon/android/apptest/GithubSearchActivity.kt rename to app/src/main/java/com/decathlon/android/apptest/githubsearch/GithubSearchActivity.kt index f0a6525..beee47f 100644 --- a/app/src/main/java/com/decathlon/android/apptest/GithubSearchActivity.kt +++ b/app/src/main/java/com/decathlon/android/apptest/githubsearch/GithubSearchActivity.kt @@ -1,12 +1,18 @@ -package com.decathlon.android.apptest +package com.decathlon.android.apptest.githubsearch -import androidx.appcompat.app.AppCompatActivity import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.decathlon.android.apptest.R class GithubSearchActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_search) + + supportFragmentManager + .beginTransaction() + .add(R.id.framelayout_githubsearch_fragmentcontainer, GithubSearchFragment.newInstance()) + .commit() } } diff --git a/app/src/main/java/com/decathlon/android/apptest/githubsearch/GithubSearchAdapter.kt b/app/src/main/java/com/decathlon/android/apptest/githubsearch/GithubSearchAdapter.kt new file mode 100644 index 0000000..6af90fe --- /dev/null +++ b/app/src/main/java/com/decathlon/android/apptest/githubsearch/GithubSearchAdapter.kt @@ -0,0 +1,45 @@ +package com.decathlon.android.apptest.githubsearch + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.decathlon.android.apptest.R +import com.decathlon.android.apptest.data.entity.search.Repository +import kotlinx.android.synthetic.main.item_search_repository.view.* + +class GithubSearchAdapter : RecyclerView.Adapter() { + + var references: List? = emptyList() + set(value) { + field = value + this.notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RepositoryViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_search_repository, parent, false) + return RepositoryViewHolder(view) + } + + override fun getItemCount(): Int { + return references?.size as Int + } + + override fun onBindViewHolder(holder: RepositoryViewHolder, position: Int) { + holder.bind(references?.get(position)) + } + + class RepositoryViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + fun bind(reference: Repository?) = with(itemView) { + textview_itemsearch_title.text = reference?.fullName + textview_itemsearch_desc.text = reference?.description + textview_itemsearch_stars.text = reference?.stargazersCount.toString() + + Glide + .with(itemView.context) + .load(reference?.owner?.avatarUrl) + .into(imageview_itemsearch_author) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/decathlon/android/apptest/githubsearch/GithubSearchContract.kt b/app/src/main/java/com/decathlon/android/apptest/githubsearch/GithubSearchContract.kt new file mode 100644 index 0000000..b93b37b --- /dev/null +++ b/app/src/main/java/com/decathlon/android/apptest/githubsearch/GithubSearchContract.kt @@ -0,0 +1,40 @@ +package com.decathlon.android.apptest.githubsearch + +import com.decathlon.android.apptest.common.base.BasePresenter +import com.decathlon.android.apptest.common.base.BaseView +import com.decathlon.android.apptest.data.entity.search.Repository + +interface GithubSearchContract { + interface View : BaseView { + + fun showLoadingView() + + fun hideLoadingView() + + fun displaySearchResult(list: List) + + fun showEmptyViewMessage() + + fun showEmptyInputError() + + fun clearInputError() + + fun showConnectivityIssueMessage() + + fun showTimeoutIssueMessage() + + fun showApiRateLimitMessage() + + fun showUnknownErrorMessage() + + fun closeKeyboard() + } + + interface Presenter : BasePresenter { + fun searchRepositoriesOnGitHub(query: String, system: System) + } + + companion object { + enum class System { ANDROID, IOS } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/decathlon/android/apptest/githubsearch/GithubSearchFragment.kt b/app/src/main/java/com/decathlon/android/apptest/githubsearch/GithubSearchFragment.kt new file mode 100644 index 0000000..a9fac74 --- /dev/null +++ b/app/src/main/java/com/decathlon/android/apptest/githubsearch/GithubSearchFragment.kt @@ -0,0 +1,185 @@ +package com.decathlon.android.apptest.githubsearch + + +import android.app.AlertDialog +import android.content.Context.INPUT_METHOD_SERVICE +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import com.decathlon.android.apptest.R +import com.decathlon.android.apptest.common.usecase.SearchGitHubRepositories +import com.decathlon.android.apptest.data.entity.search.Repository +import com.decathlon.android.apptest.data.repository.search.RemoteSearchRepository +import com.jakewharton.rxbinding3.widget.editorActionEvents +import com.jakewharton.rxbinding3.widget.textChangeEvents +import com.jakewharton.rxbinding3.widget.textChanges +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import kotlinx.android.synthetic.main.fragment_github_search.* +import java.util.concurrent.TimeUnit + + +class GithubSearchFragment() : Fragment(), GithubSearchContract.View { + override lateinit var presenter: GithubSearchContract.Presenter + private lateinit var textViewObserver: Disposable + private lateinit var githubSearchAdapter: GithubSearchAdapter + private lateinit var choosenOs: GithubSearchContract.Companion.System + private var firstLaunch = true + + companion object { + fun newInstance(): GithubSearchFragment { + return GithubSearchFragment() + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + // Inflate the layout for this fragment + return inflater.inflate(R.layout.fragment_github_search, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + init() + + radiogroup_search.setOnCheckedChangeListener { group, checkedId -> + choosenOs = when (checkedId) { + R.id.radiobutton_search_android -> GithubSearchContract.Companion.System.ANDROID + R.id.radiobutton_search_ios -> GithubSearchContract.Companion.System.IOS + else -> GithubSearchContract.Companion.System.ANDROID + } + presenter.searchRepositoriesOnGitHub(edittext_search.text.toString(), choosenOs) + } + + recyclerview_search.apply { + setHasFixedSize(true) + layoutManager = LinearLayoutManager(activity) + adapter = githubSearchAdapter + addItemDecoration(DividerItemDecoration(this@GithubSearchFragment.context, DividerItemDecoration.VERTICAL)) + } + + edittext_search + .setOnEditorActionListener { editText, actionId, event -> + if(actionId == EditorInfo.IME_ACTION_SEARCH) { + presenter.searchRepositoriesOnGitHub(editText.text.toString(), choosenOs) + return@setOnEditorActionListener true + } + return@setOnEditorActionListener false + } + + textViewObserver = edittext_search + .textChanges() + .debounce(1, TimeUnit.SECONDS) + .subscribeOn(AndroidSchedulers.mainThread()) + .subscribe() { + if (firstLaunch) { + firstLaunch = false + } else { + presenter.searchRepositoriesOnGitHub(it.toString(), choosenOs) + } + } + } + + override fun onDestroy() { + super.onDestroy() + textViewObserver.dispose() + presenter.onDestroy() + } + + override fun showLoadingView() { + activity?.runOnUiThread { + loading_search.visibility = View.VISIBLE + recyclerview_search.visibility = View.INVISIBLE + textview_search_emptyresult.visibility = View.GONE + edittext_search.isEnabled = false + } + } + + override fun hideLoadingView() { + activity?.runOnUiThread { + loading_search.visibility = View.GONE + recyclerview_search.visibility = View.VISIBLE + edittext_search.isEnabled = true + } + } + + override fun showEmptyViewMessage() { + activity?.runOnUiThread { + textview_search_emptyresult.visibility = View.VISIBLE + recyclerview_search.visibility = View.INVISIBLE + } + } + + override fun showEmptyInputError() { + activity?.runOnUiThread { + textinputlayout_search.error = getString(R.string.common_emptyinput_error) + } + } + + override fun clearInputError() { + activity?.runOnUiThread { + textinputlayout_search.isErrorEnabled = false + } + } + + override fun showConnectivityIssueMessage() { + showAlertView(getString(R.string.common_connectivityissue)) + } + + override fun showTimeoutIssueMessage() { + showAlertView(getString(R.string.common_timeoutissue)) + } + + override fun showApiRateLimitMessage() { + showAlertView(getString(R.string.common_apirateissue)) + } + + override fun showUnknownErrorMessage() { + showAlertView(getString(R.string.common_unknownissue)) + } + + override fun closeKeyboard() { + activity?.runOnUiThread { + val inputManager = activity?.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + inputManager.hideSoftInputFromWindow( + activity?.currentFocus?.windowToken, + InputMethodManager.HIDE_NOT_ALWAYS + ) + } + + } + + override fun displaySearchResult(list: List) { + if (list.isEmpty()) { + showEmptyViewMessage() + } else { + this.githubSearchAdapter.references = list + } + } + + private fun init() { + val searchRepository = RemoteSearchRepository(context) + val searchGitHubRepositories = SearchGitHubRepositories(searchRepository) + choosenOs = GithubSearchContract.Companion.System.ANDROID + + presenter = GithubSearchPresenter( + this, + searchGitHubRepositories + ) + githubSearchAdapter = GithubSearchAdapter() + } + + private fun showAlertView(message: String) { + val builder = AlertDialog.Builder(context) + builder.setTitle(getString(R.string.common_error)) + builder.setMessage(message) + builder.setPositiveButton(android.R.string.ok) { _, _ -> } + builder.show() + } +} diff --git a/app/src/main/java/com/decathlon/android/apptest/githubsearch/GithubSearchPresenter.kt b/app/src/main/java/com/decathlon/android/apptest/githubsearch/GithubSearchPresenter.kt new file mode 100644 index 0000000..ba01666 --- /dev/null +++ b/app/src/main/java/com/decathlon/android/apptest/githubsearch/GithubSearchPresenter.kt @@ -0,0 +1,70 @@ +package com.decathlon.android.apptest.githubsearch + +import com.decathlon.android.apptest.common.exception.EmptyBodyException +import com.decathlon.android.apptest.common.exception.ForbiddenException +import com.decathlon.android.apptest.common.exception.NoConnectivityException +import com.decathlon.android.apptest.common.usecase.SearchGitHubRepositories +import com.decathlon.android.apptest.data.entity.search.Repository +import com.decathlon.android.apptest.data.entity.search.SearchResult +import io.reactivex.observers.DisposableSingleObserver +import java.net.SocketTimeoutException + +class GithubSearchPresenter( + private val gitHubSearchView: GithubSearchContract.View, + private val searchGitHubRepositories: SearchGitHubRepositories +) : GithubSearchContract.Presenter { + + var isASearchInProgress : Boolean = false + + init { + gitHubSearchView.presenter = this + } + + override fun searchRepositoriesOnGitHub(query: String, system: GithubSearchContract.Companion.System) { + if (query.isNotEmpty()) { + if(!isASearchInProgress) { + gitHubSearchView.showLoadingView() + gitHubSearchView.clearInputError() + gitHubSearchView.closeKeyboard() + val queryComplement = when (system) { + GithubSearchContract.Companion.System.ANDROID -> ANDROID_APPEND_QUERY + GithubSearchContract.Companion.System.IOS -> IOS_APPEND_QUERY + } + searchGitHubRepositories.execute(SearchGitHubRepositoriesObserver(), "$query$queryComplement") + isASearchInProgress = true + } + } else { + gitHubSearchView.showEmptyInputError() + } + } + + override fun onDestroy() { + this.searchGitHubRepositories.dispose() + } + + private inner class SearchGitHubRepositoriesObserver : DisposableSingleObserver() { + override fun onSuccess(results: SearchResult) { + gitHubSearchView.hideLoadingView() + isASearchInProgress = false + results.items?.filterIsInstance()?.let { gitHubSearchView.displaySearchResult(it) } + } + + override fun onError(e: Throwable) { + gitHubSearchView.hideLoadingView() + isASearchInProgress = false + when (e) { + is NoConnectivityException -> gitHubSearchView.showConnectivityIssueMessage() + is SocketTimeoutException -> gitHubSearchView.showTimeoutIssueMessage() + is EmptyBodyException -> gitHubSearchView.showEmptyViewMessage() + is ForbiddenException -> gitHubSearchView.showApiRateLimitMessage() + else -> gitHubSearchView.showUnknownErrorMessage() + } + } + + } + + companion object { + private const val ANDROID_APPEND_QUERY = "+in:nametopic:android+language:java+language:kotlin" + private const val IOS_APPEND_QUERY = "+in:nametopic:ios+language:objectivec+language:swift" + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index a0ad202..c206d43 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -5,70 +5,167 @@ android:width="108dp" android:viewportHeight="108" android:viewportWidth="108"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_search.xml b/app/src/main/res/layout/activity_search.xml index 8bec371..0fcfdb5 100644 --- a/app/src/main/res/layout/activity_search.xml +++ b/app/src/main/res/layout/activity_search.xml @@ -5,12 +5,12 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context=".GithubSearchActivity"> + tools:context=".githubsearch.GithubSearchActivity"> - + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_search_repository.xml b/app/src/main/res/layout/item_search_repository.xml new file mode 100644 index 0000000..c65068d --- /dev/null +++ b/app/src/main/res/layout/item_search_repository.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7f483a3..6da5419 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,15 @@ D4AppTest + Entrer le nom d\'un repository + Image du propriétaire du repository + Nombre d\'étoiles du repository + Android + iOS + Aucun résultat ne correspond à votre recherche + Erreur + Aucun texte renseigné + La recherche n\'a pas pu aboutir car vous n\'êtes pas connecté à internet + La recherche n\'a pas pu aboutir car le délai d\'attente est dépassé + La recherche n\'a pas pu aboutir car trop d\'appels ont été effectués ou la requête a été mal formulée + La recherche n\'a pas pu aboutir car une erreur inconnue est survenue diff --git a/app/src/test/java/com/decathlon/android/apptest/ExampleUnitTest.kt b/app/src/test/java/com/decathlon/android/apptest/ExampleUnitTest.kt index 6564bda..a1492cc 100644 --- a/app/src/test/java/com/decathlon/android/apptest/ExampleUnitTest.kt +++ b/app/src/test/java/com/decathlon/android/apptest/ExampleUnitTest.kt @@ -1,9 +1,8 @@ package com.decathlon.android.apptest +import org.junit.Assert.assertEquals import org.junit.Test -import org.junit.Assert.* - /** * Example local unit test, which will execute on the development machine (host). * diff --git a/build.gradle b/build.gradle index af2e406..b222805 100644 --- a/build.gradle +++ b/build.gradle @@ -1,15 +1,16 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = '1.3.21' + ext.kotlinVersion = '1.3.31' + repositories { google() jcenter() - + } dependencies { classpath 'com.android.tools.build:gradle:3.4.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } @@ -19,10 +20,35 @@ allprojects { repositories { google() jcenter() - + } } task clean(type: Delete) { delete rootProject.buildDir } + +ext { + applicationId = "com.decathlon.android.apptest" + minSdkVersion = 21 + targetSdkVersion = 28 + compileSdkVersion = 28 + appVersionCode = 1 + appVersionName = "1.O" + appCompatVersion = "1.0.2" + coreKtxVersion = "1.0.2" + constraintLayoutVersion = "1.1.3" + junitVersion = "4.12" + androidTestRunnerVersion = "1.1.1" + espressoVersion = "3.1.1" + androidxLegacySupportV4Version = "1.0.0" + retrofitVersion = "2.5.0" + moshiVersion = "1.8.0" + okHttpVersion = "3.14.1" + rxJavaVersion = "2.2.8" + rxAndroidVersion = "2.1.1" + recyclerViewVersion = "1.0.0" + rxBindingsVersion = "3.0.0-alpha2" + glideVersion = "4.9.0" + materialVersion = "1.0.0" +} \ No newline at end of file