diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..66a629e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,40 @@
+# Built application files
+*.apk
+*.ap_
+
+# Files for the ART/Dalvik VM
+*.dex
+
+# Java class files
+*.class
+
+# Generated files
+bin/
+gen/
+out/
+
+# Gradle files
+.gradle/
+build/
+
+# Local configuration file (sdk path, etc)
+local.properties
+
+# Proguard folder generated by Eclipse
+proguard/
+
+# Log Files
+*.log
+
+# Android Studio Navigation editor temp files
+.navigation/
+
+# Android Studio captures folder
+captures/
+
+# Intellij
+*.iml
+.idea
+
+# External native build folder generated in Android Studio 2.2 and later
+.externalNativeBuild
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..223efa1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,29 @@
+## Tech stack & News App libraries
+
+- Navigation component - navigation graph for navigating and replacing screens/fragments
+- ViewBinding - allows to more easily write code that interacts with views and replaces ```findViewById```.
+- ViewModel - UI related data holder, lifecycle aware.
+- LiveData - Build data objects that notify views when the underlying database changes.
+- Dagger-Hilt for dependency injection. Object creation and scoping is handled by Hilt.
+- Kotlin Coroutines + Flow - for managing background threads with simplified code and reducing needs for callbacks
+- Retrofit2 & OkHttp3 - to make REST requests to the web service integrated.
+- Room for database
+- Recyclerview animation
+- Coil - for image loading
+
+## Architecture:
+
+- MVVM Architecture
+- Repository pattern
+- Applying SOLID principles, each class has a single job with separation of concerns by making classes independent
+ of each other and communicating with interfaces.
+
+## Features
+
++ Get breaking news
++ Save news to local
++ Search for specific news
+
+## License
+
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..796b96d
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/app/build.gradle b/app/build.gradle
new file mode 100644
index 0000000..c57dda0
--- /dev/null
+++ b/app/build.gradle
@@ -0,0 +1,87 @@
+plugins {
+ id 'com.android.application'
+ id 'kotlin-android'
+ id 'kotlin-kapt'
+ id 'androidx.navigation.safeargs'
+ id 'dagger.hilt.android.plugin'
+}
+
+android {
+ compileSdk compile_sdk_version
+
+ defaultConfig {
+ applicationId "com.mina_mikhail.newsapp"
+ minSdk min_sdk_version
+ targetSdk compile_sdk_version
+ versionCode version_code
+ versionName version_name
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ debug {
+ buildConfigField("String", "API_BASE_URL", "\"https://newsapi.org/v2/\"")
+ buildConfigField("String", "API_KEY", "\"5c203f74fdcc4265bca981fd059fee2c\"")
+ }
+
+ release {
+ minifyEnabled true
+ shrinkResources true
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+
+ buildConfigField("String", "API_BASE_URL", "\"https://newsapi.org/v2/\"")
+ buildConfigField("String", "API_KEY", "\"5c203f74fdcc4265bca981fd059fee2c\"")
+ }
+ }
+
+ buildFeatures {
+ viewBinding true
+ }
+}
+
+dependencies {
+ implementation fileTree(dir: 'libs', include: ['*.jar'])
+
+ // Support
+ implementation "androidx.appcompat:appcompat:$appcompat"
+ implementation "androidx.core:core-ktx:$core_ktx"
+ implementation "androidx.legacy:legacy-support-v4:$support_version"
+
+ // Arch Components
+ implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
+ implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
+ implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
+ implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycle_version"
+
+ // Kotlin Coroutines
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines"
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines"
+
+ // Room
+ implementation "androidx.room:room-runtime:$room_version"
+ kapt "androidx.room:room-compiler:$room_version"
+ implementation "androidx.room:room-ktx:$room_version"
+
+ // Networking
+ implementation "com.squareup.retrofit2:retrofit:$retrofit"
+ implementation "com.squareup.retrofit2:converter-gson:$retrofit"
+ implementation "com.google.code.gson:gson:$gson"
+ implementation "com.squareup.okhttp3:logging-interceptor:$interceptor"
+ implementation "com.mocklets:pluto:$pluto"
+
+ // UI
+ implementation "com.google.android.material:material:$material_design"
+ implementation "androidx.navigation:navigation-fragment-ktx:$android_navigation"
+ implementation "androidx.navigation:navigation-ui-ktx:$android_navigation"
+ implementation "com.github.ybq:Android-SpinKit:$loading_animations"
+ implementation "com.tapadoo.android:alerter:$alerter"
+ implementation "io.coil-kt:coil:$coil"
+
+ // Utils
+ implementation "androidx.datastore:datastore-preferences:$datastore_preferences"
+
+ // Hilt
+ implementation "com.google.dagger:hilt-android:$hilt_version"
+ kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
+}
\ No newline at end of file
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# 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
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..80af570
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png
new file mode 100644
index 0000000..09c6ade
Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/core/MyApplication.kt b/app/src/main/java/com/mina_mikhail/newsapp/core/MyApplication.kt
new file mode 100644
index 0000000..01bb829
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/core/MyApplication.kt
@@ -0,0 +1,40 @@
+package com.mina_mikhail.newsapp.core
+
+import android.app.Application
+import com.mina_mikhail.newsapp.BuildConfig
+import com.mocklets.pluto.Pluto
+import com.mocklets.pluto.PlutoLog
+import com.mocklets.pluto.modules.exceptions.ANRException
+import com.mocklets.pluto.modules.exceptions.ANRListener
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class MyApplication : Application() {
+
+ override
+ fun onCreate() {
+ super.onCreate()
+
+ initPlutoNetworkInspection()
+
+ initPlutoANRInspection()
+ }
+
+ private fun initPlutoNetworkInspection() {
+ if (BuildConfig.DEBUG) {
+ Pluto.initialize(this)
+ }
+ }
+
+ private fun initPlutoANRInspection() {
+ if (BuildConfig.DEBUG) {
+ Pluto.setANRListener(object : ANRListener {
+ override
+ fun onAppNotResponding(exception: ANRException) {
+ exception.printStackTrace()
+ PlutoLog.e("ANR", exception.threadStateMap)
+ }
+ })
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/core/data_source/BaseRemoteDataSource.kt b/app/src/main/java/com/mina_mikhail/newsapp/core/data_source/BaseRemoteDataSource.kt
new file mode 100644
index 0000000..5f30154
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/core/data_source/BaseRemoteDataSource.kt
@@ -0,0 +1,58 @@
+package com.mina_mikhail.newsapp.core.data_source
+
+import com.mina_mikhail.newsapp.core.network.FailureStatus.API_FAIL
+import com.mina_mikhail.newsapp.core.network.FailureStatus.NO_INTERNET
+import com.mina_mikhail.newsapp.core.network.FailureStatus.OTHER
+import com.mina_mikhail.newsapp.core.network.FailureStatus.SERVER_SIDE_EXCEPTION
+import com.mina_mikhail.newsapp.core.network.FailureStatus.TOKEN_EXPIRED
+import com.mina_mikhail.newsapp.core.network.Resource
+import com.mina_mikhail.newsapp.core.network.ResponseStatus
+import com.mina_mikhail.newsapp.features.news.domain.entity.response.NewsResponse
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import retrofit2.HttpException
+import java.net.UnknownHostException
+import javax.inject.Inject
+
+open class BaseRemoteDataSource @Inject constructor() {
+
+ companion object {
+ const val LIST_PAGE_SIZE = 10
+ }
+
+ suspend fun safeApiCall(apiCall: suspend () -> T): Resource {
+ return withContext(Dispatchers.IO) {
+ try {
+ val apiResponse: T = apiCall.invoke()
+
+ if ((apiResponse as NewsResponse).status == ResponseStatus.SUCCESS) {
+ if ((apiResponse as NewsResponse).totalResults == 0) {
+ Resource.Empty
+ } else {
+ Resource.Success(apiResponse)
+ }
+ } else {
+ Resource.Failure(API_FAIL, 200, "Error from api")
+ }
+ } catch (throwable: Throwable) {
+ when (throwable) {
+ is HttpException -> {
+ if (throwable.code() == 401) {
+ Resource.Failure(TOKEN_EXPIRED, throwable.code(), throwable.response()?.errorBody().toString())
+ } else {
+ Resource.Failure(SERVER_SIDE_EXCEPTION, throwable.code(), throwable.response()?.errorBody().toString())
+ }
+ }
+
+ is UnknownHostException -> {
+ Resource.Failure(NO_INTERNET, null, null)
+ }
+
+ else -> {
+ Resource.Failure(OTHER, null, null)
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/core/di/module/NetworkServicesModule.kt b/app/src/main/java/com/mina_mikhail/newsapp/core/di/module/NetworkServicesModule.kt
new file mode 100644
index 0000000..1a5c61d
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/core/di/module/NetworkServicesModule.kt
@@ -0,0 +1,20 @@
+package com.mina_mikhail.newsapp.core.di.module
+
+import com.mina_mikhail.newsapp.features.news.data.data_source.remote.NewsServices
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import retrofit2.Retrofit
+import javax.inject.Singleton
+
+@Module(includes = [RetrofitModule::class])
+@InstallIn(SingletonComponent::class)
+object NetworkServicesModule {
+
+ @Provides
+ @Singleton
+ fun provideNewsServices(retrofit: Retrofit): NewsServices {
+ return retrofit.create(NewsServices::class.java)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/core/di/module/RetrofitModule.kt b/app/src/main/java/com/mina_mikhail/newsapp/core/di/module/RetrofitModule.kt
new file mode 100644
index 0000000..87cfda2
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/core/di/module/RetrofitModule.kt
@@ -0,0 +1,91 @@
+package com.mina_mikhail.newsapp.core.di.module
+
+import com.google.gson.Gson
+import com.google.gson.GsonBuilder
+import com.mina_mikhail.newsapp.BuildConfig
+import com.mocklets.pluto.PlutoInterceptor
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import okhttp3.HttpUrl
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import java.util.concurrent.TimeUnit
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object RetrofitModule {
+
+ private const val REQUEST_TIME_OUT: Long = 60
+
+ @Provides
+ @Singleton
+ fun provideHeadersInterceptor(): Interceptor =
+ Interceptor { chain: Interceptor.Chain ->
+ val original = chain.request()
+ // Request customization: add request query params
+ val originalHttpUrl: HttpUrl = original.url
+ val url = originalHttpUrl.newBuilder()
+ .addQueryParameter("apiKey", BuildConfig.API_KEY)
+ .build()
+ // Request customization: add request headers
+ val requestBuilder = original.newBuilder()
+ .url(url)
+ val request = requestBuilder.build()
+ chain.proceed(request)
+ }
+
+ @Provides
+ @Singleton
+ fun provideHttpLoggingInterceptor(): HttpLoggingInterceptor {
+ val logging = HttpLoggingInterceptor()
+ logging.level = HttpLoggingInterceptor.Level.BODY
+ return logging
+ }
+
+ @Provides
+ @Singleton
+ fun provideOkHttpClient(
+ headersInterceptor: Interceptor,
+ logging: HttpLoggingInterceptor
+ ): OkHttpClient {
+ return if (BuildConfig.DEBUG) {
+ OkHttpClient.Builder()
+ .readTimeout(REQUEST_TIME_OUT, TimeUnit.SECONDS)
+ .connectTimeout(REQUEST_TIME_OUT, TimeUnit.SECONDS)
+ .addNetworkInterceptor(headersInterceptor)
+ .addNetworkInterceptor(logging)
+ .addInterceptor(PlutoInterceptor())
+ .build()
+ } else {
+ OkHttpClient.Builder()
+ .readTimeout(REQUEST_TIME_OUT, TimeUnit.SECONDS)
+ .connectTimeout(REQUEST_TIME_OUT, TimeUnit.SECONDS)
+ .addNetworkInterceptor(headersInterceptor)
+ .build()
+ }
+ }
+
+ @Provides
+ @Singleton
+ fun provideGson(): Gson {
+ return GsonBuilder()
+ .setLenient()
+ .serializeNulls() // To allow sending null values
+ .create()
+ }
+
+ @Provides
+ @Singleton
+ fun provideRetrofit(gson: Gson, okHttpClient: OkHttpClient): Retrofit = Retrofit.Builder()
+ .client(okHttpClient)
+ .addConverterFactory(GsonConverterFactory.create(gson))
+ .baseUrl(BuildConfig.API_BASE_URL)
+ .build()
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/core/di/module/RoomModule.kt b/app/src/main/java/com/mina_mikhail/newsapp/core/di/module/RoomModule.kt
new file mode 100644
index 0000000..cd237fc
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/core/di/module/RoomModule.kt
@@ -0,0 +1,21 @@
+package com.mina_mikhail.newsapp.core.di.module
+
+import android.content.Context
+import androidx.room.Room
+import com.mina_mikhail.newsapp.core.local.MyDatabase
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object RoomModule {
+
+ @Provides
+ @Singleton
+ fun provideDatabase(@ApplicationContext context: Context): MyDatabase =
+ Room.databaseBuilder(context, MyDatabase::class.java, MyDatabase.DATABASE_NAME).build()
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/core/local/MyDatabase.kt b/app/src/main/java/com/mina_mikhail/newsapp/core/local/MyDatabase.kt
new file mode 100644
index 0000000..d3188a5
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/core/local/MyDatabase.kt
@@ -0,0 +1,20 @@
+package com.mina_mikhail.newsapp.core.local
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+import com.mina_mikhail.newsapp.features.news.data.data_source.local.ArticlesDao
+import com.mina_mikhail.newsapp.features.news.domain.Converters
+import com.mina_mikhail.newsapp.features.news.domain.entity.model.Article
+
+@Database(entities = [Article::class], version = MyDatabase.DATABASE_VERSION)
+@TypeConverters(Converters::class)
+abstract class MyDatabase : RoomDatabase() {
+
+ companion object {
+ const val DATABASE_VERSION = 1
+ const val DATABASE_NAME = "NewsDatabase"
+ }
+
+ abstract fun getArticlesDao(): ArticlesDao
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/core/network/FailureStatus.kt b/app/src/main/java/com/mina_mikhail/newsapp/core/network/FailureStatus.kt
new file mode 100644
index 0000000..4651bad
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/core/network/FailureStatus.kt
@@ -0,0 +1,11 @@
+package com.mina_mikhail.newsapp.core.network
+
+enum class FailureStatus {
+
+ API_FAIL,
+ SERVER_SIDE_EXCEPTION,
+ TOKEN_EXPIRED,
+ NO_INTERNET,
+ OTHER
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/core/network/NetworkUtils.kt b/app/src/main/java/com/mina_mikhail/newsapp/core/network/NetworkUtils.kt
new file mode 100644
index 0000000..c31ca89
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/core/network/NetworkUtils.kt
@@ -0,0 +1,36 @@
+package com.mina_mikhail.newsapp.core.network
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.os.Build
+
+fun isNetworkAvailable(context: Context): Boolean {
+ var result = false
+ val connectivityManager =
+ context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ val networkCapabilities = connectivityManager.activeNetwork ?: return false
+ val actNw =
+ connectivityManager.getNetworkCapabilities(networkCapabilities) ?: return false
+ result = when {
+ actNw.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
+ actNw.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
+ actNw.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true
+ else -> false
+ }
+ } else {
+ connectivityManager.run {
+ connectivityManager.activeNetworkInfo?.run {
+ result = when (type) {
+ ConnectivityManager.TYPE_WIFI -> true
+ ConnectivityManager.TYPE_MOBILE -> true
+ ConnectivityManager.TYPE_ETHERNET -> true
+ else -> false
+ }
+
+ }
+ }
+ }
+ return result
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/core/network/Resource.kt b/app/src/main/java/com/mina_mikhail/newsapp/core/network/Resource.kt
new file mode 100644
index 0000000..31511a0
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/core/network/Resource.kt
@@ -0,0 +1,13 @@
+package com.mina_mikhail.newsapp.core.network
+
+sealed class Resource {
+
+ data class Success(val value: T) : Resource()
+
+ data class Failure(val failureStatus: FailureStatus, val code: Int?, val message: String?) : Resource()
+
+ object Empty : Resource()
+
+ object Loading : Resource()
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/core/network/ResponseStatus.kt b/app/src/main/java/com/mina_mikhail/newsapp/core/network/ResponseStatus.kt
new file mode 100644
index 0000000..9a2ea60
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/core/network/ResponseStatus.kt
@@ -0,0 +1,9 @@
+package com.mina_mikhail.newsapp.core.network
+
+class ResponseStatus {
+
+ companion object {
+ public const val SUCCESS = "ok"
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/core/utils/DateUtils.kt b/app/src/main/java/com/mina_mikhail/newsapp/core/utils/DateUtils.kt
new file mode 100644
index 0000000..e798c91
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/core/utils/DateUtils.kt
@@ -0,0 +1,81 @@
+package com.mina_mikhail.newsapp.core.utils
+
+import android.text.format.DateUtils
+import java.text.ParseException
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Date
+import java.util.Locale
+
+class DateUtils {
+ companion object {
+ const val API_DATE_FORMAT = "yyyy-MM-dd"
+ const val UI_DATE_FORMAT = "dd-MM-yyyy"
+ const val FULL_DATE_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"
+ const val MONTH_UI_DATE_FORMAT = "dd MMM yyyy"
+ const val DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss"
+ const val TIME_12_FORMAT = "hh:mm a"
+ const val TIME_24_FORMAT = "HH:mm"
+ const val TIME_24_FORMAT_WITH_SECONDS = "HH:mm:ss"
+ const val TIME_HOUR_ONLY = "HH"
+ const val TIME_MINUTE_ONLY = "mm"
+ const val DATE_TIME_12_FORMAT = "yyyy-MM-dd hh:mm a"
+ const val DATE_TIME_UI_FORMAT = "dd MMM yyyy, hh:mm a"
+ const val DAY_NAME = "EEEE"
+ const val SHORT_DAY_NAME = "E"
+ const val DATE_WITH_DAY_NAME = "EEE, dd MMM yyyy"
+ const val HOUR = "HH"
+ const val MINUTE = "mm"
+ const val DAY = "dd"
+ const val MONTH = "MM"
+ const val YEAR = "yyyy"
+ }
+}
+
+fun String.changeDataFormat(oldFormat: String, newFormat: String): String {
+ val formatView = SimpleDateFormat(oldFormat, getLocale())
+ val newFormatView = SimpleDateFormat(newFormat, getLocale())
+ var dateObj: Date? = null
+ try {
+ dateObj = formatView.parse(this)
+ } catch (e: ParseException) {
+ e.printStackTrace()
+ }
+
+ return if (dateObj == null) "" else newFormatView.format(dateObj)
+}
+
+fun String.convertDateTimeToTimesAgo(format: String): String {
+ val inputFormat = SimpleDateFormat(format, getLocale())
+ val date: Date?
+ return try {
+ date = inputFormat.parse(this)
+
+ // the new date style
+ DateUtils.getRelativeTimeSpanString(
+ date.time,
+ Calendar.getInstance().timeInMillis,
+ DateUtils.MINUTE_IN_MILLIS
+ ) as String
+ } catch (e: Exception) {
+ e.printStackTrace()
+ ""
+ }
+}
+
+fun getLocale(): Locale? {
+ return when (Locale.getDefault().language) {
+ "en" -> {
+ Locale.ENGLISH
+ }
+ "ar" -> {
+ Locale("ar")
+ }
+ "fr" -> {
+ Locale.FRENCH
+ }
+ else -> {
+ Locale.ENGLISH
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/core/utils/EndlessRecyclerViewScrollListener.kt b/app/src/main/java/com/mina_mikhail/newsapp/core/utils/EndlessRecyclerViewScrollListener.kt
new file mode 100644
index 0000000..18546a9
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/core/utils/EndlessRecyclerViewScrollListener.kt
@@ -0,0 +1,84 @@
+package com.mina_mikhail.newsapp.core.utils
+
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.StaggeredGridLayoutManager
+
+abstract class EndlessRecyclerViewScrollListener(private var visibleThreshold: Int = 5) :
+ RecyclerView.OnScrollListener() {
+
+ private var currentPage = 0
+ private var previousTotalItemCount = 0
+ private var loading = true
+ private val startingPageIndex = 0
+
+ private var mLayoutManager: RecyclerView.LayoutManager? = null
+
+ constructor(visibleThreshold: Int, layoutManager: LinearLayoutManager) : this(visibleThreshold) {
+ this.mLayoutManager = layoutManager
+ }
+
+ constructor(visibleThreshold: Int, layoutManager: GridLayoutManager) : this(visibleThreshold) {
+ this.mLayoutManager = layoutManager
+ this.visibleThreshold = visibleThreshold * layoutManager.spanCount
+ }
+
+ constructor(visibleThreshold: Int, layoutManager: StaggeredGridLayoutManager) : this(visibleThreshold) {
+ this.mLayoutManager = layoutManager
+ this.visibleThreshold = visibleThreshold * layoutManager.spanCount
+ }
+
+ fun getLastVisibleItem(lastVisibleItemPositions: IntArray): Int {
+ var maxSize = 0
+ for (i in lastVisibleItemPositions.indices) {
+ if (i == 0) {
+ maxSize = lastVisibleItemPositions[i]
+ } else if (lastVisibleItemPositions[i] > maxSize) {
+ maxSize = lastVisibleItemPositions[i]
+ }
+ }
+ return maxSize
+ }
+
+ override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
+ var lastVisibleItemPosition = 0
+ val totalItemCount = mLayoutManager?.itemCount ?: 0
+
+ if (mLayoutManager is StaggeredGridLayoutManager) {
+ val lastVisibleItemPositions = (mLayoutManager as StaggeredGridLayoutManager).findLastVisibleItemPositions(null)
+ lastVisibleItemPosition = getLastVisibleItem(lastVisibleItemPositions)
+ } else if (mLayoutManager is GridLayoutManager) {
+ lastVisibleItemPosition = (mLayoutManager as GridLayoutManager).findLastVisibleItemPosition()
+ } else if (mLayoutManager is LinearLayoutManager) {
+ lastVisibleItemPosition = (mLayoutManager as LinearLayoutManager).findLastVisibleItemPosition()
+ }
+
+ if (totalItemCount < previousTotalItemCount) {
+ this.currentPage = this.startingPageIndex
+ this.previousTotalItemCount = totalItemCount
+ if (totalItemCount == 0) {
+ this.loading = true
+ }
+ }
+
+ if (loading && totalItemCount > previousTotalItemCount) {
+ loading = false
+ previousTotalItemCount = totalItemCount
+ }
+
+ if (!loading && lastVisibleItemPosition + visibleThreshold > totalItemCount) {
+ currentPage++
+ onLoadMore(currentPage, totalItemCount, view)
+ loading = true
+ }
+ }
+
+ fun resetState() {
+ this.currentPage = this.startingPageIndex
+ this.previousTotalItemCount = 0
+ this.loading = true
+ }
+
+ abstract fun onLoadMore(page: Int, totalItemsCount: Int, view: RecyclerView)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/core/utils/KeyboardUtils.kt b/app/src/main/java/com/mina_mikhail/newsapp/core/utils/KeyboardUtils.kt
new file mode 100644
index 0000000..72d7d88
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/core/utils/KeyboardUtils.kt
@@ -0,0 +1,22 @@
+package com.mina_mikhail.newsapp.core.utils
+
+import android.app.Activity
+import android.content.Context
+import android.view.View
+import android.view.inputmethod.InputMethodManager
+import android.widget.EditText
+
+fun hideSoftInput(activity: Activity) {
+ var view = activity.currentFocus
+ if (view == null) view = View(activity)
+ val imm = activity.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
+ imm.hideSoftInputFromWindow(view.windowToken, 0)
+}
+
+fun showSoftInput(edit: EditText, context: Context) {
+ edit.isFocusable = true
+ edit.isFocusableInTouchMode = true
+ edit.requestFocus()
+ val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
+ imm.showSoftInput(edit, 0)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/core/utils/SearchEditTextListener.kt b/app/src/main/java/com/mina_mikhail/newsapp/core/utils/SearchEditTextListener.kt
new file mode 100644
index 0000000..b8bddf9
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/core/utils/SearchEditTextListener.kt
@@ -0,0 +1,56 @@
+package com.mina_mikhail.newsapp.core.utils
+
+import android.text.Editable
+import android.text.TextWatcher
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleObserver
+import androidx.lifecycle.OnLifecycleEvent
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+internal class SearchEditTextListener(
+ lifecycle: Lifecycle,
+ private val onSearchQueryChange: (String?) -> Unit
+) : TextWatcher, LifecycleObserver {
+
+ companion object {
+ private const val debouncePeriod: Long = 600
+ }
+
+ private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Main)
+ private var currentSearchJob: Job? = null
+
+ init {
+ lifecycle.addObserver(this)
+ }
+
+ override
+ fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
+
+ }
+
+ override
+ fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
+ currentSearchJob?.cancel()
+
+ currentSearchJob = coroutineScope.launch {
+ p0?.let {
+ delay(debouncePeriod)
+ onSearchQueryChange(p0.toString())
+ }
+ }
+ }
+
+ override
+ fun afterTextChanged(p0: Editable?) {
+
+ }
+
+ @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
+ private fun destroy() {
+ currentSearchJob?.cancel()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/core/utils/SingleLiveEvent.kt b/app/src/main/java/com/mina_mikhail/newsapp/core/utils/SingleLiveEvent.kt
new file mode 100644
index 0000000..ceac149
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/core/utils/SingleLiveEvent.kt
@@ -0,0 +1,58 @@
+package com.mina_mikhail.newsapp.core.utils
+
+import android.util.Log
+import androidx.annotation.MainThread
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.Observer
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * A lifecycle-aware observable that sends only new updates after subscription, used for events like
+ * navigation and Snackbar messages.
+ *
+ *
+ * This avoids a common problem with events: on configuration change (like rotation) an update
+ * can be emitted if the observer is active. This LiveData only calls the observable if there's an
+ * explicit call to setValue() or call().
+ *
+ *
+ * Note that only one observer is going to be notified of changes.
+ */
+class SingleLiveEvent : MutableLiveData() {
+ private val pending = AtomicBoolean(false)
+
+ @MainThread
+ override fun observe(owner: LifecycleOwner, observer: Observer) {
+ if (hasActiveObservers()) {
+ Log.w(TAG, "Multiple observers registered but only one will be notified of changes.")
+ }
+ // Observe the internal MutableLiveData
+ super.observe(
+ owner,
+ Observer { t ->
+ if (pending.compareAndSet(true, false)) {
+ observer.onChanged(t)
+ }
+ }
+ )
+ }
+
+ @MainThread
+ override fun setValue(t: T?) {
+ pending.set(true)
+ super.setValue(t)
+ }
+
+ /**
+ * Used for cases where T is Void, to make calls cleaner.
+ */
+ @MainThread
+ fun call() {
+ value = null
+ }
+
+ companion object {
+ private const val TAG = "SingleLiveEvent"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/core/utils/SwipeToDeleteCallback.kt b/app/src/main/java/com/mina_mikhail/newsapp/core/utils/SwipeToDeleteCallback.kt
new file mode 100644
index 0000000..d82e05d
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/core/utils/SwipeToDeleteCallback.kt
@@ -0,0 +1,87 @@
+package com.mina_mikhail.newsapp.core.utils
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.PorterDuff
+import android.graphics.PorterDuffXfermode
+import android.graphics.drawable.ColorDrawable
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.RecyclerView
+
+abstract class SwipeToDeleteCallback(context: Context) :
+ ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT) {
+
+ private val deleteIcon = ContextCompat.getDrawable(context, android.R.drawable.ic_menu_delete)!!
+ private val intrinsicWidth = deleteIcon.intrinsicWidth
+ private val intrinsicHeight = deleteIcon.intrinsicHeight
+ private val background = ColorDrawable()
+ private val backgroundColor = Color.RED
+ private val clearPaint = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) }
+
+ override fun getMovementFlags(
+ recyclerView: RecyclerView,
+ viewHolder: RecyclerView.ViewHolder
+ ): Int {
+ return super.getMovementFlags(recyclerView, viewHolder)
+ }
+
+ override fun onMove(
+ recyclerView: RecyclerView,
+ viewHolder: RecyclerView.ViewHolder,
+ target: RecyclerView.ViewHolder
+ ): Boolean {
+ return false
+ }
+
+ override fun onChildDraw(
+ c: Canvas, recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder,
+ dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean
+ ) {
+
+ val itemView = viewHolder.itemView
+ val itemHeight = itemView.bottom - itemView.top
+ val isCanceled = dX == 0f && !isCurrentlyActive
+
+ if (isCanceled) {
+ clearCanvas(
+ c,
+ itemView.right + dX,
+ itemView.top.toFloat(),
+ itemView.right.toFloat(),
+ itemView.bottom.toFloat()
+ )
+ super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
+ return
+ }
+
+ // Draw the red delete background
+ background.color = backgroundColor
+ background.setBounds(
+ itemView.right + dX.toInt(),
+ itemView.top,
+ itemView.right,
+ itemView.bottom
+ )
+ background.draw(c)
+
+ // Calculate position of delete icon
+ val deleteIconTop = itemView.top + (itemHeight - intrinsicHeight) / 2
+ val deleteIconMargin = (itemHeight - intrinsicHeight) / 2
+ val deleteIconLeft = itemView.right - deleteIconMargin - intrinsicWidth
+ val deleteIconRight = itemView.right - deleteIconMargin
+ val deleteIconBottom = deleteIconTop + intrinsicHeight
+
+ // Draw the delete icon
+ deleteIcon.setBounds(deleteIconLeft, deleteIconTop, deleteIconRight, deleteIconBottom)
+ deleteIcon.draw(c)
+
+ super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
+ }
+
+ private fun clearCanvas(c: Canvas?, left: Float, top: Float, right: Float, bottom: Float) {
+ c?.drawRect(left, top, right, bottom, clearPaint)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/core/utils/Utils.kt b/app/src/main/java/com/mina_mikhail/newsapp/core/utils/Utils.kt
new file mode 100644
index 0000000..ed06d7f
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/core/utils/Utils.kt
@@ -0,0 +1,61 @@
+package com.mina_mikhail.newsapp.core.utils
+
+import android.app.Activity
+import android.app.Dialog
+import android.content.Context
+import android.graphics.Color
+import android.graphics.drawable.ColorDrawable
+import android.view.View
+import android.widget.TextView
+import android.widget.Toast
+import com.mina_mikhail.newsapp.R
+import com.mina_mikhail.newsapp.R.id
+import com.mina_mikhail.newsapp.R.layout
+import com.tapadoo.alerter.Alerter
+
+fun showMessage(context: Context, message: String?) {
+ Toast.makeText(context, message ?: context.resources.getString(R.string.some_error), Toast.LENGTH_SHORT)
+ .show()
+}
+
+fun showNoInternetAlert(activity: Activity) {
+ Alerter.create(activity)
+ .setTitle(activity.resources.getString(R.string.connection_error))
+ .setText(activity.resources.getString(R.string.no_internet))
+ .setIcon(R.drawable.ic_no_internet)
+ .setBackgroundColorRes(R.color.red)
+ .enableClickAnimation(true)
+ .enableSwipeToDismiss()
+ .show()
+}
+
+fun showLoadingDialog(activity: Activity?, hint: String?): Dialog? {
+ if (activity == null || activity.isFinishing()) {
+ return null
+ }
+ val progressDialog = Dialog(activity)
+ progressDialog.show()
+ if (progressDialog.window != null) {
+ progressDialog.window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
+ }
+ progressDialog.setContentView(layout.progress_dialog)
+ val tvHint = progressDialog.findViewById(id.tv_hint)
+ if (hint != null && !hint.isEmpty()) {
+ tvHint.visibility = View.VISIBLE
+ tvHint.text = hint
+ } else {
+ tvHint.visibility = View.GONE
+ }
+
+ progressDialog.setCancelable(false)
+ progressDialog.setCanceledOnTouchOutside(false)
+ progressDialog.show()
+
+ return progressDialog
+}
+
+fun hideLoadingDialog(mProgressDialog: Dialog?, activity: Activity?) {
+ if (activity != null && !activity.isFinishing() && mProgressDialog != null && mProgressDialog.isShowing) {
+ mProgressDialog.dismiss()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/core/view/BaseActivity.kt b/app/src/main/java/com/mina_mikhail/newsapp/core/view/BaseActivity.kt
new file mode 100644
index 0000000..b42a7d5
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/core/view/BaseActivity.kt
@@ -0,0 +1,48 @@
+package com.mina_mikhail.newsapp.core.view
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.LiveData
+import androidx.navigation.NavController
+import androidx.viewbinding.ViewBinding
+
+abstract class BaseActivity : AppCompatActivity() {
+
+ protected lateinit var binding: VB
+ protected lateinit var navController: LiveData
+
+ override
+ fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ initViewBinding()
+ setContentView(binding.root)
+
+ if (savedInstanceState == null) {
+ setUpBottomNavigation()
+ }
+
+ setUpViews()
+ }
+
+ override
+ fun onRestoreInstanceState(savedInstanceState: Bundle) {
+ super.onRestoreInstanceState(savedInstanceState)
+
+ setUpBottomNavigation()
+ }
+
+ private fun initViewBinding() {
+ binding = getViewBinding()
+ }
+
+ abstract fun getViewBinding(): VB
+
+ open fun setUpBottomNavigation() {}
+
+ open fun setUpViews() {}
+
+ override
+ fun onSupportNavigateUp(): Boolean {
+ return navController.value?.navigateUp()!! || super.onSupportNavigateUp()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/core/view/BaseFragment.kt b/app/src/main/java/com/mina_mikhail/newsapp/core/view/BaseFragment.kt
new file mode 100644
index 0000000..78ab5d4
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/core/view/BaseFragment.kt
@@ -0,0 +1,84 @@
+package com.mina_mikhail.newsapp.core.view
+
+import android.app.Dialog
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.viewbinding.ViewBinding
+import com.mina_mikhail.newsapp.core.utils.hideLoadingDialog
+import com.mina_mikhail.newsapp.core.utils.showLoadingDialog
+
+abstract class BaseFragment : Fragment() {
+
+ protected lateinit var binding: VB
+ private var mRootView: View? = null
+ private var hasInitializedRootView = false
+ private var progressDialog: Dialog? = null
+
+ override
+ fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ if (mRootView == null) {
+ initViewBinding(inflater, container)
+ }
+
+ return mRootView
+ }
+
+ private fun initViewBinding(inflater: LayoutInflater, container: ViewGroup?) {
+ binding = getViewBinding(inflater, container)
+ mRootView = binding.root
+ }
+
+ override
+ fun onResume() {
+ super.onResume()
+
+ registerListeners()
+ }
+
+ override
+ fun onPause() {
+ unRegisterListeners()
+
+ super.onPause()
+ }
+
+ override
+ fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ if (!hasInitializedRootView) {
+ setUpViews()
+ handleClickListeners()
+ subscribeToObservables();
+
+ hasInitializedRootView = true
+ }
+ }
+
+ abstract fun getViewBinding(inflater: LayoutInflater, container: ViewGroup?): VB
+
+ open fun registerListeners() {}
+
+ open fun unRegisterListeners() {}
+
+ open fun setUpViews() {}
+
+ open fun handleClickListeners() {}
+
+ open fun subscribeToObservables() {}
+
+ fun showLoading() {
+ hideLoading()
+ progressDialog = showLoadingDialog(requireActivity(), null)
+ }
+
+ fun showLoading(hint: String?) {
+ hideLoading()
+ progressDialog = showLoadingDialog(requireActivity(), hint)
+ }
+
+ fun hideLoading() = hideLoadingDialog(progressDialog, requireActivity())
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/core/view/extensions/ActivityExtensions.kt b/app/src/main/java/com/mina_mikhail/newsapp/core/view/extensions/ActivityExtensions.kt
new file mode 100644
index 0000000..294fc18
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/core/view/extensions/ActivityExtensions.kt
@@ -0,0 +1,34 @@
+package com.mina_mikhail.newsapp.core.view.extensions
+
+import android.Manifest
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import androidx.core.app.ActivityCompat
+
+fun Activity.openActivityAndClearStack(activity: Class) {
+ Intent(this, activity).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ startActivity(this)
+ finish()
+ }
+}
+
+fun Activity.openActivity(activity: Class) {
+ Intent(this, activity).apply {
+ startActivity(this)
+ }
+}
+
+fun Context.hasPermission(permission: String): Boolean {
+ // Background permissions didn't exit prior to Q, so it's approved by default.
+ if (permission == Manifest.permission.ACCESS_BACKGROUND_LOCATION &&
+ android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q
+ ) {
+ return true
+ }
+
+ return ActivityCompat.checkSelfPermission(this, permission) ==
+ PackageManager.PERMISSION_GRANTED
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/core/view/extensions/FragmentExtensions.kt b/app/src/main/java/com/mina_mikhail/newsapp/core/view/extensions/FragmentExtensions.kt
new file mode 100644
index 0000000..e5546b2
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/core/view/extensions/FragmentExtensions.kt
@@ -0,0 +1,66 @@
+package com.mina_mikhail.newsapp.core.view.extensions
+
+import androidx.fragment.app.Fragment
+import com.mina_mikhail.newsapp.R
+import com.mina_mikhail.newsapp.core.network.FailureStatus.API_FAIL
+import com.mina_mikhail.newsapp.core.network.FailureStatus.NO_INTERNET
+import com.mina_mikhail.newsapp.core.network.FailureStatus.OTHER
+import com.mina_mikhail.newsapp.core.network.FailureStatus.SERVER_SIDE_EXCEPTION
+import com.mina_mikhail.newsapp.core.network.FailureStatus.TOKEN_EXPIRED
+import com.mina_mikhail.newsapp.core.network.Resource.Failure
+import com.mina_mikhail.newsapp.core.utils.hideSoftInput
+import com.mina_mikhail.newsapp.core.utils.showMessage
+import com.mina_mikhail.newsapp.core.utils.showNoInternetAlert
+
+fun Fragment.handleApiError(
+ failure: Failure,
+ retryAction: (() -> Unit)? = null,
+ noDataAction: (() -> Unit)? = null,
+ noInternetAction: (() -> Unit)? = null
+) {
+ when (failure.failureStatus) {
+ API_FAIL, SERVER_SIDE_EXCEPTION -> {
+ noDataAction?.let {
+ it()
+ }
+
+ requireView().showSnackBar(
+ resources.getString(R.string.some_error),
+ resources.getString(R.string.retry),
+ retryAction
+ )
+ }
+ TOKEN_EXPIRED -> {
+ // TODO : CALL API TO REFRESH TOKEN
+ // OR (depends on your application business)
+ // TODO : LOG OUT
+ }
+ NO_INTERNET -> {
+ noInternetAction?.let {
+ it()
+ }
+
+ showNoInternetAlert(requireActivity())
+ }
+ OTHER -> {
+ noDataAction?.let {
+ it()
+ }
+
+ requireView().showSnackBar(
+ resources.getString(R.string.some_error),
+ resources.getString(R.string.retry),
+ retryAction
+ )
+ }
+ }
+}
+
+fun Fragment.hideKeyboard() = hideSoftInput(requireActivity())
+
+fun Fragment.showNoInternetAlert() = showNoInternetAlert(requireActivity())
+
+fun Fragment.showMessage(message: String?) = showMessage(requireContext(), message)
+
+fun Fragment.showError(message: String, retryActionName: String? = null, action: (() -> Unit)? = null) =
+ requireView().showSnackBar(message, retryActionName, action)
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/core/view/extensions/NavigationExtensions.kt b/app/src/main/java/com/mina_mikhail/newsapp/core/view/extensions/NavigationExtensions.kt
new file mode 100644
index 0000000..7137dfe
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/core/view/extensions/NavigationExtensions.kt
@@ -0,0 +1,239 @@
+package com.mina_mikhail.newsapp.core.view.extensions
+
+import android.content.Intent
+import android.util.SparseArray
+import androidx.core.util.forEach
+import androidx.core.util.set
+import androidx.fragment.app.FragmentManager
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.navigation.NavController
+import androidx.navigation.fragment.NavHostFragment
+import com.google.android.material.bottomnavigation.BottomNavigationView
+import com.mina_mikhail.newsapp.R
+
+/**
+ * Manages the various graphs needed for a [BottomNavigationView].
+ *
+ * This sample is a workaround until the Navigation Component supports multiple back stacks.
+ */
+fun BottomNavigationView.setupWithNavController(
+ navGraphIds: List,
+ fragmentManager: FragmentManager,
+ containerId: Int,
+ intent: Intent
+): LiveData {
+
+ // Map of tags
+ val graphIdToTagMap = SparseArray()
+ // Result. Mutable live data with the selected controlled
+ val selectedNavController = MutableLiveData()
+
+ var firstFragmentGraphId = 0
+
+ // First create a NavHostFragment for each NavGraph ID
+ navGraphIds.forEachIndexed { index, navGraphId ->
+ val fragmentTag = getFragmentTag(index)
+
+ // Find or create the Navigation host fragment
+ val navHostFragment = obtainNavHostFragment(
+ fragmentManager,
+ fragmentTag,
+ navGraphId,
+ containerId
+ )
+
+ // Obtain its id
+ val graphId = navHostFragment.navController.graph.id
+
+ if (index == 0) {
+ firstFragmentGraphId = graphId
+ }
+
+ // Save to the map
+ graphIdToTagMap[graphId] = fragmentTag
+
+ // Attach or detach nav host fragment depending on whether it's the selected item.
+ if (this.selectedItemId == graphId) {
+ // Update livedata with the selected graph
+ selectedNavController.value = navHostFragment.navController
+ attachNavHostFragment(fragmentManager, navHostFragment, index == 0)
+ } else {
+ detachNavHostFragment(fragmentManager, navHostFragment)
+ }
+ }
+
+ // Now connect selecting an item with swapping Fragments
+ var selectedItemTag = graphIdToTagMap[this.selectedItemId]
+ val firstFragmentTag = graphIdToTagMap[firstFragmentGraphId]
+ var isOnFirstFragment = selectedItemTag == firstFragmentTag
+
+ // When a navigation item is selected
+ setOnNavigationItemSelectedListener { item ->
+ // Don't do anything if the state is state has already been saved.
+ if (fragmentManager.isStateSaved) {
+ false
+ } else {
+ val newlySelectedItemTag = graphIdToTagMap[item.itemId]
+ if (selectedItemTag != newlySelectedItemTag) {
+ // Pop everything above the first fragment (the "fixed start destination")
+ fragmentManager.popBackStack(
+ firstFragmentTag,
+ FragmentManager.POP_BACK_STACK_INCLUSIVE
+ )
+ val selectedFragment = fragmentManager.findFragmentByTag(newlySelectedItemTag)
+ as NavHostFragment
+
+ // Exclude the first fragment tag because it's always in the back stack.
+ if (firstFragmentTag != newlySelectedItemTag) {
+ // Commit a transaction that cleans the back stack and adds the first fragment
+ // to it, creating the fixed started destination.
+ fragmentManager.beginTransaction()
+ .setCustomAnimations(
+ R.anim.nav_default_enter_anim,
+ R.anim.nav_default_exit_anim,
+ R.anim.nav_default_pop_enter_anim,
+ R.anim.nav_default_pop_exit_anim
+ )
+ .attach(selectedFragment)
+ .setPrimaryNavigationFragment(selectedFragment)
+ .apply {
+ // Detach all other Fragments
+ graphIdToTagMap.forEach { _, fragmentTagIter ->
+ if (fragmentTagIter != newlySelectedItemTag) {
+ detach(fragmentManager.findFragmentByTag(firstFragmentTag)!!)
+ }
+ }
+ }
+ .addToBackStack(firstFragmentTag)
+ .setReorderingAllowed(true)
+ .commit()
+ }
+ selectedItemTag = newlySelectedItemTag
+ isOnFirstFragment = selectedItemTag == firstFragmentTag
+ selectedNavController.value = selectedFragment.navController
+ true
+ } else {
+ false
+ }
+ }
+ }
+
+ // Optional: on item reselected, pop back stack to the destination of the graph
+ setupItemReselected(graphIdToTagMap, fragmentManager)
+
+ // Handle deep link
+ setupDeepLinks(navGraphIds, fragmentManager, containerId, intent)
+
+ // Finally, ensure that we update our BottomNavigationView when the back stack changes
+ fragmentManager.addOnBackStackChangedListener {
+ if (!isOnFirstFragment && !fragmentManager.isOnBackStack(firstFragmentTag)) {
+ this.selectedItemId = firstFragmentGraphId
+ }
+
+ // Reset the graph if the currentDestination is not valid (happens when the back
+ // stack is popped after using the back button).
+ selectedNavController.value?.let { controller ->
+ if (controller.currentDestination == null) {
+ controller.navigate(controller.graph.id)
+ }
+ }
+ }
+ return selectedNavController
+}
+
+private fun BottomNavigationView.setupDeepLinks(
+ navGraphIds: List,
+ fragmentManager: FragmentManager,
+ containerId: Int,
+ intent: Intent
+) {
+ navGraphIds.forEachIndexed { index, navGraphId ->
+ val fragmentTag = getFragmentTag(index)
+
+ // Find or create the Navigation host fragment
+ val navHostFragment = obtainNavHostFragment(
+ fragmentManager,
+ fragmentTag,
+ navGraphId,
+ containerId
+ )
+ // Handle Intent
+ if (navHostFragment.navController.handleDeepLink(intent)
+ && selectedItemId != navHostFragment.navController.graph.id
+ ) {
+ this.selectedItemId = navHostFragment.navController.graph.id
+ }
+ }
+}
+
+private fun BottomNavigationView.setupItemReselected(
+ graphIdToTagMap: SparseArray,
+ fragmentManager: FragmentManager
+) {
+ setOnNavigationItemReselectedListener { item ->
+ val newlySelectedItemTag = graphIdToTagMap[item.itemId]
+ val selectedFragment = fragmentManager.findFragmentByTag(newlySelectedItemTag)
+ as NavHostFragment
+ val navController = selectedFragment.navController
+ // Pop the back stack to the start destination of the current navController graph
+ navController.popBackStack(
+ navController.graph.startDestination, false
+ )
+ }
+}
+
+private fun detachNavHostFragment(
+ fragmentManager: FragmentManager,
+ navHostFragment: NavHostFragment
+) {
+ fragmentManager.beginTransaction()
+ .detach(navHostFragment)
+ .commitNow()
+}
+
+private fun attachNavHostFragment(
+ fragmentManager: FragmentManager,
+ navHostFragment: NavHostFragment,
+ isPrimaryNavFragment: Boolean
+) {
+ fragmentManager.beginTransaction()
+ .attach(navHostFragment)
+ .apply {
+ if (isPrimaryNavFragment) {
+ setPrimaryNavigationFragment(navHostFragment)
+ }
+ }
+ .commitNow()
+
+}
+
+private fun obtainNavHostFragment(
+ fragmentManager: FragmentManager,
+ fragmentTag: String,
+ navGraphId: Int,
+ containerId: Int
+): NavHostFragment {
+ // If the Nav Host fragment exists, return it
+ val existingFragment = fragmentManager.findFragmentByTag(fragmentTag) as NavHostFragment?
+ existingFragment?.let { return it }
+
+ // Otherwise, create it and return it.
+ val navHostFragment = NavHostFragment.create(navGraphId)
+ fragmentManager.beginTransaction()
+ .add(containerId, navHostFragment, fragmentTag)
+ .commitNow()
+ return navHostFragment
+}
+
+private fun FragmentManager.isOnBackStack(backStackName: String): Boolean {
+ val backStackCount = backStackEntryCount
+ for (index in 0 until backStackCount) {
+ if (getBackStackEntryAt(index).name == backStackName) {
+ return true
+ }
+ }
+ return false
+}
+
+private fun getFragmentTag(index: Int) = "bottomNavigation#$index"
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/core/view/extensions/ViewExtensions.kt b/app/src/main/java/com/mina_mikhail/newsapp/core/view/extensions/ViewExtensions.kt
new file mode 100644
index 0000000..a48e564
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/core/view/extensions/ViewExtensions.kt
@@ -0,0 +1,97 @@
+package com.mina_mikhail.newsapp.core.view.extensions
+
+import android.view.View
+import android.widget.ImageView
+import androidx.constraintlayout.widget.Group
+import coil.load
+import coil.transform.CircleCropTransformation
+import coil.transform.RoundedCornersTransformation
+import com.google.android.material.snackbar.Snackbar
+import com.mina_mikhail.newsapp.R
+
+fun View.show() {
+ visibility = View.VISIBLE
+ if (this is Group) {
+ this.requestLayout()
+ }
+}
+
+fun View.hide() {
+ visibility = View.GONE
+ if (this is Group) {
+ this.requestLayout()
+ }
+}
+
+fun View.invisible() {
+ visibility = View.INVISIBLE
+ if (this is Group) {
+ this.requestLayout()
+ }
+}
+
+fun View.enable() {
+ isEnabled = true
+ alpha = 1f
+}
+
+fun View.disable() {
+ isEnabled = false
+ alpha = 0.5f
+}
+
+fun View.showSnackBar(message: String, retryActionName: String? = null, action: (() -> Unit)? = null) {
+ val snackBar = Snackbar.make(this, message, Snackbar.LENGTH_LONG)
+
+ action?.let {
+ snackBar.setAction(retryActionName) {
+ it()
+ }
+ }
+
+ snackBar.show()
+}
+
+fun ImageView.loadImage(imageUrl: String?) {
+ if (imageUrl != null && imageUrl.isNotEmpty()) {
+ load(imageUrl) {
+ crossfade(true)
+ placeholder(R.color.backgroundGray)
+ error(R.drawable.bg_no_image)
+ }
+ } else {
+ setImageResource(R.drawable.bg_no_image)
+ }
+}
+
+fun ImageView.loadCircleImage(imageUrl: String?) {
+ if (imageUrl != null && imageUrl.isNotEmpty()) {
+ load(imageUrl) {
+ crossfade(true)
+ placeholder(R.color.backgroundGray)
+ error(R.drawable.bg_no_image)
+ transformations(
+ CircleCropTransformation()
+ )
+ }
+ } else {
+ setImageResource(R.drawable.bg_no_image)
+ }
+}
+
+fun ImageView.loadRoundImage(imageUrl: String?) {
+ if (imageUrl != null && imageUrl.isNotEmpty()) {
+ load(imageUrl) {
+ crossfade(true)
+ placeholder(R.color.backgroundGray)
+ error(R.drawable.bg_no_image)
+ transformations(
+ RoundedCornersTransformation(
+ resources.getDimension(R.dimen.dimen7)
+ )
+ )
+ }
+ } else {
+ setImageResource(R.drawable.bg_no_image)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/features/news/data/data_source/local/ArticlesDao.kt b/app/src/main/java/com/mina_mikhail/newsapp/features/news/data/data_source/local/ArticlesDao.kt
new file mode 100644
index 0000000..241dbd9
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/features/news/data/data_source/local/ArticlesDao.kt
@@ -0,0 +1,36 @@
+package com.mina_mikhail.newsapp.features.news.data.data_source.local
+
+import androidx.lifecycle.LiveData
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import com.mina_mikhail.newsapp.features.news.domain.entity.model.Article
+
+@Dao
+interface ArticlesDao {
+ /**
+ * Select all articles from the articles table.
+ *
+ * @return all articles.
+ */
+ @Query("SELECT * FROM articles")
+ fun getAll(): LiveData>
+
+ /**
+ * Insert articles in the database. If the article already exists, replace it.
+ *
+ * @param article the city to be inserted.
+ */
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(article: Article)
+
+ /**
+ * Delete article from the articles table.
+ *
+ * @param article the city to be inserted.
+ */
+ @Delete
+ suspend fun delete(article: Article)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/features/news/data/data_source/local/NewsLocalDataSource.kt b/app/src/main/java/com/mina_mikhail/newsapp/features/news/data/data_source/local/NewsLocalDataSource.kt
new file mode 100644
index 0000000..7e49cbd
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/features/news/data/data_source/local/NewsLocalDataSource.kt
@@ -0,0 +1,15 @@
+package com.mina_mikhail.newsapp.features.news.data.data_source.local
+
+import androidx.lifecycle.LiveData
+import com.mina_mikhail.newsapp.core.local.MyDatabase
+import com.mina_mikhail.newsapp.features.news.domain.entity.model.Article
+import javax.inject.Inject
+
+class NewsLocalDataSource @Inject constructor(private val myDatabase: MyDatabase) {
+
+ fun getArticlesFromLocal(): LiveData> = myDatabase.getArticlesDao().getAll()
+
+ suspend fun saveArticleToLocal(article: Article) = myDatabase.getArticlesDao().insert(article)
+
+ suspend fun deleteArticleFromLocal(article: Article) = myDatabase.getArticlesDao().delete(article)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/features/news/data/data_source/remote/NewsRemoteDataSource.kt b/app/src/main/java/com/mina_mikhail/newsapp/features/news/data/data_source/remote/NewsRemoteDataSource.kt
new file mode 100644
index 0000000..30f03da
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/features/news/data/data_source/remote/NewsRemoteDataSource.kt
@@ -0,0 +1,15 @@
+package com.mina_mikhail.newsapp.features.news.data.data_source.remote
+
+import com.mina_mikhail.newsapp.core.data_source.BaseRemoteDataSource
+import javax.inject.Inject
+
+class NewsRemoteDataSource @Inject constructor(private val apiService: NewsServices) : BaseRemoteDataSource() {
+
+ suspend fun getBreakingNews(country: String, page: Int) = safeApiCall {
+ apiService.getBreakingNews(country, page, LIST_PAGE_SIZE)
+ }
+
+ suspend fun searchForNews(searchQuery: String, page: Int) = safeApiCall {
+ apiService.searchForNews(searchQuery, page, LIST_PAGE_SIZE)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/features/news/data/data_source/remote/NewsServices.kt b/app/src/main/java/com/mina_mikhail/newsapp/features/news/data/data_source/remote/NewsServices.kt
new file mode 100644
index 0000000..f67fa6f
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/features/news/data/data_source/remote/NewsServices.kt
@@ -0,0 +1,23 @@
+package com.mina_mikhail.newsapp.features.news.data.data_source.remote
+
+import com.mina_mikhail.newsapp.features.news.domain.entity.response.NewsResponse
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+interface NewsServices {
+
+ @GET("top-headlines")
+ suspend fun getBreakingNews(
+ @Query("country") country: String,
+ @Query("page") page: Int,
+ @Query("pageSize") pageSize: Int
+ ): NewsResponse
+
+ @GET("everything")
+ suspend fun searchForNews(
+ @Query("q") searchQuery: String,
+ @Query("page") page: Int,
+ @Query("pageSize") pageSize: Int
+ ): NewsResponse
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/features/news/data/repository/NewsRepository.kt b/app/src/main/java/com/mina_mikhail/newsapp/features/news/data/repository/NewsRepository.kt
new file mode 100644
index 0000000..40321da
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/features/news/data/repository/NewsRepository.kt
@@ -0,0 +1,22 @@
+package com.mina_mikhail.newsapp.features.news.data.repository
+
+import com.mina_mikhail.newsapp.features.news.data.data_source.local.NewsLocalDataSource
+import com.mina_mikhail.newsapp.features.news.data.data_source.remote.NewsRemoteDataSource
+import com.mina_mikhail.newsapp.features.news.domain.entity.model.Article
+import javax.inject.Inject
+
+class NewsRepository @Inject constructor(
+ private val remoteDataSource: NewsRemoteDataSource,
+ private val newsLocalDataSource: NewsLocalDataSource
+) {
+
+ suspend fun getBreakingNews(country: String, page: Int) = remoteDataSource.getBreakingNews(country, page)
+
+ suspend fun searchForNews(searchQuery: String, page: Int) = remoteDataSource.searchForNews(searchQuery, page)
+
+ fun getArticlesFromLocal() = newsLocalDataSource.getArticlesFromLocal()
+
+ suspend fun saveArticleToLocal(article: Article) = newsLocalDataSource.saveArticleToLocal(article)
+
+ suspend fun deleteArticleFromLocal(article: Article) = newsLocalDataSource.deleteArticleFromLocal(article)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/features/news/domain/Converters.kt b/app/src/main/java/com/mina_mikhail/newsapp/features/news/domain/Converters.kt
new file mode 100644
index 0000000..af29b3f
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/features/news/domain/Converters.kt
@@ -0,0 +1,22 @@
+package com.mina_mikhail.newsapp.features.news.domain
+
+import androidx.room.TypeConverter
+import com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
+import com.mina_mikhail.newsapp.features.news.domain.entity.model.Source
+import java.lang.reflect.Type
+
+class Converters {
+
+ @TypeConverter
+ fun fromSourceString(value: String): Source {
+ val modelType: Type = object : TypeToken() {}.type
+ return Gson().fromJson(value, modelType)
+ }
+
+ @TypeConverter
+ fun fromSourceModel(model: Source): String {
+ val gson = Gson()
+ return gson.toJson(model)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/features/news/domain/entity/model/Article.kt b/app/src/main/java/com/mina_mikhail/newsapp/features/news/domain/entity/model/Article.kt
new file mode 100644
index 0000000..0eab510
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/features/news/domain/entity/model/Article.kt
@@ -0,0 +1,23 @@
+package com.mina_mikhail.newsapp.features.news.domain.entity.model
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import java.io.Serializable
+
+@Entity(tableName = "articles")
+data class Article(
+ @PrimaryKey(autoGenerate = true)
+ val id: Int,
+ val author: String,
+ val content: String,
+ val description: String,
+ val publishedAt: String,
+ val title: String,
+ val url: String,
+ val urlToImage: String,
+ val source: Source
+) : Serializable
+
+data class Source(
+ val name: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/features/news/domain/entity/response/NewsResponse.kt b/app/src/main/java/com/mina_mikhail/newsapp/features/news/domain/entity/response/NewsResponse.kt
new file mode 100644
index 0000000..f317b65
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/features/news/domain/entity/response/NewsResponse.kt
@@ -0,0 +1,9 @@
+package com.mina_mikhail.newsapp.features.news.domain.entity.response
+
+import com.mina_mikhail.newsapp.features.news.domain.entity.model.Article
+
+data class NewsResponse(
+ val articles: List,
+ val status: String,
+ val totalResults: Int
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/NewsActivity.kt b/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/NewsActivity.kt
new file mode 100644
index 0000000..95ea363
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/NewsActivity.kt
@@ -0,0 +1,32 @@
+package com.mina_mikhail.newsapp.features.news.presentation
+
+import com.mina_mikhail.newsapp.R
+import com.mina_mikhail.newsapp.core.view.BaseActivity
+import com.mina_mikhail.newsapp.core.view.extensions.setupWithNavController
+import com.mina_mikhail.newsapp.databinding.ActivityNewsBinding
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class NewsActivity : BaseActivity() {
+
+ override
+ fun getViewBinding() = ActivityNewsBinding.inflate(layoutInflater)
+
+ override
+ fun setUpBottomNavigation() {
+ val graphIds = listOf(
+ R.navigation.nav_breaking_news,
+ R.navigation.nav_saved_news,
+ R.navigation.nav_search_news
+ )
+
+ val controller = binding.bottomNavigationView.setupWithNavController(
+ graphIds,
+ supportFragmentManager,
+ R.id.fragment_host_container,
+ intent
+ )
+
+ navController = controller
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/NewsAdapter.kt b/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/NewsAdapter.kt
new file mode 100644
index 0000000..980b309
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/NewsAdapter.kt
@@ -0,0 +1,70 @@
+package com.mina_mikhail.newsapp.features.news.presentation
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import com.mina_mikhail.newsapp.R
+import com.mina_mikhail.newsapp.core.utils.DateUtils
+import com.mina_mikhail.newsapp.core.utils.convertDateTimeToTimesAgo
+import com.mina_mikhail.newsapp.core.view.extensions.loadRoundImage
+import com.mina_mikhail.newsapp.databinding.ItemNewsBinding
+import com.mina_mikhail.newsapp.features.news.domain.entity.model.Article
+import com.mina_mikhail.newsapp.features.news.presentation.NewsAdapter.ArticlesViewHolder
+
+class NewsAdapter(private var itemClick: (Article) -> Unit) : ListAdapter(DIFF_CALLBACK) {
+
+ companion object {
+ private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() {
+ override
+ fun areItemsTheSame(oldItem: Article, newItem: Article): Boolean =
+ oldItem.url == newItem.url
+
+ override
+ fun areContentsTheSame(oldItem: Article, newItem: Article): Boolean =
+ oldItem == newItem
+ }
+ }
+
+ override
+ fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int
+ ): ArticlesViewHolder {
+ val root = LayoutInflater.from(parent.context).inflate(R.layout.item_news, parent, false)
+ return ArticlesViewHolder(root)
+ }
+
+ override
+ fun onBindViewHolder(holder: ArticlesViewHolder, position: Int) {
+ val item = getItem(position)
+ holder.bind(item)
+ }
+
+ fun getItemByPosition(position: Int): Article = getItem(position)
+
+ inner class ArticlesViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+
+ private val binding = ItemNewsBinding.bind(itemView)
+ private var currentItem: Article? = null
+
+ init {
+ binding.item.setOnClickListener {
+ currentItem?.let {
+ itemClick(it)
+ }
+ }
+ }
+
+ fun bind(item: Article) {
+ currentItem = item
+
+ binding.ivArticleImage.loadRoundImage(item.urlToImage)
+ binding.tvArticleDate.text = item.publishedAt.convertDateTimeToTimesAgo(DateUtils.FULL_DATE_TIME_FORMAT)
+ binding.tvArticleTitle.text = item.title
+ binding.tvArticleDescription.text = item.description
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/breaking_news/BreakingNewsFragment.kt b/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/breaking_news/BreakingNewsFragment.kt
new file mode 100644
index 0000000..6b5c5cc
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/breaking_news/BreakingNewsFragment.kt
@@ -0,0 +1,191 @@
+package com.mina_mikhail.newsapp.features.news.presentation.breaking_news
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.findNavController
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.mina_mikhail.newsapp.R
+import com.mina_mikhail.newsapp.core.data_source.BaseRemoteDataSource
+import com.mina_mikhail.newsapp.core.network.Resource.Empty
+import com.mina_mikhail.newsapp.core.network.Resource.Failure
+import com.mina_mikhail.newsapp.core.network.Resource.Loading
+import com.mina_mikhail.newsapp.core.network.Resource.Success
+import com.mina_mikhail.newsapp.core.utils.EndlessRecyclerViewScrollListener
+import com.mina_mikhail.newsapp.core.view.BaseFragment
+import com.mina_mikhail.newsapp.core.view.extensions.handleApiError
+import com.mina_mikhail.newsapp.core.view.extensions.hide
+import com.mina_mikhail.newsapp.core.view.extensions.show
+import com.mina_mikhail.newsapp.databinding.FragmentBreakingNewsBinding
+import com.mina_mikhail.newsapp.features.news.domain.entity.model.Article
+import com.mina_mikhail.newsapp.features.news.presentation.NewsAdapter
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class BreakingNewsFragment : BaseFragment() {
+
+ private val viewModel: BreakingNewsViewModel by viewModels()
+
+ private lateinit var articlesAdapter: NewsAdapter
+ private lateinit var scrollListener: EndlessRecyclerViewScrollListener
+
+ override
+ fun getViewBinding(inflater: LayoutInflater, container: ViewGroup?) =
+ FragmentBreakingNewsBinding.inflate(inflater, container, false)
+
+ override
+ fun setUpViews() {
+ setUpRecyclerView()
+
+ initSwipeRefreshLayout()
+
+ getBreakingNews()
+ }
+
+ private fun setUpRecyclerView() {
+ articlesAdapter = NewsAdapter { onArticleClick(it) }
+ binding.includedList.recyclerView.apply {
+ setHasFixedSize(true)
+ layoutManager = LinearLayoutManager(requireContext())
+ adapter = articlesAdapter
+
+ initPaging(layoutManager as LinearLayoutManager)
+ }
+ }
+
+ private fun initPaging(layoutManager: LinearLayoutManager) {
+ scrollListener = object : EndlessRecyclerViewScrollListener(3, layoutManager) {
+ override
+ fun onLoadMore(page: Int, totalItemsCount: Int, view: RecyclerView) {
+ if (viewModel.shouldLoadMore) {
+ viewModel.shouldLoadMore = false
+ viewModel.isLoading = true
+
+ getBreakingNews()
+ }
+ }
+ }
+ binding.includedList.recyclerView.addOnScrollListener(scrollListener)
+ }
+
+ private fun getBreakingNews() {
+ viewModel.getBreakingNews("us").observe(this, {
+ binding.swipeRefresh.isRefreshing = false
+
+ when (it) {
+ is Loading -> {
+ if (articlesAdapter.currentList.isNullOrEmpty()) {
+ showDataLoading()
+ } else {
+ showPaginationLoading()
+ }
+ }
+ is Empty -> {
+ if (articlesAdapter.currentList.isNullOrEmpty()) {
+ showNoData()
+ } else {
+ hidePaginationLoading()
+ }
+ }
+ is Success -> {
+ if (it.value.articles.size != BaseRemoteDataSource.LIST_PAGE_SIZE) {
+ viewModel.shouldLoadMore = false
+ } else {
+ viewModel.shouldLoadMore = true
+ viewModel.page += 1
+ }
+
+ if (articlesAdapter.currentList.isNullOrEmpty()) {
+ articlesAdapter.submitList(it.value.articles)
+ } else {
+ articlesAdapter.submitList(articlesAdapter.currentList + it.value.articles)
+ }
+
+ showData()
+ }
+ is Failure -> {
+ if (articlesAdapter.currentList.isNullOrEmpty()) {
+ handleApiError(it, noDataAction = { showNoData() }, noInternetAction = { showNoInternet() })
+ } else {
+ handleApiError(it)
+ hidePaginationLoading()
+ }
+ }
+ }
+ })
+ }
+
+ private fun initSwipeRefreshLayout() {
+ binding.swipeRefresh.setOnRefreshListener { refreshData() }
+ binding.swipeRefresh.setColorSchemeResources(R.color.colorPrimary, R.color.colorPrimaryDark, R.color.colorAccent)
+ }
+
+ private fun refreshData() {
+ initPagingParameters()
+
+ getBreakingNews()
+ }
+
+ private fun initPagingParameters() {
+ viewModel.page = 1
+ viewModel.isLoading = true
+ viewModel.shouldLoadMore = false
+
+ articlesAdapter.submitList(null)
+
+ scrollListener.resetState()
+ }
+
+ private fun onArticleClick(article: Article) {
+ val bundle = Bundle().apply {
+ putSerializable("article", article)
+ }
+ findNavController().navigate(
+ R.id.action_open_news_details_fragment,
+ bundle
+ )
+ }
+
+ private fun showDataLoading() {
+ binding.includedList.container.show()
+ binding.includedList.progressBar.show()
+ binding.includedList.emptyViewContainer.hide()
+ binding.includedList.internetErrorViewContainer.hide()
+ binding.includedList.recyclerView.hide()
+ binding.includedList.paginationProgressBar.hide()
+ }
+
+ private fun showPaginationLoading() {
+ binding.includedList.paginationProgressBar.show()
+ }
+
+ private fun hidePaginationLoading() {
+ binding.includedList.paginationProgressBar.hide()
+ }
+
+ private fun showData() {
+ binding.includedList.recyclerView.show()
+ binding.includedList.container.hide()
+ binding.includedList.paginationProgressBar.hide()
+ }
+
+ private fun showNoData() {
+ binding.includedList.container.show()
+ binding.includedList.emptyViewContainer.show()
+ binding.includedList.internetErrorViewContainer.hide()
+ binding.includedList.progressBar.hide()
+ binding.includedList.paginationProgressBar.hide()
+ binding.includedList.recyclerView.hide()
+ }
+
+ private fun showNoInternet() {
+ binding.includedList.container.show()
+ binding.includedList.internetErrorViewContainer.show()
+ binding.includedList.emptyViewContainer.hide()
+ binding.includedList.progressBar.hide()
+ binding.includedList.paginationProgressBar.hide()
+ binding.includedList.recyclerView.hide()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/breaking_news/BreakingNewsViewModel.kt b/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/breaking_news/BreakingNewsViewModel.kt
new file mode 100644
index 0000000..26e2007
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/breaking_news/BreakingNewsViewModel.kt
@@ -0,0 +1,22 @@
+package com.mina_mikhail.newsapp.features.news.presentation.breaking_news
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.liveData
+import com.mina_mikhail.newsapp.core.network.Resource
+import com.mina_mikhail.newsapp.features.news.data.repository.NewsRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import javax.inject.Inject
+
+@HiltViewModel
+class BreakingNewsViewModel @Inject constructor(private val repository: NewsRepository) : ViewModel() {
+
+ var shouldLoadMore = false
+ var isLoading = false
+ var page: Int = 1
+
+ fun getBreakingNews(country: String) = liveData(Dispatchers.IO) {
+ emit(Resource.Loading)
+ emit(repository.getBreakingNews(country, page))
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/news_details/NewsDetailsFragment.kt b/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/news_details/NewsDetailsFragment.kt
new file mode 100644
index 0000000..de4c461
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/news_details/NewsDetailsFragment.kt
@@ -0,0 +1,56 @@
+package com.mina_mikhail.newsapp.features.news.presentation.news_details
+
+import android.annotation.SuppressLint
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import android.webkit.WebViewClient
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.navArgs
+import com.mina_mikhail.newsapp.R
+import com.mina_mikhail.newsapp.core.view.BaseFragment
+import com.mina_mikhail.newsapp.core.view.extensions.showMessage
+import com.mina_mikhail.newsapp.databinding.FragmentNewsDetailsBinding
+import com.mina_mikhail.newsapp.features.news.domain.entity.model.Article
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class NewsDetailsFragment : BaseFragment() {
+
+ private val viewModel: NewsDetailsViewModel by viewModels()
+
+ private val args: NewsDetailsFragmentArgs by navArgs()
+ private lateinit var article: Article
+
+ override
+ fun getViewBinding(inflater: LayoutInflater, container: ViewGroup?) =
+ FragmentNewsDetailsBinding.inflate(inflater, container, false)
+
+ override
+ fun setUpViews() {
+ getArticleFromArguments()
+
+ setUpWebView()
+ }
+
+ private fun getArticleFromArguments() {
+ article = args.article
+ }
+
+ @SuppressLint("SetJavaScriptEnabled")
+ private fun setUpWebView() {
+ binding.webView.apply {
+ webViewClient = WebViewClient()
+ settings.javaScriptEnabled = true
+
+ loadUrl(article.url)
+ }
+ }
+
+ override
+ fun handleClickListeners() {
+ binding.btnSaveArticle.setOnClickListener {
+ viewModel.saveArticleToLocal(article)
+ showMessage(resources.getString(R.string.article_saved_to_local))
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/news_details/NewsDetailsViewModel.kt b/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/news_details/NewsDetailsViewModel.kt
new file mode 100644
index 0000000..667e6a1
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/news_details/NewsDetailsViewModel.kt
@@ -0,0 +1,18 @@
+package com.mina_mikhail.newsapp.features.news.presentation.news_details
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.mina_mikhail.newsapp.features.news.data.repository.NewsRepository
+import com.mina_mikhail.newsapp.features.news.domain.entity.model.Article
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class NewsDetailsViewModel @Inject constructor(private val repository: NewsRepository) : ViewModel() {
+
+ fun saveArticleToLocal(article: Article) = viewModelScope.launch {
+ repository.saveArticleToLocal(article)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/saved_news/SavedNewsFragment.kt b/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/saved_news/SavedNewsFragment.kt
new file mode 100644
index 0000000..8bde569
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/saved_news/SavedNewsFragment.kt
@@ -0,0 +1,74 @@
+package com.mina_mikhail.newsapp.features.news.presentation.saved_news
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.findNavController
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.mina_mikhail.newsapp.R
+import com.mina_mikhail.newsapp.core.utils.SwipeToDeleteCallback
+import com.mina_mikhail.newsapp.core.view.BaseFragment
+import com.mina_mikhail.newsapp.core.view.extensions.showError
+import com.mina_mikhail.newsapp.databinding.FragmentSavedNewsBinding
+import com.mina_mikhail.newsapp.features.news.domain.entity.model.Article
+import com.mina_mikhail.newsapp.features.news.presentation.NewsAdapter
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class SavedNewsFragment : BaseFragment() {
+
+ private val viewModel: SavedNewsViewModel by viewModels()
+
+ private lateinit var articlesAdapter: NewsAdapter
+
+ override
+ fun getViewBinding(inflater: LayoutInflater, container: ViewGroup?) =
+ FragmentSavedNewsBinding.inflate(inflater, container, false)
+
+ override
+ fun setUpViews() {
+ setUpRecyclerView()
+
+ getSavedNews()
+ }
+
+ private fun setUpRecyclerView() {
+ articlesAdapter = NewsAdapter { onArticleClick(it) }
+ binding.recyclerView.apply {
+ setHasFixedSize(true)
+ layoutManager = LinearLayoutManager(requireContext())
+ adapter = articlesAdapter
+ }
+
+ val itemTouchHelper = ItemTouchHelper(object : SwipeToDeleteCallback(requireContext()) {
+ override
+ fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
+ val article = articlesAdapter.getItemByPosition(viewHolder.adapterPosition)
+ viewModel.deleteArticleFromLocal(article)
+
+ showError(
+ resources.getString(R.string.article_removed),
+ resources.getString(R.string.undo)
+ ) { viewModel.saveArticleToLocal(article) }
+ }
+ })
+ itemTouchHelper.attachToRecyclerView(binding.recyclerView)
+ }
+
+ private fun getSavedNews() {
+ viewModel.getArticlesFromLocal().observe(this, { articlesAdapter.submitList(it) })
+ }
+
+ private fun onArticleClick(article: Article) {
+ val bundle = Bundle().apply {
+ putSerializable("article", article)
+ }
+ findNavController().navigate(
+ R.id.action_open_news_details_fragment,
+ bundle
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/saved_news/SavedNewsViewModel.kt b/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/saved_news/SavedNewsViewModel.kt
new file mode 100644
index 0000000..1c86fcc
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/saved_news/SavedNewsViewModel.kt
@@ -0,0 +1,24 @@
+package com.mina_mikhail.newsapp.features.news.presentation.saved_news
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.mina_mikhail.newsapp.features.news.data.repository.NewsRepository
+import com.mina_mikhail.newsapp.features.news.domain.entity.model.Article
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class SavedNewsViewModel @Inject constructor(private val repository: NewsRepository) : ViewModel() {
+
+ fun getArticlesFromLocal() = repository.getArticlesFromLocal()
+
+ fun deleteArticleFromLocal(article: Article) = viewModelScope.launch {
+ repository.deleteArticleFromLocal(article)
+ }
+
+ fun saveArticleToLocal(article: Article) = viewModelScope.launch {
+ repository.saveArticleToLocal(article)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/search_news/SearchNewsFragment.kt b/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/search_news/SearchNewsFragment.kt
new file mode 100644
index 0000000..24e6d96
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/search_news/SearchNewsFragment.kt
@@ -0,0 +1,262 @@
+package com.mina_mikhail.newsapp.features.news.presentation.search_news
+
+import android.os.Bundle
+import android.view.KeyEvent
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import android.view.inputmethod.EditorInfo
+import android.widget.TextView
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.findNavController
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.mina_mikhail.newsapp.R
+import com.mina_mikhail.newsapp.core.data_source.BaseRemoteDataSource
+import com.mina_mikhail.newsapp.core.network.Resource.Empty
+import com.mina_mikhail.newsapp.core.network.Resource.Failure
+import com.mina_mikhail.newsapp.core.network.Resource.Loading
+import com.mina_mikhail.newsapp.core.network.Resource.Success
+import com.mina_mikhail.newsapp.core.utils.EndlessRecyclerViewScrollListener
+import com.mina_mikhail.newsapp.core.utils.SearchEditTextListener
+import com.mina_mikhail.newsapp.core.view.BaseFragment
+import com.mina_mikhail.newsapp.core.view.extensions.handleApiError
+import com.mina_mikhail.newsapp.core.view.extensions.hide
+import com.mina_mikhail.newsapp.core.view.extensions.hideKeyboard
+import com.mina_mikhail.newsapp.core.view.extensions.show
+import com.mina_mikhail.newsapp.databinding.FragmentSearchNewsBinding
+import com.mina_mikhail.newsapp.features.news.domain.entity.model.Article
+import com.mina_mikhail.newsapp.features.news.presentation.NewsAdapter
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class SearchNewsFragment : BaseFragment() {
+
+ private val viewModel: SearchNewsViewModel by viewModels()
+
+ private lateinit var articlesAdapter: NewsAdapter
+ private lateinit var scrollListener: EndlessRecyclerViewScrollListener
+ private lateinit var searchTextListener: SearchEditTextListener
+
+ override
+ fun getViewBinding(inflater: LayoutInflater, container: ViewGroup?) =
+ FragmentSearchNewsBinding.inflate(inflater, container, false)
+
+ override
+ fun registerListeners() {
+ startSearchListener()
+ }
+
+ override
+ fun unRegisterListeners() {
+ stopSearchListener()
+ }
+
+ override
+ fun setUpViews() {
+ initSearchListener()
+
+ setUpRecyclerView()
+
+ handleSearchArea()
+ }
+
+ private fun initSearchListener() {
+ searchTextListener = SearchEditTextListener(lifecycle) { searchQuery ->
+ searchQuery?.let {
+ if (it.isNotEmpty()) {
+ hideSearchHintView()
+
+ initPagingParameters()
+
+ viewModel.searchQuery = it
+ searchForNews()
+ } else {
+ showSearchHintView()
+ }
+ }
+ }
+ }
+
+ private fun setUpRecyclerView() {
+ articlesAdapter = NewsAdapter { onArticleClick(it) }
+ binding.includedList.recyclerView.apply {
+ setHasFixedSize(true)
+ layoutManager = LinearLayoutManager(requireContext())
+ adapter = articlesAdapter
+
+ initPaging(layoutManager as LinearLayoutManager)
+ }
+ }
+
+ private fun startSearchListener() {
+ binding.etSearch.addTextChangedListener(searchTextListener)
+ }
+
+ private fun stopSearchListener() {
+ binding.etSearch.removeTextChangedListener(searchTextListener)
+ }
+
+ private fun handleSearchArea() {
+ binding.etSearch.setOnEditorActionListener(object : TextView.OnEditorActionListener {
+ override
+ fun onEditorAction(p0: TextView?, actionId: Int, p2: KeyEvent?): Boolean {
+ if (actionId == EditorInfo.IME_ACTION_SEARCH) {
+ hideKeyboard()
+ binding.etSearch.clearFocus()
+ return true
+ }
+
+ return false
+ }
+ })
+ }
+
+ private fun initPaging(layoutManager: LinearLayoutManager) {
+ scrollListener = object : EndlessRecyclerViewScrollListener(3, layoutManager) {
+ override
+ fun onLoadMore(page: Int, totalItemsCount: Int, view: RecyclerView) {
+ if (viewModel.shouldLoadMore) {
+ viewModel.shouldLoadMore = false
+ viewModel.isLoading = true
+
+ searchForNews()
+ }
+ }
+ }
+ binding.includedList.recyclerView.addOnScrollListener(scrollListener)
+ }
+
+ private fun searchForNews() {
+ viewModel.searchForNews().observe(this, {
+
+ when (it) {
+ is Loading -> {
+ if (articlesAdapter.currentList.isNullOrEmpty()) {
+ showDataLoading()
+ } else {
+ showPaginationLoading()
+ }
+ }
+ is Empty -> {
+ if (articlesAdapter.currentList.isNullOrEmpty()) {
+ showNoData()
+ hideKeyboard()
+ } else {
+ hidePaginationLoading()
+ }
+ }
+ is Success -> {
+ if (it.value.articles.size != BaseRemoteDataSource.LIST_PAGE_SIZE) {
+ viewModel.shouldLoadMore = false
+ } else {
+ viewModel.shouldLoadMore = true
+ viewModel.page += 1
+ }
+
+ if (articlesAdapter.currentList.isNullOrEmpty()) {
+ articlesAdapter.submitList(it.value.articles)
+ } else {
+ articlesAdapter.submitList(articlesAdapter.currentList + it.value.articles)
+ }
+
+ showData()
+ }
+ is Failure -> {
+ if (articlesAdapter.currentList.isNullOrEmpty()) {
+ handleApiError(it, noDataAction = { showNoData() }, noInternetAction = { showNoInternet() })
+ } else {
+ handleApiError(it)
+ hidePaginationLoading()
+ }
+ }
+ }
+ })
+ }
+
+ private fun initPagingParameters() {
+ viewModel.searchQuery = ""
+ viewModel.page = 1
+ viewModel.isLoading = true
+ viewModel.shouldLoadMore = false
+
+ articlesAdapter.submitList(null)
+
+ scrollListener.resetState()
+ }
+
+ override
+ fun handleClickListeners() {
+ binding.btnDismissSearch.setOnClickListener {
+ initPagingParameters()
+ hideKeyboard()
+ binding.etSearch.setText("")
+ binding.etSearch.clearFocus()
+ }
+ }
+
+ private fun onArticleClick(article: Article) {
+ hideKeyboard()
+ binding.etSearch.clearFocus()
+
+ val bundle = Bundle().apply {
+ putSerializable("article", article)
+ }
+ findNavController().navigate(
+ R.id.action_open_news_details_fragment,
+ bundle
+ )
+ }
+
+ private fun showSearchHintView() {
+ binding.searchHint.show()
+ binding.listContainer.hide()
+ binding.btnDismissSearch.hide()
+ }
+
+ private fun hideSearchHintView() {
+ binding.listContainer.show()
+ binding.btnDismissSearch.show()
+ binding.searchHint.hide()
+ }
+
+ private fun showDataLoading() {
+ binding.includedList.container.show()
+ binding.includedList.progressBar.show()
+ binding.includedList.emptyViewContainer.hide()
+ binding.includedList.internetErrorViewContainer.hide()
+ binding.includedList.recyclerView.hide()
+ binding.includedList.paginationProgressBar.hide()
+ }
+
+ private fun showPaginationLoading() {
+ binding.includedList.paginationProgressBar.show()
+ }
+
+ private fun hidePaginationLoading() {
+ binding.includedList.paginationProgressBar.hide()
+ }
+
+ private fun showData() {
+ binding.includedList.recyclerView.show()
+ binding.includedList.container.hide()
+ binding.includedList.paginationProgressBar.hide()
+ }
+
+ private fun showNoData() {
+ binding.includedList.container.show()
+ binding.includedList.emptyViewContainer.show()
+ binding.includedList.internetErrorViewContainer.hide()
+ binding.includedList.progressBar.hide()
+ binding.includedList.paginationProgressBar.hide()
+ binding.includedList.recyclerView.hide()
+ }
+
+ private fun showNoInternet() {
+ binding.includedList.container.show()
+ binding.includedList.internetErrorViewContainer.show()
+ binding.includedList.emptyViewContainer.hide()
+ binding.includedList.progressBar.hide()
+ binding.includedList.paginationProgressBar.hide()
+ binding.includedList.recyclerView.hide()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/search_news/SearchNewsViewModel.kt b/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/search_news/SearchNewsViewModel.kt
new file mode 100644
index 0000000..12f1c38
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/features/news/presentation/search_news/SearchNewsViewModel.kt
@@ -0,0 +1,23 @@
+package com.mina_mikhail.newsapp.features.news.presentation.search_news
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.liveData
+import com.mina_mikhail.newsapp.core.network.Resource
+import com.mina_mikhail.newsapp.features.news.data.repository.NewsRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import javax.inject.Inject
+
+@HiltViewModel
+class SearchNewsViewModel @Inject constructor(private val repository: NewsRepository) : ViewModel() {
+
+ var searchQuery: String = ""
+ var shouldLoadMore = false
+ var isLoading = false
+ var page: Int = 1
+
+ fun searchForNews() = liveData(Dispatchers.IO) {
+ emit(Resource.Loading)
+ emit(repository.searchForNews(searchQuery, page))
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mina_mikhail/newsapp/features/splash/presentation/SplashActivity.kt b/app/src/main/java/com/mina_mikhail/newsapp/features/splash/presentation/SplashActivity.kt
new file mode 100644
index 0000000..1651189
--- /dev/null
+++ b/app/src/main/java/com/mina_mikhail/newsapp/features/splash/presentation/SplashActivity.kt
@@ -0,0 +1,27 @@
+package com.mina_mikhail.newsapp.features.splash.presentation
+
+import android.os.Handler
+import android.os.Looper
+import com.mina_mikhail.newsapp.core.view.BaseActivity
+import com.mina_mikhail.newsapp.core.view.extensions.openActivityAndClearStack
+import com.mina_mikhail.newsapp.databinding.ActivitySplashBinding
+import com.mina_mikhail.newsapp.features.news.presentation.NewsActivity
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class SplashActivity : BaseActivity() {
+
+ override
+ fun getViewBinding() = ActivitySplashBinding.inflate(layoutInflater)
+
+ override
+ fun setUpViews() {
+ decideNavigationLogic()
+ }
+
+ private fun decideNavigationLogic() {
+ Handler(Looper.getMainLooper()).postDelayed({
+ openActivityAndClearStack(NewsActivity::class.java)
+ }, 2000)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/anim/anim_list_fall_down.xml b/app/src/main/res/anim/anim_list_fall_down.xml
new file mode 100644
index 0000000..b6a1a5f
--- /dev/null
+++ b/app/src/main/res/anim/anim_list_fall_down.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/anim/anim_list_slide_in.xml b/app/src/main/res/anim/anim_list_slide_in.xml
new file mode 100644
index 0000000..2c408af
--- /dev/null
+++ b/app/src/main/res/anim/anim_list_slide_in.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/anim/anim_slide_in_left.xml b/app/src/main/res/anim/anim_slide_in_left.xml
new file mode 100644
index 0000000..1b86af1
--- /dev/null
+++ b/app/src/main/res/anim/anim_slide_in_left.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/anim/anim_slide_in_right.xml b/app/src/main/res/anim/anim_slide_in_right.xml
new file mode 100644
index 0000000..ebc5408
--- /dev/null
+++ b/app/src/main/res/anim/anim_slide_in_right.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/anim/anim_slide_out_left.xml b/app/src/main/res/anim/anim_slide_out_left.xml
new file mode 100644
index 0000000..ef4baad
--- /dev/null
+++ b/app/src/main/res/anim/anim_slide_out_left.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/anim/anim_slide_out_right.xml b/app/src/main/res/anim/anim_slide_out_right.xml
new file mode 100644
index 0000000..83320d2
--- /dev/null
+++ b/app/src/main/res/anim/anim_slide_out_right.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/anim/layout_list_fall_down_animation.xml b/app/src/main/res/anim/layout_list_fall_down_animation.xml
new file mode 100644
index 0000000..3fdf19a
--- /dev/null
+++ b/app/src/main/res/anim/layout_list_fall_down_animation.xml
@@ -0,0 +1,5 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/anim/layout_list_slide_in_animation.xml b/app/src/main/res/anim/layout_list_slide_in_animation.xml
new file mode 100644
index 0000000..f2458e9
--- /dev/null
+++ b/app/src/main/res/anim/layout_list_slide_in_animation.xml
@@ -0,0 +1,5 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/bg_gray.xml b/app/src/main/res/drawable/bg_gray.xml
new file mode 100644
index 0000000..41a717f
--- /dev/null
+++ b/app/src/main/res/drawable/bg_gray.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/bg_no_data.xml b/app/src/main/res/drawable/bg_no_data.xml
new file mode 100644
index 0000000..d8c5fab
--- /dev/null
+++ b/app/src/main/res/drawable/bg_no_data.xml
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_no_image.png b/app/src/main/res/drawable/bg_no_image.png
new file mode 100644
index 0000000..8ce2cf5
Binary files /dev/null and b/app/src/main/res/drawable/bg_no_image.png differ
diff --git a/app/src/main/res/drawable/bg_no_internet.xml b/app/src/main/res/drawable/bg_no_internet.xml
new file mode 100644
index 0000000..27a9c9a
--- /dev/null
+++ b/app/src/main/res/drawable/bg_no_internet.xml
@@ -0,0 +1,185 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_rounded_white.xml b/app/src/main/res/drawable/bg_rounded_white.xml
new file mode 100644
index 0000000..94d0468
--- /dev/null
+++ b/app/src/main/res/drawable/bg_rounded_white.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/bg_toast.xml b/app/src/main/res/drawable/bg_toast.xml
new file mode 100644
index 0000000..075605c
--- /dev/null
+++ b/app/src/main/res/drawable/bg_toast.xml
@@ -0,0 +1,30 @@
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/btn_white_circle.xml b/app/src/main/res/drawable/btn_white_circle.xml
new file mode 100644
index 0000000..94b17af
--- /dev/null
+++ b/app/src/main/res/drawable/btn_white_circle.xml
@@ -0,0 +1,13 @@
+
+
+ -
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_breaking_news.xml b/app/src/main/res/drawable/ic_breaking_news.xml
new file mode 100644
index 0000000..3a08439
--- /dev/null
+++ b/app/src/main/res/drawable/ic_breaking_news.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_breaking_news_selected.xml b/app/src/main/res/drawable/ic_breaking_news_selected.xml
new file mode 100644
index 0000000..2a3cd73
--- /dev/null
+++ b/app/src/main/res/drawable/ic_breaking_news_selected.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_dismiss.xml b/app/src/main/res/drawable/ic_dismiss.xml
new file mode 100644
index 0000000..6e9e6d7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_dismiss.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..d6b7494
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_no_internet.xml b/app/src/main/res/drawable/ic_no_internet.xml
new file mode 100644
index 0000000..50aaf80
--- /dev/null
+++ b/app/src/main/res/drawable/ic_no_internet.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_saved_news.xml b/app/src/main/res/drawable/ic_saved_news.xml
new file mode 100644
index 0000000..e9ab299
--- /dev/null
+++ b/app/src/main/res/drawable/ic_saved_news.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_saved_news_selected.xml b/app/src/main/res/drawable/ic_saved_news_selected.xml
new file mode 100644
index 0000000..d48af7d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_saved_news_selected.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml
new file mode 100644
index 0000000..f20a83e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_search.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_search_selected.xml b/app/src/main/res/drawable/ic_search_selected.xml
new file mode 100644
index 0000000..d5d44a2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_search_selected.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/nav_breaking_news_selector.xml b/app/src/main/res/drawable/nav_breaking_news_selector.xml
new file mode 100644
index 0000000..7e3384d
--- /dev/null
+++ b/app/src/main/res/drawable/nav_breaking_news_selector.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/nav_saved_news_selector.xml b/app/src/main/res/drawable/nav_saved_news_selector.xml
new file mode 100644
index 0000000..3cdd49d
--- /dev/null
+++ b/app/src/main/res/drawable/nav_saved_news_selector.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/nav_search_news_selector.xml b/app/src/main/res/drawable/nav_search_news_selector.xml
new file mode 100644
index 0000000..faf5478
--- /dev/null
+++ b/app/src/main/res/drawable/nav_search_news_selector.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/font/rubik_bold.ttf b/app/src/main/res/font/rubik_bold.ttf
new file mode 100644
index 0000000..4e77930
Binary files /dev/null and b/app/src/main/res/font/rubik_bold.ttf differ
diff --git a/app/src/main/res/font/rubik_medium.ttf b/app/src/main/res/font/rubik_medium.ttf
new file mode 100644
index 0000000..9e358b2
Binary files /dev/null and b/app/src/main/res/font/rubik_medium.ttf differ
diff --git a/app/src/main/res/font/rubik_regular.ttf b/app/src/main/res/font/rubik_regular.ttf
new file mode 100644
index 0000000..52b59ca
Binary files /dev/null and b/app/src/main/res/font/rubik_regular.ttf differ
diff --git a/app/src/main/res/layout/activity_news.xml b/app/src/main/res/layout/activity_news.xml
new file mode 100644
index 0000000..036f4fe
--- /dev/null
+++ b/app/src/main/res/layout/activity_news.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_splash.xml b/app/src/main/res/layout/activity_splash.xml
new file mode 100644
index 0000000..ce36ca2
--- /dev/null
+++ b/app/src/main/res/layout/activity_splash.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_breaking_news.xml b/app/src/main/res/layout/fragment_breaking_news.xml
new file mode 100644
index 0000000..b60dbd1
--- /dev/null
+++ b/app/src/main/res/layout/fragment_breaking_news.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_news_details.xml b/app/src/main/res/layout/fragment_news_details.xml
new file mode 100644
index 0000000..b510dab
--- /dev/null
+++ b/app/src/main/res/layout/fragment_news_details.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_saved_news.xml b/app/src/main/res/layout/fragment_saved_news.xml
new file mode 100644
index 0000000..a2d5330
--- /dev/null
+++ b/app/src/main/res/layout/fragment_saved_news.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_search_news.xml b/app/src/main/res/layout/fragment_search_news.xml
new file mode 100644
index 0000000..1dd80a1
--- /dev/null
+++ b/app/src/main/res/layout/fragment_search_news.xml
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_news.xml b/app/src/main/res/layout/item_news.xml
new file mode 100644
index 0000000..4f3cdc6
--- /dev/null
+++ b/app/src/main/res/layout/item_news.xml
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/list_general.xml b/app/src/main/res/layout/list_general.xml
new file mode 100644
index 0000000..3ed2453
--- /dev/null
+++ b/app/src/main/res/layout/list_general.xml
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/progress_dialog.xml b/app/src/main/res/layout/progress_dialog.xml
new file mode 100644
index 0000000..67f79fb
--- /dev/null
+++ b/app/src/main/res/layout/progress_dialog.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/toast.xml b/app/src/main/res/layout/toast.xml
new file mode 100644
index 0000000..2672c1e
--- /dev/null
+++ b/app/src/main/res/layout/toast.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/menu_bottom_navigation.xml b/app/src/main/res/menu/menu_bottom_navigation.xml
new file mode 100644
index 0000000..ce5a4a0
--- /dev/null
+++ b/app/src/main/res/menu/menu_bottom_navigation.xml
@@ -0,0 +1,19 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..e25a360
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..e25a360
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..94f2433
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..e0cdd22
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..e4990e5
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..2b3ce81
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..dfea1c8
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..7b8d3c4
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..e4e7468
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..7055276
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..540e406
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..5054f90
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/navigation/nav_breaking_news.xml b/app/src/main/res/navigation/nav_breaking_news.xml
new file mode 100644
index 0000000..ec21179
--- /dev/null
+++ b/app/src/main/res/navigation/nav_breaking_news.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/navigation/nav_saved_news.xml b/app/src/main/res/navigation/nav_saved_news.xml
new file mode 100644
index 0000000..71cb3dc
--- /dev/null
+++ b/app/src/main/res/navigation/nav_saved_news.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/navigation/nav_search_news.xml b/app/src/main/res/navigation/nav_search_news.xml
new file mode 100644
index 0000000..b0aff2a
--- /dev/null
+++ b/app/src/main/res/navigation/nav_search_news.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..8e72c0c
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,16 @@
+
+
+ #0088ff
+ #136ef1
+ #e50027
+ #8A031A
+ #FF000000
+ #373737
+ #CECECE
+ #FFFFFFFF
+ #00FFFFFF
+ #00ACEE
+ #FF0000
+ #F5F6F8
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
new file mode 100644
index 0000000..3ecc72f
--- /dev/null
+++ b/app/src/main/res/values/dimens.xml
@@ -0,0 +1,61 @@
+
+ 16dp
+
+ 1dp
+ 2dp
+ -2dp
+ 3dp
+ 4dp
+ 5dp
+ 6dp
+ 7dp
+ 8dp
+ 9dp
+ 10dp
+ 12dp
+ 13dp
+ 14dp
+ 16dp
+ 18dp
+ 20dp
+ 21dp
+ 24dp
+ 25dp
+ 25dp
+ 28dp
+ 30dp
+ 32dp
+ 34dp
+ 36dp
+ 40dp
+ 45dp
+ 46dp
+ 50dp
+ 56dp
+ 60dp
+ 65dp
+ 70dp
+ 80dp
+ 90dp
+ 100dp
+ 120dp
+ 130dp
+ 150dp
+ 180dp
+ 200dp
+ 220dp
+ 250dp
+ 300dp
+ 350dp
+
+ 12sp
+ 13sp
+ 14sp
+ 15sp
+ 16sp
+ 18sp
+ 20sp
+ 22sp
+ 28sp
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..16cdd5a
--- /dev/null
+++ b/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #FFFFFF
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..ab8bb11
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,29 @@
+
+ News App
+
+
+ Hide
+ Connection Error
+ No internet connection please try again later
+ Retry
+ Undo
+ Some error occurred, try again later
+ Sorry, There are no results here!
+
+
+ Breaking News
+ Saved News
+ Search News
+ News Details
+
+
+ Search hereā¦
+ Search for any news you prefer here
+
+
+ Article saved to local successfully
+
+
+ Article removed from your saved list
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..95c00ef
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..21918d1
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,60 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+buildscript {
+ ext {
+ compile_sdk_version = 30
+ min_sdk_version = 21
+
+ version_code = 1
+ version_name = '1'
+
+ // Support
+ appcompat = '1.3.1'
+ core_ktx = '1.6.0'
+ support_version = '1.0.0'
+
+ // Arch Components
+ lifecycle_version = '2.4.0-alpha03'
+
+ // Kotlin Coroutines
+ kotlin_coroutines = '1.4.1'
+
+ // Room
+ room_version = '2.3.0'
+
+ // Networking
+ retrofit = '2.9.0'
+ gson = '2.8.6'
+ interceptor = '4.8.1'
+ pluto = '1.0.2-beta'
+
+ // UI
+ material_design = '1.4.0'
+ android_navigation = '2.3.5'
+ loading_animations = '1.4.0'
+ alerter = '7.0.1'
+ coil = '1.3.2'
+
+ // Utils
+ datastore_preferences = '1.0.0'
+
+ // Hilt
+ hilt_version = '2.38.1'
+ }
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath "com.android.tools.build:gradle:7.0.0"
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.21"
+ classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$android_navigation"
+ classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
+
+ // NOTE: Do not place your application dependencies here; they belong
+ // in the individual module build.gradle files
+ }
+}
+
+task clean(type: Delete) {
+ delete rootProject.buildDir
+}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..98bed16
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,21 @@
+# 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=-Xmx2048m -Dfile.encoding=UTF-8
+# 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
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app"s APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Automatically convert third-party libraries to use AndroidX
+android.enableJetifier=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..e708b1c
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..6974aba
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Sat Aug 14 00:33:07 EET 2021
+distributionBase=GRADLE_USER_HOME
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip
+distributionPath=wrapper/dists
+zipStorePath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..4f906e0
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## 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='"-Xmx64m" "-Xms64m"'
+
+# 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 or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; 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=`expr $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"
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..107acd3
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@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 Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@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="-Xmx64m" "-Xms64m"
+
+@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 execute
+
+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 execute
+
+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
+
+: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 %*
+
+: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
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..60591d5
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,10 @@
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ jcenter() // Warning: this repository is going to shut down soon
+ }
+}
+rootProject.name = "News-App"
+include ':app'