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'