diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..aa724b7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..26d3352
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 0000000..b589d56
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
new file mode 100644
index 0000000..ae388c2
--- /dev/null
+++ b/.idea/gradle.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..103e00c
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
new file mode 100644
index 0000000..f8467b4
--- /dev/null
+++ b/.idea/kotlinc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..8978d23
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..66449ab
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,139 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("com.google.devtools.ksp")
+ id("dagger.hilt.android.plugin")
+}
+
+android {
+
+ namespace = "sample.latest.news"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "sample.latest.news"
+ minSdk = 24
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ multiDexEnabled = true
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ debug {
+ applicationIdSuffix = ".debug"
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+
+ buildFeatures {
+ buildConfig = true
+ compose = true
+ }
+
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.3"
+ }
+
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+
+ // Compose
+ implementation(platform("androidx.compose:compose-bom:2023.10.01"))
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-graphics")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.material:material")
+ implementation("androidx.compose.foundation:foundation")
+ implementation("androidx.compose.ui:ui-util")
+
+ implementation("androidx.activity:activity-compose:1.8.1")
+ implementation("androidx.navigation:navigation-compose:2.7.5")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2")
+
+ // Hilt
+ implementation("com.google.dagger:hilt-android:2.48.1")
+ ksp("com.google.dagger:hilt-android-compiler:2.48.1")
+ implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
+
+ // Architecture Components
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
+
+ // Retrofit
+ val retrofit = "2.9.0"
+ implementation("com.squareup.retrofit2:retrofit:$retrofit")
+ implementation("com.squareup.retrofit2:converter-moshi:$retrofit")
+
+ // Okhttp
+ implementation(platform("com.squareup.okhttp3:okhttp-bom:5.0.0-alpha.11"))
+ implementation("com.squareup.okhttp3:okhttp")
+ implementation("com.squareup.okhttp3:logging-interceptor")
+
+ // Moshi Kotlin
+ val moshi = "1.15.0"
+ implementation("com.squareup.moshi:moshi-kotlin:$moshi")
+ ksp("com.squareup.moshi:moshi-kotlin-codegen:$moshi")
+
+ // Coroutine
+ val coroutines = "1.7.3"
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines")
+
+ // Coil
+ implementation("io.coil-kt:coil-compose:2.5.0")
+
+ // Room
+ val room = "2.6.0"
+ implementation("androidx.room:room-runtime:$room")
+ ksp("androidx.room:room-compiler:$room")
+ implementation("androidx.room:room-ktx:$room")
+
+ // Preferences DataStore
+ implementation("androidx.datastore:datastore-preferences:1.0.0")
+
+ // KTX
+ implementation("androidx.core:core-ktx:1.12.0")
+
+ // Multidex
+ implementation("androidx.multidex:multidex:2.0.1")
+
+ // Splash
+ implementation("androidx.core:core-splashscreen:1.0.1")
+
+ // Test
+ testImplementation("junit:junit:4.13.2")
+ androidTestImplementation("androidx.test.ext:junit:1.1.5")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
+ androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01"))
+ androidTestImplementation("androidx.compose.ui:ui-test-junit4")
+ debugImplementation("androidx.compose.ui:ui-tooling")
+ debugImplementation("androidx.compose.ui:ui-test-manifest")
+}
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/androidTest/java/sample/latest/news/ExampleInstrumentedTest.kt b/app/src/androidTest/java/sample/latest/news/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..144a4fe
--- /dev/null
+++ b/app/src/androidTest/java/sample/latest/news/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package sample.latest.news
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("sample.latest.news", appContext.packageName)
+ }
+}
\ 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..31edf38
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/sample/latest/news/core/api/ApiResult.kt b/app/src/main/java/sample/latest/news/core/api/ApiResult.kt
new file mode 100644
index 0000000..c6296f1
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/api/ApiResult.kt
@@ -0,0 +1,8 @@
+package sample.latest.news.core.api
+
+sealed class ApiResult {
+
+ data class Success(val data: T) : ApiResult()
+
+ data class Error(val exception: Exceptions, val data: T? = null) : ApiResult()
+}
diff --git a/app/src/main/java/sample/latest/news/core/api/ApiUtils.kt b/app/src/main/java/sample/latest/news/core/api/ApiUtils.kt
new file mode 100644
index 0000000..48ab006
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/api/ApiUtils.kt
@@ -0,0 +1,37 @@
+package sample.latest.news.core.api
+
+import android.util.Log
+import org.json.JSONObject
+import sample.latest.news.core.model.AppError
+
+/**
+ * Wrap a suspending API [call] in try/catch. In case an exception is thrown, a [ApiResult.Error] is
+ * created based on the [errorMessage].
+ */
+suspend fun safeApiCall(
+ call: suspend () -> ApiResult,
+ errorMessage: String
+): ApiResult {
+ return try {
+ call()
+ } catch (e: Exception) {
+ // An exception was thrown when calling the API so we're converting this to an IOException
+ Log.d("IOException", e.message.toString())
+ ApiResult.Error(Exceptions.IOException(errorMessage, e))
+ }
+}
+
+fun errorParser(errorBody: String?): AppError {
+
+ return if (errorBody != null) {
+
+ val errorBodyObject = JSONObject(errorBody)
+
+ val message = errorBodyObject.getString("message").toString()
+
+ AppError(message = message)
+
+ } else {
+ AppError()
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/core/api/BaseRemoteDataSource.kt b/app/src/main/java/sample/latest/news/core/api/BaseRemoteDataSource.kt
new file mode 100644
index 0000000..431a34d
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/api/BaseRemoteDataSource.kt
@@ -0,0 +1,21 @@
+package sample.latest.news.core.api
+
+import retrofit2.Response
+
+open class BaseRemoteDataSource {
+
+ protected fun checkApiResult(response: Response): ApiResult {
+ if (response.isSuccessful) {
+ val body = response.body()
+ if (body != null)
+ return ApiResult.Success(body)
+ }
+ val error = errorParser(response.errorBody()?.string())
+ return ApiResult.Error(
+ Exceptions.RemoteDataSourceException(
+ error.message,
+ response.code()
+ )
+ )
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/core/api/DefaultIfNullFactory.kt b/app/src/main/java/sample/latest/news/core/api/DefaultIfNullFactory.kt
new file mode 100644
index 0000000..d9c420e
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/api/DefaultIfNullFactory.kt
@@ -0,0 +1,26 @@
+package sample.latest.news.core.api
+
+import com.squareup.moshi.JsonAdapter
+import com.squareup.moshi.JsonReader
+import com.squareup.moshi.JsonWriter
+import com.squareup.moshi.Moshi
+import java.lang.reflect.Type
+
+class DefaultIfNullFactory : JsonAdapter.Factory {
+ override fun create(type: Type, annotations: MutableSet, moshi: Moshi): JsonAdapter<*> {
+ val delegate = moshi.nextAdapter(this, type, annotations)
+ return object : JsonAdapter() {
+ override fun fromJson(reader: JsonReader): Any? {
+ val blob = reader.readJsonValue()
+ if (blob is Map<*, *>) {
+ val noNulls = blob.filterValues { it != null }
+ return delegate.fromJsonValue(noNulls)
+ }
+ return delegate.fromJsonValue(blob)
+ }
+ override fun toJson(writer: JsonWriter, value: Any?) {
+ return delegate.toJson(writer, value)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/core/api/ExceptionHelper.kt b/app/src/main/java/sample/latest/news/core/api/ExceptionHelper.kt
new file mode 100644
index 0000000..f8cbded
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/api/ExceptionHelper.kt
@@ -0,0 +1,45 @@
+package sample.latest.news.core.api
+
+import sample.latest.news.R
+
+object ExceptionHelper {
+
+ fun getError(exception: Exceptions): ErrorView {
+
+ val serverErrorMessage: String?
+ val message: Int
+
+ when (exception) {
+ is Exceptions.IOException -> {
+ serverErrorMessage = null
+ message = R.string.error_server
+ }
+ is Exceptions.NetworkConnectionException -> {
+ serverErrorMessage = null
+ message = R.string.error_connection
+ }
+ is Exceptions.LocalDataSourceException -> {
+ serverErrorMessage = null
+ message = R.string.error_general
+ }
+ is Exceptions.RemoteDataSourceException -> {
+ serverErrorMessage =
+ if (exception.message.isNullOrEmpty()) null else exception.message
+ message = R.string.error_server
+ }
+ else -> {
+ serverErrorMessage = null
+ message = R.string.error_general
+ }
+ }
+ return ErrorView(
+ serverErrorMessage = serverErrorMessage,
+ message = message
+ )
+ }
+
+ data class ErrorView(
+ val serverErrorMessage: String?,
+ val message: Int
+ )
+}
diff --git a/app/src/main/java/sample/latest/news/core/api/Exceptions.kt b/app/src/main/java/sample/latest/news/core/api/Exceptions.kt
new file mode 100644
index 0000000..6393cb6
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/api/Exceptions.kt
@@ -0,0 +1,25 @@
+package sample.latest.news.core.api
+
+sealed class Exceptions {
+
+ data class IOException(val message: String = "IO Exception", val cause: Throwable) :
+ Exceptions()
+
+ data class NetworkConnectionException(val message: String = "Network Connection Error") :
+ Exceptions()
+
+ data class RemoteDataSourceException(
+ val message: String?,
+ val responseCode: Int
+ ) : Exceptions()
+
+ data class LocalDataSourceException(
+ val message: String = "Local Data Source Error",
+ val cause: Throwable? = null
+ ) :
+ Exceptions()
+
+ data class InputDataException(val errors: List) : Exceptions()
+
+ data class ValidationException(val errors: T) : Exceptions()
+}
diff --git a/app/src/main/java/sample/latest/news/core/api/TLSSocketFactory.kt b/app/src/main/java/sample/latest/news/core/api/TLSSocketFactory.kt
new file mode 100644
index 0000000..1a14f6d
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/api/TLSSocketFactory.kt
@@ -0,0 +1,92 @@
+package sample.latest.news.core.api
+
+import java.io.IOException
+import java.net.InetAddress
+import java.net.Socket
+import java.net.UnknownHostException
+import java.security.KeyManagementException
+import java.security.KeyStore
+import java.security.NoSuchAlgorithmException
+import java.util.*
+import javax.net.ssl.*
+
+class TLSSocketFactory @Throws(KeyManagementException::class, NoSuchAlgorithmException::class)
+constructor() : SSLSocketFactory() {
+
+ private val delegate: SSLSocketFactory
+ var trustManager: X509TrustManager
+
+ init {
+ val trustManagerFactory =
+ TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
+ trustManagerFactory.init(null as KeyStore?)
+ val trustManagers = trustManagerFactory.trustManagers
+ if (trustManagers.size != 1 || trustManagers[0] !is X509TrustManager) {
+ throw IllegalStateException(
+ "Unexpected default trust managers:" + Arrays.toString(
+ trustManagers
+ )
+ )
+ }
+ trustManager = trustManagers[0] as X509TrustManager
+
+ val context = SSLContext.getInstance("TLS")
+ context.init(null, arrayOf(trustManager), null)
+ delegate = context.socketFactory
+ }
+
+ override fun getDefaultCipherSuites(): Array {
+ return delegate.defaultCipherSuites
+ }
+
+ override fun getSupportedCipherSuites(): Array {
+ return delegate.supportedCipherSuites
+ }
+
+ @Throws(IOException::class)
+ override fun createSocket(): Socket? {
+ return enableTLSOnSocket(delegate.createSocket())
+ }
+
+ @Throws(IOException::class)
+ override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket? {
+ return enableTLSOnSocket(delegate.createSocket(s, host, port, autoClose))
+ }
+
+ @Throws(IOException::class, UnknownHostException::class)
+ override fun createSocket(host: String, port: Int): Socket? {
+ return enableTLSOnSocket(delegate.createSocket(host, port))
+ }
+
+ @Throws(IOException::class, UnknownHostException::class)
+ override fun createSocket(
+ host: String,
+ port: Int,
+ localHost: InetAddress,
+ localPort: Int
+ ): Socket? {
+ return enableTLSOnSocket(delegate.createSocket(host, port, localHost, localPort))
+ }
+
+ @Throws(IOException::class)
+ override fun createSocket(host: InetAddress, port: Int): Socket? {
+ return enableTLSOnSocket(delegate.createSocket(host, port))
+ }
+
+ @Throws(IOException::class)
+ override fun createSocket(
+ address: InetAddress,
+ port: Int,
+ localAddress: InetAddress,
+ localPort: Int
+ ): Socket? {
+ return enableTLSOnSocket(delegate.createSocket(address, port, localAddress, localPort))
+ }
+
+ private fun enableTLSOnSocket(socket: Socket?): Socket? {
+ if (socket != null && socket is SSLSocket) {
+ socket.enabledProtocols = arrayOf("TLSv1.1", "TLSv1.2")
+ }
+ return socket
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/core/db/AppDb.kt b/app/src/main/java/sample/latest/news/core/db/AppDb.kt
new file mode 100644
index 0000000..107cf22
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/db/AppDb.kt
@@ -0,0 +1,18 @@
+package sample.latest.news.core.db
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import sample.latest.news.features.movie.data.MovieDao
+import sample.latest.news.features.movie.data.entity.MovieEntity
+
+@Database(
+ entities = [
+ MovieEntity::class
+ ],
+ version = 1,
+ exportSchema = false
+)
+abstract class AppDb : RoomDatabase() {
+
+ abstract fun movieDao(): MovieDao
+}
diff --git a/app/src/main/java/sample/latest/news/core/di/AppModule.kt b/app/src/main/java/sample/latest/news/core/di/AppModule.kt
new file mode 100644
index 0000000..88d435b
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/di/AppModule.kt
@@ -0,0 +1,102 @@
+package sample.latest.news.core.di
+
+import android.content.Context
+import androidx.room.Room
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.*
+import okhttp3.ConnectionPool
+import okhttp3.Dispatcher
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.moshi.MoshiConverterFactory
+import sample.latest.news.BuildConfig
+import sample.latest.news.core.api.DefaultIfNullFactory
+import sample.latest.news.core.api.TLSSocketFactory
+import sample.latest.news.core.db.AppDb
+import sample.latest.news.core.preferences.PreferencesDataStore
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AppModule {
+
+ @Singleton
+ @Provides
+ fun providePreferencesDataStore(
+ @ApplicationContext context: Context
+ ): PreferencesDataStore = PreferencesDataStore(context)
+
+ @Singleton
+ @Provides
+ fun provideMoshi(): Moshi = Moshi.Builder()
+ .add(DefaultIfNullFactory())
+ .addLast(KotlinJsonAdapterFactory())
+ .build()
+
+ @Singleton
+ @Provides
+ fun provideLoggingInterceptor(): HttpLoggingInterceptor {
+ return HttpLoggingInterceptor().apply {
+ level = if (BuildConfig.DEBUG) {
+ HttpLoggingInterceptor.Level.BODY
+ } else {
+ HttpLoggingInterceptor.Level.NONE
+ }
+ }
+ }
+
+ @Singleton
+ @Provides
+ fun provideOkHttpClient(
+ httpLoggingInterceptor: HttpLoggingInterceptor
+ ) = OkHttpClient.Builder()
+ .dispatcher(Dispatcher(Executors.newFixedThreadPool(20)).apply {
+ maxRequests = 20
+ maxRequestsPerHost = 20
+ })
+ .connectionPool(ConnectionPool(100, 30L, TimeUnit.SECONDS))
+ .addInterceptor(httpLoggingInterceptor)
+ .readTimeout(30L, TimeUnit.SECONDS)
+ .writeTimeout(30L, TimeUnit.SECONDS)
+ .connectTimeout(30L, TimeUnit.SECONDS)
+ .sslSocketFactory(
+ TLSSocketFactory(),
+ TLSSocketFactory().trustManager
+ )
+ .build()
+
+ @Singleton
+ @Provides
+ fun provideRetrofit(
+ okHttpClient: OkHttpClient,
+ moshi: Moshi
+ ): Retrofit = Retrofit.Builder()
+ .addConverterFactory(
+ MoshiConverterFactory.create(moshi)
+ )
+ .client(okHttpClient)
+ .baseUrl("https://api.salamcinama.ir/")
+ .build()
+
+ @Singleton
+ @Provides
+ fun provideDb(@ApplicationContext context: Context): AppDb {
+ return Room
+ .databaseBuilder(
+ context,
+ AppDb::class.java,
+ BuildConfig.APPLICATION_ID + ".db"
+ )
+ .fallbackToDestructiveMigration()
+ .build()
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/core/model/AppError.kt b/app/src/main/java/sample/latest/news/core/model/AppError.kt
new file mode 100644
index 0000000..5f4b7a3
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/model/AppError.kt
@@ -0,0 +1,8 @@
+package sample.latest.news.core.model
+
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class AppError(
+ val message: String = ""
+)
diff --git a/app/src/main/java/sample/latest/news/core/model/NetworkViewState.kt b/app/src/main/java/sample/latest/news/core/model/NetworkViewState.kt
new file mode 100644
index 0000000..826dd7d
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/model/NetworkViewState.kt
@@ -0,0 +1,16 @@
+package sample.latest.news.core.model
+
+import sample.latest.news.R
+
+data class NetworkViewState(
+ var showProgress: Boolean = false,
+ var showProgressMore: Boolean = false,
+ var showSuccess: Boolean = false,
+ var showError: Boolean = false,
+ var showValidationError: Boolean = false,
+ var serverErrorMessage: String? = null,
+ var errorMessage: Int = R.string.error_general,
+ var requestTag: String? = null,
+ var validationError: Any? = null,
+ var data: Any? = null
+)
diff --git a/app/src/main/java/sample/latest/news/core/preferences/PreferencesDataStore.kt b/app/src/main/java/sample/latest/news/core/preferences/PreferencesDataStore.kt
new file mode 100644
index 0000000..fc28915
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/preferences/PreferencesDataStore.kt
@@ -0,0 +1,43 @@
+package sample.latest.news.core.preferences
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.emptyPreferences
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.map
+import sample.latest.news.BuildConfig
+import sample.latest.news.features.theme.ui.ThemeType
+import java.io.IOException
+
+class PreferencesDataStore constructor(
+ private val context: Context
+) {
+ private object PreferencesKeys {
+ val keyTheme = intPreferencesKey("key_theme")
+ }
+
+ private val Context.dataStore: DataStore by preferencesDataStore(
+ name = BuildConfig.APPLICATION_ID + ".preferences.data.store"
+ )
+
+ val getTheme: Flow = context.dataStore.data
+ .catch { exception ->
+ if (exception is IOException)
+ emit(emptyPreferences())
+ else throw exception
+ }
+ .map { preferences ->
+ preferences[PreferencesKeys.keyTheme] ?: ThemeType.SystemDefault.value
+ }
+
+ suspend fun updateTheme(value: Int) {
+ context.dataStore.edit { preferences ->
+ preferences[PreferencesKeys.keyTheme] = value
+ }
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/core/preferences/domain/DoUpdateThemePrefs.kt b/app/src/main/java/sample/latest/news/core/preferences/domain/DoUpdateThemePrefs.kt
new file mode 100644
index 0000000..9a7c45d
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/preferences/domain/DoUpdateThemePrefs.kt
@@ -0,0 +1,10 @@
+package sample.latest.news.core.preferences.domain
+
+import sample.latest.news.core.preferences.PreferencesDataStore
+import javax.inject.Inject
+
+class DoUpdateThemePrefs @Inject constructor(
+ private val preferencesDataStore: PreferencesDataStore
+) {
+ suspend operator fun invoke(value: Int) = preferencesDataStore.updateTheme(value)
+}
diff --git a/app/src/main/java/sample/latest/news/core/preferences/domain/GetThemePrefs.kt b/app/src/main/java/sample/latest/news/core/preferences/domain/GetThemePrefs.kt
new file mode 100644
index 0000000..b99f443
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/preferences/domain/GetThemePrefs.kt
@@ -0,0 +1,10 @@
+package sample.latest.news.core.preferences.domain
+
+import sample.latest.news.core.preferences.PreferencesDataStore
+import javax.inject.Inject
+
+class GetThemePrefs @Inject constructor(
+ private val preferencesDataStore: PreferencesDataStore
+) {
+ operator fun invoke() = preferencesDataStore.getTheme
+}
diff --git a/app/src/main/java/sample/latest/news/core/theme/Color.kt b/app/src/main/java/sample/latest/news/core/theme/Color.kt
new file mode 100644
index 0000000..605dafb
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/theme/Color.kt
@@ -0,0 +1,37 @@
+package sample.latest.news.core.theme
+
+import androidx.compose.ui.graphics.Color
+
+val md_theme_dark_primary = Color(0xFF36B566)
+val md_theme_dark_onPrimary = Color(0xFFFFFFFF)
+val md_theme_dark_background = Color(0xFF1C1C1C)
+val md_theme_dark_onBackground = Color(0xFFFFFFFF)
+val md_theme_dark_surface = Color(0xFFFFFFFF).copy(alpha = 0.05f)
+val md_theme_dark_onSurface = Color(0xFFFFFFFF)
+val md_theme_dark_error = Color(0xFFFF4E4E)
+val md_theme_dark_onError = Color(0xFFFFFFFF)
+val md_theme_dark_warning = Color(0xFFFF9432)
+val md_theme_dark_onWarning = Color(0xFFFFFFFF)
+val md_theme_dark_success = Color(0xFF01C141)
+val md_theme_dark_onSuccess = Color(0xFFFFFFFF)
+val md_theme_dark_disable = Color(0xFFFFFFFF).copy(alpha = 0.08f)
+val md_theme_dark_onDisable = Color(0xFFFFFFFF).copy(alpha = 0.24f)
+val md_theme_dark_divider = Color(0xFFFFFFFF).copy(alpha = 0.1f)
+val md_theme_dark_scrim = Color(0xFF000000).copy(alpha = 0.9f)
+
+val md_theme_light_primary = Color(0xFF36B566)
+val md_theme_light_onPrimary = Color(0xFFFFFFFF)
+val md_theme_light_background = Color(0xFFFEFEFE)
+val md_theme_light_onBackground = Color(0xFF000000)
+val md_theme_light_surface = Color(0xFF000000).copy(alpha = 0.05f)
+val md_theme_light_onSurface = Color(0xFF000000)
+val md_theme_light_error = Color(0xFF8E0101)
+val md_theme_light_onError = Color(0xFFFFFFFF)
+val md_theme_light_warning = Color(0xFFFF9432)
+val md_theme_light_onWarning = Color(0xFFFFFFFF)
+val md_theme_light_success = Color(0xFF01C141)
+val md_theme_light_onSuccess = Color(0xFFFFFFFF)
+val md_theme_light_disable = Color(0xFF000000).copy(alpha = 0.08f)
+val md_theme_light_onDisable = Color(0xFF000000).copy(alpha = 0.24f)
+val md_theme_light_divider = Color(0xFF000000).copy(alpha = 0.1f)
+val md_theme_light_scrim = Color(0xFFFFFFFF).copy(alpha = 0.9f)
diff --git a/app/src/main/java/sample/latest/news/core/theme/Shape.kt b/app/src/main/java/sample/latest/news/core/theme/Shape.kt
new file mode 100644
index 0000000..65d3b58
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/theme/Shape.kt
@@ -0,0 +1,19 @@
+package sample.latest.news.core.theme
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Shapes
+import androidx.compose.ui.unit.dp
+
+val ExtraSmallRadius = 4.dp
+val SmallRadius = 8.dp
+val MediumRadius = 12.dp
+val LargeRadius = 16.dp
+val ExtraLargeRadius = 24.dp
+
+val Shapes = Shapes(
+ extraSmall = RoundedCornerShape(ExtraSmallRadius),
+ small = RoundedCornerShape(SmallRadius),
+ medium = RoundedCornerShape(MediumRadius),
+ large = RoundedCornerShape(LargeRadius),
+ extraLarge = RoundedCornerShape(ExtraLargeRadius)
+)
diff --git a/app/src/main/java/sample/latest/news/core/theme/Theme.kt b/app/src/main/java/sample/latest/news/core/theme/Theme.kt
new file mode 100644
index 0000000..5abab1a
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/theme/Theme.kt
@@ -0,0 +1,83 @@
+package sample.latest.news.core.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
+
+private val darkColorScheme = darkColorScheme(
+ primary = md_theme_dark_primary,
+ onPrimary = md_theme_dark_onPrimary,
+ background = md_theme_dark_background,
+ onBackground = md_theme_dark_onBackground,
+ surface = md_theme_dark_surface,
+ onSurface = md_theme_dark_onSurface,
+ error = md_theme_dark_error,
+ onError = md_theme_dark_onError,
+ scrim = md_theme_dark_scrim
+)
+
+private val lightColorScheme = lightColorScheme(
+ primary = md_theme_light_primary,
+ onPrimary = md_theme_light_onPrimary,
+ background = md_theme_light_background,
+ onBackground = md_theme_light_onBackground,
+ surface = md_theme_light_surface,
+ onSurface = md_theme_light_onSurface,
+ error = md_theme_light_error,
+ onError = md_theme_light_onError,
+ scrim = md_theme_light_scrim
+)
+
+@Composable
+fun ColorScheme.isLight() = this.background.luminance() > 0.5
+
+val ColorScheme.warning: Color
+ @Composable
+ get() = if (isLight()) md_theme_light_warning else md_theme_dark_warning
+
+val ColorScheme.onWarning: Color
+ @Composable
+ get() = if (isLight()) md_theme_light_onWarning else md_theme_dark_onWarning
+
+val ColorScheme.success: Color
+ @Composable
+ get() = if (isLight()) md_theme_light_success else md_theme_dark_success
+
+val ColorScheme.onSuccess: Color
+ @Composable
+ get() = if (isLight()) md_theme_light_onSuccess else md_theme_dark_onSuccess
+
+val ColorScheme.disable: Color
+ @Composable
+ get() = if (isLight()) md_theme_light_disable else md_theme_dark_disable
+
+val ColorScheme.onDisable: Color
+ @Composable
+ get() = if (isLight()) md_theme_light_onDisable else md_theme_dark_onDisable
+
+val ColorScheme.divider: Color
+ @Composable
+ get() = if (isLight()) md_theme_light_divider else md_theme_dark_divider
+
+@Composable
+fun AppTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ content: @Composable () -> Unit
+) {
+ val colorScheme = if (darkTheme) {
+ darkColorScheme
+ } else {
+ lightColorScheme
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
diff --git a/app/src/main/java/sample/latest/news/core/theme/Type.kt b/app/src/main/java/sample/latest/news/core/theme/Type.kt
new file mode 100644
index 0000000..a7265e1
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/theme/Type.kt
@@ -0,0 +1,114 @@
+package sample.latest.news.core.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+import sample.latest.news.R
+
+val FontName = FontFamily(
+ Font(R.font.iran_yekan_x_w100, FontWeight.W100),
+ Font(R.font.iran_yekan_x_w200, FontWeight.W200),
+ Font(R.font.iran_yekan_x_w300, FontWeight.W300),
+ Font(R.font.iran_yekan_x_w400, FontWeight.W400),
+ Font(R.font.iran_yekan_x_w500, FontWeight.W500),
+ Font(R.font.iran_yekan_x_w600, FontWeight.W600),
+ Font(R.font.iran_yekan_x_w700, FontWeight.W700),
+ Font(R.font.iran_yekan_x_w800, FontWeight.W800),
+ Font(R.font.iran_yekan_x_w900, FontWeight.W900)
+)
+
+// Set of Material typography styles to start with
+val Typography = Typography()
+
+val BaseTextStyle = TextStyle(
+ fontFamily = FontName,
+ lineHeight = 20.sp
+)
+
+val Typography.w100: TextStyle
+ get() = BaseTextStyle.copy(
+ fontWeight = FontWeight.W100
+ )
+
+val Typography.w200: TextStyle
+ get() = BaseTextStyle.copy(
+ fontWeight = FontWeight.W200
+ )
+
+val Typography.w300: TextStyle
+ get() = BaseTextStyle.copy(
+ fontWeight = FontWeight.W300
+ )
+
+val Typography.w400: TextStyle
+ get() = BaseTextStyle.copy(
+ fontWeight = FontWeight.W400
+ )
+
+val Typography.w500: TextStyle
+ get() = BaseTextStyle.copy(
+ fontWeight = FontWeight.W500
+ )
+
+val Typography.w600: TextStyle
+ get() = BaseTextStyle.copy(
+ fontWeight = FontWeight.W600
+ )
+
+val Typography.w700: TextStyle
+ get() = BaseTextStyle.copy(
+ fontWeight = FontWeight.W700
+ )
+
+val Typography.w800: TextStyle
+ get() = BaseTextStyle.copy(
+ fontWeight = FontWeight.W800
+ )
+
+val Typography.w900: TextStyle
+ get() = BaseTextStyle.copy(
+ fontWeight = FontWeight.W900
+ )
+
+val TextStyle.x1: TextStyle
+ get() = copy(
+ fontSize = 10.sp
+ )
+
+val TextStyle.x2: TextStyle
+ get() = copy(
+ fontSize = 12.sp
+ )
+
+val TextStyle.x3: TextStyle
+ get() = copy(
+ fontSize = 14.sp
+ )
+
+val TextStyle.x4: TextStyle
+ get() = copy(
+ fontSize = 16.sp
+ )
+
+val TextStyle.x5: TextStyle
+ get() = copy(
+ fontSize = 18.sp
+ )
+
+val TextStyle.x6: TextStyle
+ get() = copy(
+ fontSize = 20.sp
+ )
+
+val TextStyle.x7: TextStyle
+ get() = copy(
+ fontSize = 22.sp
+ )
+
+val TextStyle.x8: TextStyle
+ get() = copy(
+ fontSize = 24.sp
+ )
diff --git a/app/src/main/java/sample/latest/news/core/util/LocaleUtils.kt b/app/src/main/java/sample/latest/news/core/util/LocaleUtils.kt
new file mode 100644
index 0000000..b8980ee
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/util/LocaleUtils.kt
@@ -0,0 +1,12 @@
+package sample.latest.news.core.util
+
+import android.content.Context
+import java.util.Locale
+
+fun localizedContext(baseContext: Context, locale: Locale = Locale("fa")): Context {
+ Locale.setDefault(locale)
+ val configuration = baseContext.resources.configuration
+ configuration.setLocale(locale)
+ configuration.setLayoutDirection(locale)
+ return baseContext.createConfigurationContext(configuration)
+}
diff --git a/app/src/main/java/sample/latest/news/core/util/NetworkHandler.kt b/app/src/main/java/sample/latest/news/core/util/NetworkHandler.kt
new file mode 100644
index 0000000..1d0b3da
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/util/NetworkHandler.kt
@@ -0,0 +1,21 @@
+package sample.latest.news.core.util
+
+import android.content.Context
+import android.content.Context.CONNECTIVITY_SERVICE
+import android.net.ConnectivityManager
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+
+/**
+ * Checks if a network connection exists.
+ */
+@Suppress("DEPRECATION")
+class NetworkHandler @Inject constructor(@ApplicationContext private val context: Context) {
+
+ fun hasNetworkConnection(): Boolean {
+ val connectivityManager =
+ context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
+ val activeNetworkInfo = connectivityManager.activeNetworkInfo
+ return activeNetworkInfo != null && activeNetworkInfo.isConnected
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/core/util/accompanist/navigationMaterial/BottomSheet.kt b/app/src/main/java/sample/latest/news/core/util/accompanist/navigationMaterial/BottomSheet.kt
new file mode 100644
index 0000000..4b2dff3
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/util/accompanist/navigationMaterial/BottomSheet.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * 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.
+ */
+
+package sample.latest.news.core.util.accompanist.navigationMaterial
+
+import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.ModalBottomSheetDefaults
+import androidx.compose.material.ModalBottomSheetLayout
+import androidx.compose.material.contentColorFor
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.Dp
+
+/**
+ * Helper function to create a [ModalBottomSheetLayout] from a [BottomSheetNavigator].
+ *
+ * @see [ModalBottomSheetLayout]
+ */
+@ExperimentalMaterialNavigationApi
+@OptIn(ExperimentalMaterialApi::class)
+@Composable
+public fun ModalBottomSheetLayout(
+ bottomSheetNavigator: BottomSheetNavigator,
+ modifier: Modifier = Modifier,
+ sheetShape: Shape = MaterialTheme.shapes.large,
+ sheetElevation: Dp = ModalBottomSheetDefaults.Elevation,
+ sheetBackgroundColor: Color = MaterialTheme.colors.surface,
+ sheetContentColor: Color = contentColorFor(sheetBackgroundColor),
+ scrimColor: Color = ModalBottomSheetDefaults.scrimColor,
+ content: @Composable () -> Unit
+) {
+ ModalBottomSheetLayout(
+ sheetState = bottomSheetNavigator.sheetState,
+ sheetContent = bottomSheetNavigator.sheetContent,
+ modifier = modifier,
+ sheetShape = sheetShape,
+ sheetElevation = sheetElevation,
+ sheetBackgroundColor = sheetBackgroundColor,
+ sheetContentColor = sheetContentColor,
+ scrimColor = scrimColor,
+ content = content
+ )
+}
diff --git a/app/src/main/java/sample/latest/news/core/util/accompanist/navigationMaterial/BottomSheetNavigator.kt b/app/src/main/java/sample/latest/news/core/util/accompanist/navigationMaterial/BottomSheetNavigator.kt
new file mode 100644
index 0000000..770f72e
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/util/accompanist/navigationMaterial/BottomSheetNavigator.kt
@@ -0,0 +1,253 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * 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.
+ */
+
+package sample.latest.news.core.util.accompanist.navigationMaterial
+
+import android.annotation.SuppressLint
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.animation.core.SpringSpec
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.material.ModalBottomSheetState
+import androidx.compose.material.ModalBottomSheetValue
+import androidx.compose.material.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveableStateHolder
+import androidx.compose.runtime.setValue
+import androidx.navigation.FloatingWindow
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavDestination
+import androidx.navigation.NavOptions
+import androidx.navigation.Navigator
+import androidx.navigation.NavigatorState
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.transform
+import sample.latest.news.core.util.accompanist.navigationMaterial.BottomSheetNavigator.Destination
+
+/**
+ * The state of a [ModalBottomSheetLayout] that the [BottomSheetNavigator] drives
+ *
+ * @param sheetState The sheet state that is driven by the [BottomSheetNavigator]
+ */
+@ExperimentalMaterialNavigationApi
+@OptIn(ExperimentalMaterialApi::class)
+@Stable
+public class BottomSheetNavigatorSheetState(internal val sheetState: ModalBottomSheetState) {
+ /**
+ * @see ModalBottomSheetState.isVisible
+ */
+ public val isVisible: Boolean
+ get() = sheetState.isVisible
+
+ /**
+ * @see ModalBottomSheetState.currentValue
+ */
+ public val currentValue: ModalBottomSheetValue
+ get() = sheetState.currentValue
+
+ /**
+ * @see ModalBottomSheetState.targetValue
+ */
+ public val targetValue: ModalBottomSheetValue
+ get() = sheetState.targetValue
+}
+
+/**
+ * Create and remember a [BottomSheetNavigator]
+ */
+@ExperimentalMaterialNavigationApi
+@OptIn(ExperimentalMaterialApi::class)
+@Composable
+public fun rememberBottomSheetNavigator(
+ animationSpec: AnimationSpec = SpringSpec()
+): BottomSheetNavigator {
+ val sheetState = rememberModalBottomSheetState(
+ ModalBottomSheetValue.Hidden,
+ animationSpec = animationSpec,
+ skipHalfExpanded = true
+ )
+ return remember { BottomSheetNavigator(sheetState) }
+}
+
+/**
+ * Navigator that drives a [ModalBottomSheetState] for use of [ModalBottomSheetLayout]s
+ * with the navigation library. Every destination using this Navigator must set a valid
+ * [Composable] by setting it directly on an instantiated [Destination] or calling
+ * [androidx.navigation.compose.material.bottomSheet].
+ *
+ * The [sheetContent] [Composable] will always host the latest entry of the back stack. When
+ * navigating from a [BottomSheetNavigator.Destination] to another
+ * [BottomSheetNavigator.Destination], the content of the sheet will be replaced instead of a
+ * new bottom sheet being shown.
+ *
+ * When the sheet is dismissed by the user, the [state]'s [NavigatorState.backStack] will be popped.
+ *
+ * The primary constructor is not intended for public use. Please refer to
+ * [rememberBottomSheetNavigator] instead.
+ *
+ * @param sheetState The [ModalBottomSheetState] that the [BottomSheetNavigator] will use to
+ * drive the sheet state
+ */
+@ExperimentalMaterialNavigationApi
+@OptIn(ExperimentalMaterialApi::class)
+@Navigator.Name("BottomSheetNavigator")
+public class BottomSheetNavigator(
+ internal val sheetState: ModalBottomSheetState
+) : Navigator() {
+
+ private var attached by mutableStateOf(false)
+
+ /**
+ * Get the back stack from the [state]. In some cases, the [sheetContent] might be composed
+ * before the Navigator is attached, so we specifically return an empty flow if we aren't
+ * attached yet.
+ */
+ private val backStack: StateFlow>
+ get() = if (attached) {
+ state.backStack
+ } else {
+ MutableStateFlow(emptyList())
+ }
+
+ /**
+ * Get the transitionsInProgress from the [state]. In some cases, the [sheetContent] might be
+ * composed before the Navigator is attached, so we specifically return an empty flow if we
+ * aren't attached yet.
+ */
+ internal val transitionsInProgress: StateFlow>
+ get() = if (attached) {
+ state.transitionsInProgress
+ } else {
+ MutableStateFlow(emptySet())
+ }
+
+ /**
+ * Access properties of the [ModalBottomSheetLayout]'s [ModalBottomSheetState]
+ */
+ public val navigatorSheetState: BottomSheetNavigatorSheetState = BottomSheetNavigatorSheetState(sheetState)
+
+ /**
+ * A [Composable] function that hosts the current sheet content. This should be set as
+ * sheetContent of your [ModalBottomSheetLayout].
+ */
+ public val sheetContent: @Composable ColumnScope.() -> Unit = {
+ val saveableStateHolder = rememberSaveableStateHolder()
+ val transitionsInProgressEntries by transitionsInProgress.collectAsState()
+
+ // The latest back stack entry, retained until the sheet is completely hidden
+ // While the back stack is updated immediately, we might still be hiding the sheet, so
+ // we keep the entry around until the sheet is hidden
+ val retainedEntry by produceState(
+ initialValue = null,
+ key1 = backStack
+ ) {
+ backStack
+ .transform { backStackEntries ->
+ // Always hide the sheet when the back stack is updated
+ // Regardless of whether we're popping or pushing, we always want to hide
+ // the sheet first before deciding whether to re-show it or keep it hidden
+ try {
+ sheetState.hide()
+ } catch (_: CancellationException) {
+ // We catch but ignore possible cancellation exceptions as we don't want
+ // them to bubble up and cancel the whole produceState coroutine
+ } finally {
+ emit(backStackEntries.lastOrNull())
+ }
+ }
+ .collect {
+ value = it
+ }
+ }
+
+ if (retainedEntry != null) {
+ LaunchedEffect(retainedEntry) {
+ sheetState.show()
+ }
+ }
+
+ BackHandler(retainedEntry != null) {
+ state.popWithTransition(retainedEntry!!, false)
+ }
+
+ SheetContentHost(
+ backStackEntry = retainedEntry,
+ sheetState = sheetState,
+ saveableStateHolder = saveableStateHolder,
+ onSheetShown = {
+ transitionsInProgressEntries.forEach(state::markTransitionComplete)
+ },
+ onSheetDismissed = { backStackEntry ->
+ // Sheet dismissal can be started through popBackStack in which case we have a
+ // transition that we'll want to complete
+ if (transitionsInProgressEntries.contains(backStackEntry)) {
+ state.markTransitionComplete(backStackEntry)
+ }
+ // If there is no transition in progress, the sheet has been dimissed by the
+ // user (for example by tapping on the scrim or through an accessibility action)
+ // In this case, we will immediately pop without a transition as the sheet has
+ // already been hidden
+ else {
+ state.pop(popUpTo = backStackEntry, saveState = false)
+ }
+ }
+ )
+ }
+
+ override fun onAttach(state: NavigatorState) {
+ super.onAttach(state)
+ attached = true
+ }
+
+ override fun createDestination(): Destination = Destination(
+ navigator = this,
+ content = {}
+ )
+
+ @SuppressLint("NewApi") // b/187418647
+ override fun navigate(
+ entries: List,
+ navOptions: NavOptions?,
+ navigatorExtras: Extras?
+ ) {
+ entries.forEach { entry ->
+ state.pushWithTransition(entry)
+ }
+ }
+
+ override fun popBackStack(popUpTo: NavBackStackEntry, savedState: Boolean) {
+ state.popWithTransition(popUpTo, savedState)
+ }
+
+ /**
+ * [NavDestination] specific to [BottomSheetNavigator]
+ */
+ @NavDestination.ClassType(Composable::class)
+ public class Destination(
+ navigator: BottomSheetNavigator,
+ internal val content: @Composable ColumnScope.(NavBackStackEntry) -> Unit
+ ) : NavDestination(navigator), FloatingWindow
+}
diff --git a/app/src/main/java/sample/latest/news/core/util/accompanist/navigationMaterial/ExperimentalMaterialNavigationApi.kt b/app/src/main/java/sample/latest/news/core/util/accompanist/navigationMaterial/ExperimentalMaterialNavigationApi.kt
new file mode 100644
index 0000000..b8f57aa
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/util/accompanist/navigationMaterial/ExperimentalMaterialNavigationApi.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * 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.
+ */
+
+package sample.latest.news.core.util.accompanist.navigationMaterial
+
+@RequiresOptIn(message = "This APIs are experimental and may change in the future.")
+@Retention(AnnotationRetention.BINARY)
+annotation class ExperimentalMaterialNavigationApi
diff --git a/app/src/main/java/sample/latest/news/core/util/accompanist/navigationMaterial/NavGraphBuilder.kt b/app/src/main/java/sample/latest/news/core/util/accompanist/navigationMaterial/NavGraphBuilder.kt
new file mode 100644
index 0000000..f98a372
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/util/accompanist/navigationMaterial/NavGraphBuilder.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * 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.
+ */
+
+package sample.latest.news.core.util.accompanist.navigationMaterial
+
+import android.annotation.SuppressLint
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.runtime.Composable
+import androidx.navigation.NamedNavArgument
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavDeepLink
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.get
+
+/**
+ * Add the [content] [Composable] as bottom sheet content to the [NavGraphBuilder]
+ *
+ * @param route route for the destination
+ * @param arguments list of arguments to associate with destination
+ * @param deepLinks list of deep links to associate with the destinations
+ * @param content the sheet content at the given destination
+ */
+@SuppressLint("NewApi") // b/187418647
+@ExperimentalMaterialNavigationApi
+public fun NavGraphBuilder.bottomSheet(
+ route: String,
+ arguments: List = emptyList(),
+ deepLinks: List = emptyList(),
+ content: @Composable ColumnScope.(backstackEntry: NavBackStackEntry) -> Unit
+) {
+ addDestination(
+ BottomSheetNavigator.Destination(
+ provider[BottomSheetNavigator::class],
+ content
+ ).apply {
+ this.route = route
+ arguments.forEach { (argumentName, argument) ->
+ addArgument(argumentName, argument)
+ }
+ deepLinks.forEach { deepLink ->
+ addDeepLink(deepLink)
+ }
+ }
+ )
+}
diff --git a/app/src/main/java/sample/latest/news/core/util/accompanist/navigationMaterial/SheetContentHost.kt b/app/src/main/java/sample/latest/news/core/util/accompanist/navigationMaterial/SheetContentHost.kt
new file mode 100644
index 0000000..5dcff12
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/util/accompanist/navigationMaterial/SheetContentHost.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * 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.
+ */
+
+package sample.latest.news.core.util.accompanist.navigationMaterial
+
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.material.ModalBottomSheetState
+import androidx.compose.runtime.*
+import androidx.compose.runtime.saveable.SaveableStateHolder
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.compose.LocalOwnersProvider
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.drop
+import sample.latest.news.core.util.accompanist.navigationMaterial.BottomSheetNavigator
+import sample.latest.news.core.util.accompanist.navigationMaterial.ExperimentalMaterialNavigationApi
+
+/**
+ * Hosts a [BottomSheetNavigator.Destination]'s [NavBackStackEntry] and its
+ * [BottomSheetNavigator.Destination.content] and provides a [onSheetDismissed] callback. It also
+ * shows and hides the [ModalBottomSheetLayout] through the [sheetState] when the sheet content
+ * enters or leaves the composition.
+ *
+ * @param backStackEntry The [NavBackStackEntry] holding the [BottomSheetNavigator.Destination],
+ * or null if there is no [NavBackStackEntry]
+ * @param sheetState The [ModalBottomSheetState] used to observe and control the sheet visibility
+ * @param onSheetDismissed Callback when the sheet has been dismissed. Typically, you'll want to
+ * pop the back stack here.
+ */
+@ExperimentalMaterialNavigationApi
+@OptIn(ExperimentalMaterialApi::class)
+@Composable
+internal fun ColumnScope.SheetContentHost(
+ backStackEntry: NavBackStackEntry?,
+ sheetState: ModalBottomSheetState,
+ saveableStateHolder: SaveableStateHolder,
+ onSheetShown: (entry: NavBackStackEntry) -> Unit,
+ onSheetDismissed: (entry: NavBackStackEntry) -> Unit,
+) {
+ if (backStackEntry != null) {
+ val currentOnSheetShown by rememberUpdatedState(onSheetShown)
+ val currentOnSheetDismissed by rememberUpdatedState(onSheetDismissed)
+ LaunchedEffect(sheetState, backStackEntry) {
+ snapshotFlow { sheetState.isVisible }
+ // We are only interested in changes in the sheet's visibility
+ .distinctUntilChanged()
+ // distinctUntilChanged emits the initial value which we don't need
+ .drop(1)
+ .collect { visible ->
+ if (visible) {
+ currentOnSheetShown(backStackEntry)
+ } else {
+ currentOnSheetDismissed(backStackEntry)
+ }
+ }
+ }
+ backStackEntry.LocalOwnersProvider(saveableStateHolder) {
+ val content =
+ (backStackEntry.destination as BottomSheetNavigator.Destination).content
+ content(backStackEntry)
+ }
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/core/util/accompanist/systemUiController/SystemUiController.kt b/app/src/main/java/sample/latest/news/core/util/accompanist/systemUiController/SystemUiController.kt
new file mode 100644
index 0000000..7a1180d
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/util/accompanist/systemUiController/SystemUiController.kt
@@ -0,0 +1,311 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * 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.
+ */
+
+package sample.latest.news.core.util.accompanist.systemUiController
+
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+import android.os.Build
+import android.view.View
+import android.view.Window
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.compositeOver
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.window.DialogWindowProvider
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+
+/**
+ * A class which provides easy-to-use utilities for updating the System UI bar
+ * colors within Jetpack Compose.
+ *
+ * @sample com.google.accompanist.sample.systemuicontroller.SystemUiControllerSample
+ */
+@Stable
+public interface SystemUiController {
+
+ /**
+ * Control for the behavior of the system bars. This value should be one of the
+ * [WindowInsetsControllerCompat] behavior constants:
+ * [WindowInsetsControllerCompat.BEHAVIOR_SHOW_BARS_BY_TOUCH] (Deprecated),
+ * [WindowInsetsControllerCompat.BEHAVIOR_DEFAULT] and
+ * [WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE].
+ */
+ public var systemBarsBehavior: Int
+
+ /**
+ * Property which holds the status bar visibility. If set to true, show the status bar,
+ * otherwise hide the status bar.
+ */
+ public var isStatusBarVisible: Boolean
+
+ /**
+ * Property which holds the navigation bar visibility. If set to true, show the navigation bar,
+ * otherwise hide the navigation bar.
+ */
+ public var isNavigationBarVisible: Boolean
+
+ /**
+ * Property which holds the status & navigation bar visibility. If set to true, show both bars,
+ * otherwise hide both bars.
+ */
+ public var isSystemBarsVisible: Boolean
+ get() = isNavigationBarVisible && isStatusBarVisible
+ set(value) {
+ isStatusBarVisible = value
+ isNavigationBarVisible = value
+ }
+
+ /**
+ * Set the status bar color.
+ *
+ * @param color The **desired** [Color] to set. This may require modification if running on an
+ * API level that only supports white status bar icons.
+ * @param darkIcons Whether dark status bar icons would be preferable.
+ * @param transformColorForLightContent A lambda which will be invoked to transform [color] if
+ * dark icons were requested but are not available. Defaults to applying a black scrim.
+ *
+ * @see statusBarDarkContentEnabled
+ */
+ public fun setStatusBarColor(
+ color: Color,
+ darkIcons: Boolean = color.luminance() > 0.5f,
+ transformColorForLightContent: (Color) -> Color = BlackScrimmed
+ )
+
+ /**
+ * Set the navigation bar color.
+ *
+ * @param color The **desired** [Color] to set. This may require modification if running on an
+ * API level that only supports white navigation bar icons. Additionally this will be ignored
+ * and [Color.Transparent] will be used on API 29+ where gesture navigation is preferred or the
+ * system UI automatically applies background protection in other navigation modes.
+ * @param darkIcons Whether dark navigation bar icons would be preferable.
+ * @param navigationBarContrastEnforced Whether the system should ensure that the navigation
+ * bar has enough contrast when a fully transparent background is requested. Only supported on
+ * API 29+.
+ * @param transformColorForLightContent A lambda which will be invoked to transform [color] if
+ * dark icons were requested but are not available. Defaults to applying a black scrim.
+ *
+ * @see navigationBarDarkContentEnabled
+ * @see navigationBarContrastEnforced
+ */
+ public fun setNavigationBarColor(
+ color: Color,
+ darkIcons: Boolean = color.luminance() > 0.5f,
+ navigationBarContrastEnforced: Boolean = true,
+ transformColorForLightContent: (Color) -> Color = BlackScrimmed
+ )
+
+ /**
+ * Set the status and navigation bars to [color].
+ *
+ * @see setStatusBarColor
+ * @see setNavigationBarColor
+ */
+ public fun setSystemBarsColor(
+ color: Color,
+ darkIcons: Boolean = color.luminance() > 0.5f,
+ isNavigationBarContrastEnforced: Boolean = true,
+ transformColorForLightContent: (Color) -> Color = BlackScrimmed
+ ) {
+ setStatusBarColor(color, darkIcons, transformColorForLightContent)
+ setNavigationBarColor(
+ color,
+ darkIcons,
+ isNavigationBarContrastEnforced,
+ transformColorForLightContent
+ )
+ }
+
+ /**
+ * Property which holds whether the status bar icons + content are 'dark' or not.
+ */
+ public var statusBarDarkContentEnabled: Boolean
+
+ /**
+ * Property which holds whether the navigation bar icons + content are 'dark' or not.
+ */
+ public var navigationBarDarkContentEnabled: Boolean
+
+ /**
+ * Property which holds whether the status & navigation bar icons + content are 'dark' or not.
+ */
+ public var systemBarsDarkContentEnabled: Boolean
+ get() = statusBarDarkContentEnabled && navigationBarDarkContentEnabled
+ set(value) {
+ statusBarDarkContentEnabled = value
+ navigationBarDarkContentEnabled = value
+ }
+
+ /**
+ * Property which holds whether the system is ensuring that the navigation bar has enough
+ * contrast when a fully transparent background is requested. Only has an affect when running
+ * on Android API 29+ devices.
+ */
+ public var isNavigationBarContrastEnforced: Boolean
+}
+
+/**
+ * Remembers a [SystemUiController] for the given [window].
+ *
+ * If no [window] is provided, an attempt to find the correct [Window] is made.
+ *
+ * First, if the [LocalView]'s parent is a [DialogWindowProvider], then that dialog's [Window] will
+ * be used.
+ *
+ * Second, we attempt to find [Window] for the [Activity] containing the [LocalView].
+ *
+ * If none of these are found (such as may happen in a preview), then the functionality of the
+ * returned [SystemUiController] will be degraded, but won't throw an exception.
+ */
+@Composable
+public fun rememberSystemUiController(
+ window: Window? = findWindow(),
+): SystemUiController {
+ val view = LocalView.current
+ return remember(view, window) { AndroidSystemUiController(view, window) }
+}
+
+@Composable
+private fun findWindow(): Window? =
+ (LocalView.current.parent as? DialogWindowProvider)?.window
+ ?: LocalView.current.context.findWindow()
+
+private tailrec fun Context.findWindow(): Window? =
+ when (this) {
+ is Activity -> window
+ is ContextWrapper -> baseContext.findWindow()
+ else -> null
+ }
+
+/**
+ * A helper class for setting the navigation and status bar colors for a [View], gracefully
+ * degrading behavior based upon API level.
+ *
+ * Typically you would use [rememberSystemUiController] to remember an instance of this.
+ */
+internal class AndroidSystemUiController(
+ private val view: View,
+ private val window: Window?
+) : SystemUiController {
+ private val windowInsetsController = window?.let {
+ WindowCompat.getInsetsController(it, view)
+ }
+
+ override fun setStatusBarColor(
+ color: Color,
+ darkIcons: Boolean,
+ transformColorForLightContent: (Color) -> Color
+ ) {
+ statusBarDarkContentEnabled = darkIcons
+
+ window?.statusBarColor = when {
+ darkIcons && windowInsetsController?.isAppearanceLightStatusBars != true -> {
+ // If we're set to use dark icons, but our windowInsetsController call didn't
+ // succeed (usually due to API level), we instead transform the color to maintain
+ // contrast
+ transformColorForLightContent(color)
+ }
+ else -> color
+ }.toArgb()
+ }
+
+ override fun setNavigationBarColor(
+ color: Color,
+ darkIcons: Boolean,
+ navigationBarContrastEnforced: Boolean,
+ transformColorForLightContent: (Color) -> Color
+ ) {
+ navigationBarDarkContentEnabled = darkIcons
+ isNavigationBarContrastEnforced = navigationBarContrastEnforced
+
+ window?.navigationBarColor = when {
+ darkIcons && windowInsetsController?.isAppearanceLightNavigationBars != true -> {
+ // If we're set to use dark icons, but our windowInsetsController call didn't
+ // succeed (usually due to API level), we instead transform the color to maintain
+ // contrast
+ transformColorForLightContent(color)
+ }
+ else -> color
+ }.toArgb()
+ }
+
+ override var systemBarsBehavior: Int
+ get() = windowInsetsController?.systemBarsBehavior ?: 0
+ set(value) {
+ windowInsetsController?.systemBarsBehavior = value
+ }
+
+ override var isStatusBarVisible: Boolean
+ get() {
+ return ViewCompat.getRootWindowInsets(view)
+ ?.isVisible(WindowInsetsCompat.Type.statusBars()) == true
+ }
+ set(value) {
+ if (value) {
+ windowInsetsController?.show(WindowInsetsCompat.Type.statusBars())
+ } else {
+ windowInsetsController?.hide(WindowInsetsCompat.Type.statusBars())
+ }
+ }
+
+ override var isNavigationBarVisible: Boolean
+ get() {
+ return ViewCompat.getRootWindowInsets(view)
+ ?.isVisible(WindowInsetsCompat.Type.navigationBars()) == true
+ }
+ set(value) {
+ if (value) {
+ windowInsetsController?.show(WindowInsetsCompat.Type.navigationBars())
+ } else {
+ windowInsetsController?.hide(WindowInsetsCompat.Type.navigationBars())
+ }
+ }
+
+ override var statusBarDarkContentEnabled: Boolean
+ get() = windowInsetsController?.isAppearanceLightStatusBars == true
+ set(value) {
+ windowInsetsController?.isAppearanceLightStatusBars = value
+ }
+
+ override var navigationBarDarkContentEnabled: Boolean
+ get() = windowInsetsController?.isAppearanceLightNavigationBars == true
+ set(value) {
+ windowInsetsController?.isAppearanceLightNavigationBars = value
+ }
+
+ override var isNavigationBarContrastEnforced: Boolean
+ get() = Build.VERSION.SDK_INT >= 29 && window?.isNavigationBarContrastEnforced == true
+ set(value) {
+ if (Build.VERSION.SDK_INT >= 29) {
+ window?.isNavigationBarContrastEnforced = value
+ }
+ }
+}
+
+private val BlackScrim = Color(0f, 0f, 0f, 0.3f) // 30% opaque black
+private val BlackScrimmed: (Color) -> Color = { original ->
+ BlackScrim.compositeOver(original)
+}
diff --git a/app/src/main/java/sample/latest/news/core/util/extensions/ComposeExtensions.kt b/app/src/main/java/sample/latest/news/core/util/extensions/ComposeExtensions.kt
new file mode 100644
index 0000000..14496a8
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/util/extensions/ComposeExtensions.kt
@@ -0,0 +1,88 @@
+package sample.latest.news.core.util.extensions
+
+import androidx.compose.foundation.lazy.grid.LazyGridState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Paint
+import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.flow.filter
+
+const val PerPageSize = 20
+
+@Composable
+fun LazyGridState.OnBottomReached(
+ onLoadMore: () -> Unit
+) {
+ val loadMore = remember {
+ derivedStateOf {
+ val totalItemsNumber = layoutInfo.totalItemsCount
+ val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0)
+ lastVisibleItemIndex == totalItemsNumber - 1 && totalItemsNumber != 0
+ }
+ }
+
+ LaunchedEffect(loadMore) {
+ snapshotFlow { loadMore.value }
+ .filter { it }
+ .collect {
+ onLoadMore()
+ }
+ }
+}
+
+fun Modifier.appShadow(
+ cornersRadius: Dp = 0.dp
+) = shadow(
+ color = Color.Black,
+ alpha = 0.04f,
+ blurRadius = 32.dp,
+ cornersRadius = cornersRadius
+)
+
+fun Modifier.shadow(
+ color: Color,
+ alpha: Float = 1f,
+ blurRadius: Dp = 0.dp,
+ cornersRadius: Dp = 0.dp,
+ offsetY: Dp = 0.dp,
+ offsetX: Dp = 0.dp
+) = drawBehind {
+
+ val shadowColorArgb = color.copy(alpha).toArgb()
+
+ val transparentColorArgb = Color.Transparent.toArgb()
+
+ val cornerRadiusPx = cornersRadius.toPx()
+
+ drawIntoCanvas {
+ val paint = Paint()
+ val frameworkPaint = paint.asFrameworkPaint()
+ frameworkPaint.color = transparentColorArgb
+
+ frameworkPaint.setShadowLayer(
+ blurRadius.toPx(),
+ offsetX.toPx(),
+ offsetY.toPx(),
+ shadowColorArgb
+ )
+
+ it.drawRoundRect(
+ 0f,
+ 0f,
+ this.size.width,
+ this.size.height,
+ cornerRadiusPx,
+ cornerRadiusPx,
+ paint
+ )
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/core/util/extensions/Extensions.kt b/app/src/main/java/sample/latest/news/core/util/extensions/Extensions.kt
new file mode 100644
index 0000000..c77f78c
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/util/extensions/Extensions.kt
@@ -0,0 +1,8 @@
+package sample.latest.news.core.util.extensions
+
+import android.content.Context
+import sample.latest.news.core.model.NetworkViewState
+
+fun Context.parseServerErrorMessage(networkViewState: NetworkViewState): String {
+ return networkViewState.serverErrorMessage ?: getString(networkViewState.errorMessage)
+}
diff --git a/app/src/main/java/sample/latest/news/core/util/navigation/NavigationExtensions.kt b/app/src/main/java/sample/latest/news/core/util/navigation/NavigationExtensions.kt
new file mode 100644
index 0000000..89f1128
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/util/navigation/NavigationExtensions.kt
@@ -0,0 +1,56 @@
+package sample.latest.news.core.util.navigation
+
+import android.util.Log
+import androidx.navigation.NavController
+import androidx.navigation.NavOptionsBuilder
+import androidx.navigation.navOptions
+
+fun NavController.safeNavigate(route: String) {
+
+ val list = route.split("/")
+
+ val fixedRoute = StringBuilder()
+
+ list.forEachIndexed { index, it ->
+ if (!it.startsWith("{")) {
+ fixedRoute.append(it)
+ if (index < list.size - 1) {
+ fixedRoute.append("/")
+ }
+ }
+ }
+
+ try {
+ navigate(fixedRoute.toString())
+ } catch (e: Exception) {
+ Log.d("Navigate", e.toString())
+ }
+}
+
+fun NavController.safeNavigate(
+ route: String,
+ builder: NavOptionsBuilder.() -> Unit
+) {
+
+ val list = route.split("/")
+
+ val fixedRoute = StringBuilder()
+
+ list.forEachIndexed { index, it ->
+ if (!it.startsWith("{")) {
+ fixedRoute.append(it)
+ if (index < list.size - 1) {
+ fixedRoute.append("/")
+ }
+ }
+ }
+
+ try {
+ navigate(
+ fixedRoute.toString(),
+ navOptions(builder)
+ )
+ } catch (e: Exception) {
+ Log.d("Navigate", e.toString())
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/core/util/navigation/NavigationKey.kt b/app/src/main/java/sample/latest/news/core/util/navigation/NavigationKey.kt
new file mode 100644
index 0000000..80d2d61
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/util/navigation/NavigationKey.kt
@@ -0,0 +1,6 @@
+package sample.latest.news.core.util.navigation
+
+object NavigationKey {
+
+ const val KEY_ID = "id"
+}
diff --git a/app/src/main/java/sample/latest/news/core/util/navigation/NavigationRoutes.kt b/app/src/main/java/sample/latest/news/core/util/navigation/NavigationRoutes.kt
new file mode 100644
index 0000000..fb7140d
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/util/navigation/NavigationRoutes.kt
@@ -0,0 +1,60 @@
+package sample.latest.news.core.util.navigation
+
+import sample.latest.news.R
+
+sealed class NavigationRoutes(
+ val route: String,
+ val selectedIcon: Int? = null,
+ val unSelectedIcon: Int? = null,
+ val label: Int? = null
+) {
+ // Main
+ data object Root : NavigationRoutes(
+ route = "root"
+ )
+
+ data object Empty : NavigationRoutes(
+ route = "empty"
+ )
+
+ // Theme
+ data object ThemeList : NavigationRoutes(
+ route = "theme_list"
+ )
+
+ // Home
+ data object Home : NavigationRoutes(
+ route = "home",
+ selectedIcon = R.drawable.ic_baseline_home,
+ unSelectedIcon = R.drawable.ic_outline_home,
+ label = R.string.label_home
+ )
+
+ // Movie
+ data object MovieList : NavigationRoutes(
+ route = "movie_list",
+ selectedIcon = R.drawable.ic_baseline_videocam,
+ unSelectedIcon = R.drawable.ic_outline_videocam,
+ label = R.string.label_movie
+ )
+
+ data object Movie : NavigationRoutes(
+ route = "movie/{${NavigationKey.KEY_ID}}"
+ )
+
+ // Subscription
+ data object Subscription : NavigationRoutes(
+ route = "subscription",
+ selectedIcon = R.drawable.ic_baseline_subscriptions,
+ unSelectedIcon = R.drawable.ic_outline_subscriptions,
+ label = R.string.label_subscription
+ )
+
+ // Library
+ data object Library : NavigationRoutes(
+ route = "library",
+ selectedIcon = R.drawable.ic_baseline_video_library,
+ unSelectedIcon = R.drawable.ic_outline_video_library,
+ label = R.string.label_library
+ )
+}
diff --git a/app/src/main/java/sample/latest/news/core/util/snackBar/AppSnackBarHost.kt b/app/src/main/java/sample/latest/news/core/util/snackBar/AppSnackBarHost.kt
new file mode 100644
index 0000000..c891198
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/util/snackBar/AppSnackBarHost.kt
@@ -0,0 +1,112 @@
+package sample.latest.news.core.util.snackBar
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import sample.latest.news.R
+import sample.latest.news.core.theme.onSuccess
+import sample.latest.news.core.theme.onWarning
+import sample.latest.news.core.theme.success
+import sample.latest.news.core.theme.w400
+import sample.latest.news.core.theme.w600
+import sample.latest.news.core.theme.warning
+import sample.latest.news.core.theme.x2
+import sample.latest.news.core.util.ui.AppCard
+
+@Composable
+fun AppSnackBarHost(
+ hostState: SnackbarHostState,
+ modifier: Modifier = Modifier,
+ onRefresh: (() -> Unit)? = null
+) {
+ SnackbarHost(
+ hostState = hostState,
+ modifier = modifier,
+ snackbar = { snackBar ->
+
+ val isRetry = snackBar.visuals.duration == SnackbarDuration.Indefinite
+
+ val backgroundColor: Color
+
+ val contentColor: Color
+
+ when (snackBar.visuals.actionLabel) {
+
+ SnackBarType.Success.name -> {
+
+ backgroundColor = MaterialTheme.colorScheme.success
+
+ contentColor = MaterialTheme.colorScheme.onSuccess
+ }
+
+ SnackBarType.Warning.name -> {
+
+ backgroundColor = MaterialTheme.colorScheme.warning
+
+ contentColor = MaterialTheme.colorScheme.onWarning
+ }
+
+ else -> {
+
+ backgroundColor = MaterialTheme.colorScheme.error
+
+ contentColor = MaterialTheme.colorScheme.onError
+ }
+ }
+
+ AppCard(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ backgroundColor = backgroundColor
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() }
+ ) {},
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ modifier = Modifier
+ .padding(start = 16.dp, top = 16.dp, bottom = 16.dp)
+ .weight(1f),
+ text = snackBar.visuals.message,
+ style = MaterialTheme.typography.w600.x2,
+ color = contentColor
+ )
+
+ Text(
+ modifier = Modifier
+ .clickable {
+ if (isRetry)
+ onRefresh?.invoke()
+
+ snackBar.dismiss()
+ }
+ .padding(16.dp),
+ text = stringResource(id = if (isRetry) R.string.label_retry else R.string.label_i_realized),
+ style = MaterialTheme.typography.w400.x2,
+ color = contentColor
+ )
+ }
+ }
+ }
+ )
+}
diff --git a/app/src/main/java/sample/latest/news/core/util/snackBar/SnackBarExtensions.kt b/app/src/main/java/sample/latest/news/core/util/snackBar/SnackBarExtensions.kt
new file mode 100644
index 0000000..f8dad45
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/util/snackBar/SnackBarExtensions.kt
@@ -0,0 +1,19 @@
+package sample.latest.news.core.util.snackBar
+
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHostState
+
+suspend fun SnackbarHostState.showAppSnackBar(
+ message: String?,
+ type: SnackBarType = SnackBarType.Error,
+ duration: SnackbarDuration,
+) {
+ if (!message.isNullOrEmpty()) {
+ currentSnackbarData?.dismiss()
+ showSnackbar(
+ message = message,
+ actionLabel = type.name,
+ duration = duration
+ )
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/core/util/snackBar/SnackBarType.kt b/app/src/main/java/sample/latest/news/core/util/snackBar/SnackBarType.kt
new file mode 100644
index 0000000..5434b40
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/util/snackBar/SnackBarType.kt
@@ -0,0 +1,7 @@
+package sample.latest.news.core.util.snackBar
+
+enum class SnackBarType {
+ Error,
+ Success,
+ Warning
+}
diff --git a/app/src/main/java/sample/latest/news/core/util/ui/BaseActivity.kt b/app/src/main/java/sample/latest/news/core/util/ui/BaseActivity.kt
new file mode 100644
index 0000000..99f720a
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/util/ui/BaseActivity.kt
@@ -0,0 +1,17 @@
+package sample.latest.news.core.util.ui
+
+import android.content.Context
+import androidx.activity.ComponentActivity
+import sample.latest.news.core.util.localizedContext
+
+abstract class BaseActivity : ComponentActivity() {
+
+ override fun attachBaseContext(context: Context) {
+ super.attachBaseContext(localizedContext(context))
+ }
+
+ override fun onStart() {
+ super.onStart()
+ localizedContext(this)
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/core/util/ui/CustomUi.kt b/app/src/main/java/sample/latest/news/core/util/ui/CustomUi.kt
new file mode 100644
index 0000000..91d7d12
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/util/ui/CustomUi.kt
@@ -0,0 +1,512 @@
+package sample.latest.news.core.util.ui
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.material.pullrefresh.PullRefreshIndicator
+import androidx.compose.material.pullrefresh.PullRefreshState
+import androidx.compose.material.pullrefresh.pullRefresh
+import androidx.compose.material.pullrefresh.rememberPullRefreshState
+import androidx.compose.material.ripple.rememberRipple
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Divider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import sample.latest.news.R
+import sample.latest.news.core.theme.ExtraLargeRadius
+import sample.latest.news.core.theme.LargeRadius
+import sample.latest.news.core.theme.SmallRadius
+import sample.latest.news.core.theme.disable
+import sample.latest.news.core.theme.divider
+import sample.latest.news.core.theme.onDisable
+import sample.latest.news.core.theme.w300
+import sample.latest.news.core.theme.w400
+import sample.latest.news.core.theme.w500
+import sample.latest.news.core.theme.w700
+import sample.latest.news.core.theme.x1
+import sample.latest.news.core.theme.x2
+import sample.latest.news.core.util.extensions.appShadow
+import sample.latest.news.core.util.snackBar.AppSnackBarHost
+
+val DividerThickness = 0.2.dp
+
+@Composable
+fun AppDivider(
+ modifier: Modifier = Modifier
+) = Divider(
+ modifier = modifier,
+ color = MaterialTheme.colorScheme.divider,
+ thickness = DividerThickness
+)
+
+@Composable
+fun AppDivider(
+ modifier: Modifier = Modifier,
+ spacing: Dp = 0.dp
+) = AppDivider(
+ modifier = modifier.padding(start = spacing, end = spacing)
+)
+
+@Composable
+fun AppDivider(
+ modifier: Modifier = Modifier,
+ top: Dp = 0.dp,
+ bottom: Dp = 0.dp,
+ end: Dp = 0.dp,
+ start: Dp = 0.dp
+) = AppDivider(
+ modifier = modifier.padding(top = top, bottom = bottom, start = start, end = end)
+)
+
+@Composable
+fun AppDivider(
+ modifier: Modifier = Modifier,
+ vertical: Dp = 0.dp,
+ horizontal: Dp = 0.dp
+) = AppDivider(
+ modifier = modifier.padding(
+ top = vertical,
+ bottom = vertical,
+ start = horizontal,
+ end = horizontal
+ )
+)
+
+@Composable
+fun AppCard(
+ modifier: Modifier = Modifier,
+ shape: Shape = MaterialTheme.shapes.small,
+ backgroundColor: Color = MaterialTheme.colorScheme.surface,
+ content: @Composable () -> Unit
+) = Box(
+ modifier = modifier
+ .appShadow(cornersRadius = SmallRadius)
+ .clip(shape = shape)
+ .background(color = backgroundColor)
+) {
+ content()
+}
+
+@Composable
+fun AppButton(
+ modifier: Modifier = Modifier,
+ height: Dp = 48.dp,
+ isWrap: Boolean = false,
+ wrapPadding: Dp = if (isWrap) 32.dp else 0.dp,
+ isBorder: Boolean = false,
+ borderWidth: Dp = 1.dp,
+ enabled: Boolean = true,
+ loading: Boolean = false,
+ backgroundColor: Color = MaterialTheme.colorScheme.primary,
+ disabledBackgroundColor: Color = MaterialTheme.colorScheme.disable,
+ contentColor: Color = MaterialTheme.colorScheme.onPrimary,
+ disabledContentColor: Color = MaterialTheme.colorScheme.onDisable,
+ shape: Shape = MaterialTheme.shapes.extraLarge,
+ text: String? = null,
+ textStyle: TextStyle = MaterialTheme.typography.w700.x2,
+ iconResource: Int? = null,
+ iconSize: Dp = 16.dp,
+ iconPadding: Dp = if (text.isNullOrEmpty()) 0.dp else 8.dp,
+ onClick: () -> Unit
+) {
+ val isEnabled = enabled && !loading
+
+ val mBackgroundColor = if (isEnabled) backgroundColor else disabledBackgroundColor
+
+ val mContentColor = if (isEnabled) {
+
+ if (isBorder) backgroundColor else contentColor
+
+ } else {
+
+ disabledContentColor
+ }
+
+ Row(
+ modifier = modifier
+ .then(
+ if (isWrap) {
+ Modifier.wrapContentWidth()
+ } else {
+ Modifier.fillMaxWidth()
+ }
+ )
+ .height(height)
+ .appShadow(cornersRadius = LargeRadius)
+ .clip(shape)
+ .then(
+ if (isBorder) {
+ Modifier.border(
+ width = borderWidth,
+ color = mBackgroundColor,
+ shape = shape
+ )
+ } else {
+ Modifier.background(mBackgroundColor)
+ }
+ )
+ .clickable(
+ onClick = onClick,
+ enabled = isEnabled
+ )
+ .padding(horizontal = wrapPadding),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center
+ ) {
+
+ if (loading) {
+
+ AppCircularProgressIndicator(
+ color = mContentColor
+ )
+
+ } else {
+
+ iconResource?.let {
+ Icon(
+ modifier = Modifier
+ .padding(end = iconPadding)
+ .size(iconSize),
+ painter = painterResource(id = it),
+ contentDescription = null,
+ tint = mContentColor
+ )
+ }
+
+ text?.let {
+ Text(
+ text = it,
+ style = textStyle,
+ color = mContentColor,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun AppCircularProgressIndicator(
+ modifier: Modifier = Modifier,
+ color: Color = MaterialTheme.colorScheme.primary
+) = CircularProgressIndicator(
+ modifier = modifier.size(16.dp),
+ color = color,
+ strokeWidth = 2.dp
+)
+
+@Composable
+fun AppBottomSheetColumn(
+ modifier: Modifier = Modifier,
+ fullExpand: Boolean = false,
+ fraction: Float = 0.9f,
+ content: @Composable ColumnScope.() -> Unit
+) {
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .then(
+ if (fullExpand) {
+ Modifier.wrapContentHeight()
+ } else {
+ Modifier
+ .wrapContentHeight()
+ .heightIn(max = LocalConfiguration.current.screenHeightDp.dp * fraction)
+ }
+ )
+ ) {
+
+ Box(
+ modifier = modifier
+ .align(Alignment.CenterHorizontally)
+ .padding(vertical = 12.dp)
+ .wrapContentSize()
+ .width(32.dp)
+ .height(3.dp)
+ .clip(MaterialTheme.shapes.extraSmall)
+ .background(MaterialTheme.colorScheme.disable)
+ )
+
+ content()
+ }
+}
+
+@Composable
+fun AppBox(
+ modifier: Modifier = Modifier,
+ contentAlignment: Alignment = Alignment.TopStart,
+ hostState: SnackbarHostState,
+ onRefresh: (() -> Unit)? = null,
+ content: @Composable BoxScope.() -> Unit
+) {
+ Box(
+ modifier = modifier,
+ contentAlignment = contentAlignment
+ ) {
+ content()
+
+ AppSnackBarHost(
+ hostState = hostState,
+ modifier = Modifier.align(Alignment.BottomCenter),
+ onRefresh = onRefresh
+ )
+ }
+}
+
+@Composable
+fun AppIconButton(
+ iconResource: Int,
+ modifier: Modifier = Modifier,
+ color: Color = MaterialTheme.colorScheme.onBackground,
+ isSmall: Boolean = false,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ enabled: Boolean = true,
+ onClick: () -> Unit
+) = Box(
+ modifier = modifier
+ .wrapContentSize()
+ .clickable(
+ enabled = enabled,
+ interactionSource = interactionSource,
+ indication = rememberRipple(bounded = false, radius = ExtraLargeRadius),
+ onClick = onClick
+ )
+ .padding(if (isSmall) 8.dp else 16.dp),
+ contentAlignment = Alignment.Center
+) {
+ Icon(
+ modifier = Modifier.size(24.dp),
+ painter = painterResource(id = iconResource),
+ contentDescription = null,
+ tint = color
+ )
+}
+
+@OptIn(ExperimentalMaterialApi::class)
+@Composable
+fun AppSwipeRefresh(
+ refreshing: Boolean,
+ onRefresh: () -> Unit,
+ modifier: Modifier = Modifier,
+ pullRefreshState: PullRefreshState = rememberPullRefreshState(
+ refreshing = refreshing,
+ onRefresh = onRefresh
+ ),
+ content: @Composable () -> Unit
+) {
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .pullRefresh(pullRefreshState)
+ ) {
+
+ content()
+
+ PullRefreshIndicator(
+ refreshing = refreshing,
+ state = pullRefreshState,
+ modifier = Modifier.align(Alignment.TopCenter),
+ backgroundColor = MaterialTheme.colorScheme.background,
+ contentColor = MaterialTheme.colorScheme.onBackground,
+ scale = true
+ )
+ }
+}
+
+@Composable
+fun AppBottomNavigation(
+ modifier: Modifier = Modifier,
+ backgroundColor: Color = MaterialTheme.colorScheme.background,
+ content: @Composable RowScope.() -> Unit
+) = Row(
+ modifier
+ .appShadow()
+ .fillMaxWidth()
+ .height(80.dp)
+ .background(backgroundColor)
+ .selectableGroup(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ content = content
+)
+
+@Composable
+fun RowScope.AppBottomNavigationItem(
+ selectedIcon: Int?,
+ unSelectedIcon: Int?,
+ label: Int?,
+ selected: Boolean,
+ selectedContentColor: Color = MaterialTheme.colorScheme.primary,
+ unselectedContentColor: Color = MaterialTheme.colorScheme.onBackground,
+ onClick: () -> Unit
+) {
+ val interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
+
+ val ripple = rememberRipple(bounded = false, color = selectedContentColor)
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .weight(1f)
+ .clickable(
+ interactionSource = interactionSource,
+ indication = ripple,
+ onClick = onClick
+ ),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ if (selectedIcon != null && unSelectedIcon != null && label != null) {
+
+ Icon(
+ modifier = Modifier.size(24.dp),
+ painter = painterResource(id = if (selected) selectedIcon else unSelectedIcon),
+ contentDescription = stringResource(id = label),
+ tint = if (selected) selectedContentColor else unselectedContentColor
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Text(
+ text = stringResource(label),
+ style = MaterialTheme.typography.w400.x1,
+ color = if (selected) selectedContentColor else unselectedContentColor,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ } else {
+
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ modifier = Modifier.size(32.dp),
+ painter = painterResource(id = R.drawable.ic_baseline_add),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun AppExpandableText(
+ modifier: Modifier = Modifier,
+ textModifier: Modifier = Modifier,
+ style: TextStyle = MaterialTheme.typography.w300.x2,
+ text: String,
+ color: Color = MaterialTheme.colorScheme.onBackground,
+ collapsedMaxLine: Int = 3,
+ showMoreText: String = " ${stringResource(id = R.string.label_more)}",
+ showMoreTextStyle: TextStyle = MaterialTheme.typography.w500.x2,
+ showMoreTextColor: Color = MaterialTheme.colorScheme.primary,
+ showLessText: String = " ${stringResource(id = R.string.label_less)}",
+ showLessTextStyle: TextStyle = showMoreTextStyle,
+ showLessTextColor: Color = showMoreTextColor,
+ textAlign: TextAlign? = null
+) {
+ var isExpanded by remember { mutableStateOf(false) }
+
+ var clickable by remember { mutableStateOf(false) }
+
+ var lastCharIndex by remember { mutableIntStateOf(0) }
+
+ Box(
+ modifier = Modifier
+ .clickable(clickable) {
+ isExpanded = !isExpanded
+ }
+ .then(modifier)
+ ) {
+ Text(
+ modifier = textModifier
+ .fillMaxWidth()
+ .animateContentSize(),
+ text = buildAnnotatedString {
+ if (clickable) {
+ if (isExpanded) {
+ append(text)
+ withStyle(
+ style = showLessTextStyle.toSpanStyle().copy(
+ color = showLessTextColor
+ )
+ ) { append(showLessText) }
+ } else {
+ val adjustText = text.substring(startIndex = 0, endIndex = lastCharIndex)
+ .dropLast(showMoreText.length)
+ .dropLastWhile { Character.isWhitespace(it) || it == '.' }
+ append(adjustText)
+ withStyle(
+ style = showMoreTextStyle.toSpanStyle().copy(
+ color = showMoreTextColor
+ )
+ ) { append(showMoreText) }
+ }
+ } else {
+ append(text)
+ }
+ },
+ maxLines = if (isExpanded) Int.MAX_VALUE else collapsedMaxLine,
+ onTextLayout = { textLayoutResult ->
+ if (!isExpanded && textLayoutResult.hasVisualOverflow) {
+ clickable = true
+ lastCharIndex = textLayoutResult.getLineEnd(collapsedMaxLine - 1)
+ }
+ },
+ style = style,
+ textAlign = textAlign,
+ color = color
+ )
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/core/util/viewModel/BaseViewModel.kt b/app/src/main/java/sample/latest/news/core/util/viewModel/BaseViewModel.kt
new file mode 100644
index 0000000..966fcb7
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/core/util/viewModel/BaseViewModel.kt
@@ -0,0 +1,117 @@
+package sample.latest.news.core.util.viewModel
+
+import androidx.lifecycle.ViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.withContext
+import sample.latest.news.core.api.ApiResult
+import sample.latest.news.core.api.ExceptionHelper
+import sample.latest.news.core.api.Exceptions
+import sample.latest.news.core.model.NetworkViewState
+
+abstract class BaseViewModel : ViewModel() {
+
+ private val _networkViewState = MutableStateFlow(NetworkViewState())
+ val networkViewState = _networkViewState.asStateFlow()
+
+ private suspend fun updateNetworkViewState(
+ networkViewStates: NetworkViewState
+ ) {
+ withContext(Dispatchers.Main) {
+ _networkViewState.update {
+ networkViewStates
+ }
+ }
+ }
+
+ protected suspend fun networkLoading(requestTag: String? = null) {
+ updateNetworkViewState(
+ NetworkViewState(
+ showProgress = true,
+ requestTag = requestTag
+ )
+ )
+ }
+
+ protected suspend fun networkMoreLoading(requestTag: String? = null) {
+ updateNetworkViewState(
+ NetworkViewState(
+ showProgressMore = true,
+ requestTag = requestTag
+ )
+ )
+ }
+
+ protected open suspend fun observeNetworkState(
+ vararg results: ApiResult,
+ requestTagList: List = listOf()
+ ) {
+ var errorChecked = false
+ var networkStateList: List = mutableListOf()
+
+ results.forEachIndexed { index, result ->
+
+ // Check and get requestTag if existed
+ val requestTag = requestTagList.elementAtOrNull(index)
+
+ if (result is ApiResult.Error && !errorChecked) {
+ val networkViewState = getNetworkStateResult(result, requestTag)
+ updateNetworkViewState(networkViewState)
+ errorChecked = true
+ }
+
+ networkStateList = networkStateList.plus(getNetworkStateResult(result, requestTag))
+ }
+
+ // When all result become Success we have to handle them
+ if (!errorChecked)
+ updateNetworkViewState(
+ NetworkViewState(
+ showSuccess = true
+ )
+ )
+ }
+
+ protected open suspend fun observeNetworkState(result: ApiResult, requestTag: String) {
+ updateNetworkViewState(getNetworkStateResult(result, requestTag))
+ }
+
+ private suspend fun getNetworkStateResult(
+ result: ApiResult,
+ requestTag: String? = null
+ ): NetworkViewState {
+ return when (result) {
+ is ApiResult.Success -> {
+ NetworkViewState(
+ showSuccess = true,
+ data = castData(result, requestTag),
+ requestTag = requestTag
+ )
+ }
+
+ is ApiResult.Error -> {
+ if (result.exception is Exceptions.ValidationException<*>) {
+ NetworkViewState(
+ showValidationError = true,
+ validationError = result.exception.errors,
+ requestTag = requestTag
+ )
+ } else {
+ val errorView = ExceptionHelper.getError(result.exception)
+ NetworkViewState(
+ showError = true,
+ serverErrorMessage = errorView.serverErrorMessage,
+ errorMessage = errorView.message,
+ requestTag = requestTag
+ )
+ }
+ }
+ }
+ }
+
+ protected open suspend fun castData(result: ApiResult, requestTag: String?): Any? {
+ return (result as ApiResult.Success).data
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/features/App.kt b/app/src/main/java/sample/latest/news/features/App.kt
new file mode 100644
index 0000000..9d30381
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/App.kt
@@ -0,0 +1,7 @@
+package sample.latest.news.features
+
+import androidx.multidex.MultiDexApplication
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class App : MultiDexApplication()
diff --git a/app/src/main/java/sample/latest/news/features/home/ui/HomeScreen.kt b/app/src/main/java/sample/latest/news/features/home/ui/HomeScreen.kt
new file mode 100644
index 0000000..a65c227
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/home/ui/HomeScreen.kt
@@ -0,0 +1,13 @@
+package sample.latest.news.features.home.ui
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+@Composable
+fun HomeScreen() {
+ Box(
+ modifier = Modifier.fillMaxSize()
+ )
+}
diff --git a/app/src/main/java/sample/latest/news/features/library/ui/LibraryScreen.kt b/app/src/main/java/sample/latest/news/features/library/ui/LibraryScreen.kt
new file mode 100644
index 0000000..c1ce502
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/library/ui/LibraryScreen.kt
@@ -0,0 +1,13 @@
+package sample.latest.news.features.library.ui
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+@Composable
+fun LibraryScreen() {
+ Box(
+ modifier = Modifier.fillMaxSize()
+ )
+}
diff --git a/app/src/main/java/sample/latest/news/features/main/ui/MainActivity.kt b/app/src/main/java/sample/latest/news/features/main/ui/MainActivity.kt
new file mode 100644
index 0000000..d3f2a87
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/main/ui/MainActivity.kt
@@ -0,0 +1,61 @@
+package sample.latest.news.features.main.ui
+
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import dagger.hilt.android.AndroidEntryPoint
+import sample.latest.news.core.theme.AppTheme
+import sample.latest.news.core.util.accompanist.systemUiController.rememberSystemUiController
+import sample.latest.news.core.util.ui.BaseActivity
+import sample.latest.news.features.theme.ui.ThemeType
+
+@AndroidEntryPoint
+class MainActivity : BaseActivity() {
+
+ private val viewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ installSplashScreen()
+
+ setContent {
+
+ val theme by viewModel.theme.collectAsState(initial = null)
+
+ val isDarkTheme = when (theme) {
+ ThemeType.Dark.value -> true
+ ThemeType.Light.value -> false
+ else -> isSystemInDarkTheme()
+ }
+
+ AppTheme(darkTheme = isDarkTheme) {
+
+ val systemUiController = rememberSystemUiController()
+ val backgroundColor = MaterialTheme.colorScheme.background
+
+ SideEffect {
+ systemUiController.setSystemBarsColor(
+ color = backgroundColor
+ )
+ }
+
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = backgroundColor
+ ) {
+ MainScreen()
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/features/main/ui/MainScreen.kt b/app/src/main/java/sample/latest/news/features/main/ui/MainScreen.kt
new file mode 100644
index 0000000..69d751a
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/main/ui/MainScreen.kt
@@ -0,0 +1,170 @@
+package sample.latest.news.features.main.ui
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavController
+import androidx.navigation.NavDestination
+import androidx.navigation.NavDestination.Companion.hierarchy
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.currentBackStackEntryAsState
+import androidx.navigation.compose.rememberNavController
+import sample.latest.news.core.util.accompanist.navigationMaterial.ExperimentalMaterialNavigationApi
+import sample.latest.news.core.util.accompanist.navigationMaterial.ModalBottomSheetLayout
+import sample.latest.news.core.util.accompanist.navigationMaterial.bottomSheet
+import sample.latest.news.core.util.accompanist.navigationMaterial.rememberBottomSheetNavigator
+import sample.latest.news.core.util.navigation.NavigationRoutes
+import sample.latest.news.core.util.navigation.safeNavigate
+import sample.latest.news.core.util.ui.AppBottomNavigation
+import sample.latest.news.core.util.ui.AppBottomNavigationItem
+import sample.latest.news.features.home.ui.HomeScreen
+import sample.latest.news.features.library.ui.LibraryScreen
+import sample.latest.news.features.subscription.ui.SubscriptionScreen
+import sample.latest.news.features.theme.ui.ThemeListScreen
+import sample.latest.news.features.theme.ui.ThemeListViewModel
+import sample.latest.news.features.movie.ui.MovieListScreen
+import sample.latest.news.features.movie.ui.MovieListViewModel
+import sample.latest.news.features.movie.ui.MovieScreen
+import sample.latest.news.features.movie.ui.MovieViewModel
+
+@OptIn(ExperimentalMaterialNavigationApi::class)
+@Composable
+fun MainScreen() {
+
+ val bottomSheetNavigator = rememberBottomSheetNavigator()
+
+ val navController = rememberNavController(bottomSheetNavigator)
+
+ val navBackStackEntry by navController.currentBackStackEntryAsState()
+
+ val currentDestination = navBackStackEntry?.destination
+
+ var showBottomBar by remember { mutableStateOf(false) }
+
+ navController.addOnDestinationChangedListener { _, destination, _ ->
+ showBottomBar = when (destination.route) {
+ NavigationRoutes.Home.route, NavigationRoutes.MovieList.route, NavigationRoutes.Subscription.route, NavigationRoutes.Library.route -> true
+ else -> false
+ }
+ }
+
+ ModalBottomSheetLayout(
+ bottomSheetNavigator = bottomSheetNavigator,
+ sheetBackgroundColor = MaterialTheme.colorScheme.background,
+ sheetShape = MaterialTheme.shapes.large,
+ scrimColor = MaterialTheme.colorScheme.scrim
+ ) {
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ bottomBar = {
+ if (showBottomBar) {
+ MainBottomNavigation(
+ navController = navController,
+ currentDestination = currentDestination
+ )
+ }
+ }
+ ) { innerPadding ->
+ NavHost(
+ navController = navController,
+ startDestination = NavigationRoutes.MovieList.route,
+ route = NavigationRoutes.Root.route,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ ) {
+ composable(
+ route = NavigationRoutes.Home.route
+ ) {
+ HomeScreen()
+ }
+ composable(
+ route = NavigationRoutes.MovieList.route
+ ) {
+ val viewModel = hiltViewModel(it)
+ MovieListScreen(
+ navController = navController,
+ viewModel = viewModel
+ )
+ }
+ composable(
+ route = NavigationRoutes.Movie.route
+ ) {
+ val viewModel = hiltViewModel(it)
+ MovieScreen(
+ navController = navController,
+ viewModel = viewModel
+ )
+ }
+ composable(
+ route = NavigationRoutes.Subscription.route
+ ) {
+ SubscriptionScreen()
+ }
+ composable(
+ route = NavigationRoutes.Library.route
+ ) {
+ LibraryScreen()
+ }
+ composable(
+ route = NavigationRoutes.Home.route
+ ) {
+ HomeScreen()
+ }
+ bottomSheet(
+ route = NavigationRoutes.ThemeList.route
+ ) {
+ val viewModel = hiltViewModel(it)
+ ThemeListScreen(
+ navController = navController,
+ viewModel = viewModel
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun MainBottomNavigation(
+ navController: NavController,
+ currentDestination: NavDestination?
+) {
+ val items = listOf(
+ NavigationRoutes.Home,
+ NavigationRoutes.MovieList,
+ NavigationRoutes.Empty,
+ NavigationRoutes.Subscription,
+ NavigationRoutes.Library
+ )
+
+ AppBottomNavigation {
+
+ items.forEach { screen ->
+
+ AppBottomNavigationItem(
+ selectedIcon = screen.selectedIcon,
+ unSelectedIcon = screen.unSelectedIcon,
+ label = screen.label,
+ selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true
+ ) {
+ if (screen.route != NavigationRoutes.Empty.route) {
+
+ navController.safeNavigate(screen.route) {
+ popUpTo(NavigationRoutes.Root.route) { inclusive = true }
+ }
+
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/features/main/ui/MainViewModel.kt b/app/src/main/java/sample/latest/news/features/main/ui/MainViewModel.kt
new file mode 100644
index 0000000..a13b220
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/main/ui/MainViewModel.kt
@@ -0,0 +1,17 @@
+package sample.latest.news.features.main.ui
+
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.flowOn
+import sample.latest.news.core.preferences.domain.GetThemePrefs
+import sample.latest.news.core.util.viewModel.BaseViewModel
+import javax.inject.Inject
+
+@HiltViewModel
+class MainViewModel @Inject constructor(
+ getThemePrefs: GetThemePrefs
+) : BaseViewModel() {
+
+ val theme = getThemePrefs()
+ .flowOn(Dispatchers.IO)
+}
diff --git a/app/src/main/java/sample/latest/news/features/movie/data/MovieDao.kt b/app/src/main/java/sample/latest/news/features/movie/data/MovieDao.kt
new file mode 100644
index 0000000..666a182
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/movie/data/MovieDao.kt
@@ -0,0 +1,27 @@
+package sample.latest.news.features.movie.data
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Transaction
+import kotlinx.coroutines.flow.Flow
+import sample.latest.news.features.movie.data.entity.MovieEntity
+
+@Dao
+interface MovieDao {
+
+ @Transaction
+ @Query("SELECT * FROM MovieEntity")
+ fun getMovieList(): Flow?>
+
+ @Transaction
+ @Query("SELECT * FROM MovieEntity WHERE id = :id")
+ fun getMovie(id: Int): Flow
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertMovieList(value: List)
+
+ @Query("DELETE FROM MovieEntity")
+ suspend fun deleteMovieList()
+}
diff --git a/app/src/main/java/sample/latest/news/features/movie/data/MovieLocalDataSource.kt b/app/src/main/java/sample/latest/news/features/movie/data/MovieLocalDataSource.kt
new file mode 100644
index 0000000..caa89e8
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/movie/data/MovieLocalDataSource.kt
@@ -0,0 +1,28 @@
+package sample.latest.news.features.movie.data
+
+import androidx.room.withTransaction
+import sample.latest.news.core.db.AppDb
+import sample.latest.news.features.movie.data.entity.MovieEntity
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class MovieLocalDataSource @Inject constructor(
+ private val appDb: AppDb,
+ private val movieDao: MovieDao
+) {
+ fun getMovieList() = movieDao.getMovieList()
+
+ fun getMovie(id: Int) = movieDao.getMovie(id)
+
+ suspend fun insertMovieList(
+ page: Int,
+ value: List
+ ) {
+ appDb.withTransaction {
+ if (page == 1)
+ movieDao.deleteMovieList()
+ movieDao.insertMovieList(value)
+ }
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/features/movie/data/MovieModule.kt b/app/src/main/java/sample/latest/news/features/movie/data/MovieModule.kt
new file mode 100644
index 0000000..d585fde
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/movie/data/MovieModule.kt
@@ -0,0 +1,26 @@
+package sample.latest.news.features.movie.data
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import retrofit2.Retrofit
+import sample.latest.news.core.db.AppDb
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+class MovieModule {
+
+ @Singleton
+ @Provides
+ fun provideMovieService(retrofit: Retrofit): MovieService {
+ return retrofit.create(MovieService::class.java)
+ }
+
+ @Singleton
+ @Provides
+ fun provideMovieDao(appDb: AppDb): MovieDao {
+ return appDb.movieDao()
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/features/movie/data/MovieRemoteDataSource.kt b/app/src/main/java/sample/latest/news/features/movie/data/MovieRemoteDataSource.kt
new file mode 100644
index 0000000..5879996
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/movie/data/MovieRemoteDataSource.kt
@@ -0,0 +1,44 @@
+package sample.latest.news.features.movie.data
+
+import sample.latest.news.core.api.BaseRemoteDataSource
+import sample.latest.news.core.api.safeApiCall
+import sample.latest.news.core.util.extensions.PerPageSize
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class MovieRemoteDataSource @Inject constructor(
+ private val movieService: MovieService
+) : BaseRemoteDataSource() {
+
+ suspend fun getMovieList(
+ search: String,
+ page: Int,
+ perPageSize: Int,
+ type: String
+ ) = safeApiCall(
+ call = {
+ requestGetMovieList(
+ search = search,
+ page = page,
+ perPageSize = perPageSize,
+ type = type
+ )
+ },
+ errorMessage = "Error get movie list"
+ )
+
+ private suspend fun requestGetMovieList(
+ search: String,
+ page: Int,
+ perPageSize: Int,
+ type: String
+ ) = checkApiResult(
+ movieService.getMovieList(
+ search = search,
+ page = page,
+ perPageSize = perPageSize,
+ type = type
+ )
+ )
+}
diff --git a/app/src/main/java/sample/latest/news/features/movie/data/MovieRepository.kt b/app/src/main/java/sample/latest/news/features/movie/data/MovieRepository.kt
new file mode 100644
index 0000000..baeb777
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/movie/data/MovieRepository.kt
@@ -0,0 +1,43 @@
+package sample.latest.news.features.movie.data
+
+import sample.latest.news.core.api.ApiResult
+import sample.latest.news.core.api.Exceptions
+import sample.latest.news.core.util.NetworkHandler
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class MovieRepository @Inject constructor(
+ private val movieLocalDataSource: MovieLocalDataSource,
+ private val movieRemoteDataSource: MovieRemoteDataSource,
+ private val networkHandler: NetworkHandler
+) {
+ fun getMovieListLocal() = movieLocalDataSource.getMovieList()
+
+ fun getMovieLocal(id: Int) = movieLocalDataSource.getMovie(id)
+
+ suspend fun getMovieListRemote(
+ search: String,
+ page: Int,
+ perPageSize: Int,
+ type: String
+ ): ApiResult {
+ return if (networkHandler.hasNetworkConnection()) {
+ when (val result = movieRemoteDataSource.getMovieList(
+ search = search,
+ page = page,
+ perPageSize = perPageSize,
+ type = type
+ )) {
+ is ApiResult.Success -> ApiResult.Success(
+ movieLocalDataSource.insertMovieList(
+ page = page,
+ value = result.data.movies.map { it.toMovieEntity() }
+ )
+ )
+
+ is ApiResult.Error -> ApiResult.Error(result.exception)
+ }
+ } else ApiResult.Error(Exceptions.NetworkConnectionException())
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/features/movie/data/MovieService.kt b/app/src/main/java/sample/latest/news/features/movie/data/MovieService.kt
new file mode 100644
index 0000000..fcdd3ac
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/movie/data/MovieService.kt
@@ -0,0 +1,17 @@
+package sample.latest.news.features.movie.data
+
+import retrofit2.Response
+import retrofit2.http.GET
+import retrofit2.http.Query
+import sample.latest.news.features.movie.data.network.MovieListNetwork
+
+interface MovieService {
+
+ @GET("/share/v1/colleagues/downloadable_movies")
+ suspend fun getMovieList(
+ @Query("by_name") search: String,
+ @Query("page") page: Int,
+ @Query("per_page") perPageSize: Int,
+ @Query("by_movies[movie_type]") type: String
+ ): Response
+}
diff --git a/app/src/main/java/sample/latest/news/features/movie/data/entity/MovieEntity.kt b/app/src/main/java/sample/latest/news/features/movie/data/entity/MovieEntity.kt
new file mode 100644
index 0000000..bec6830
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/movie/data/entity/MovieEntity.kt
@@ -0,0 +1,17 @@
+package sample.latest.news.features.movie.data.entity
+
+import androidx.compose.runtime.Immutable
+import androidx.room.Entity
+import com.squareup.moshi.JsonClass
+
+@Immutable
+@JsonClass(generateAdapter = true)
+@Entity(primaryKeys = ["id"])
+data class MovieEntity(
+ val id: Int = 0,
+ val name: String = "",
+ val engName: String = "",
+ val story: String = "",
+ val generalDownloadPage: String = "",
+ val poster: String = ""
+)
diff --git a/app/src/main/java/sample/latest/news/features/movie/data/network/MovieListNetwork.kt b/app/src/main/java/sample/latest/news/features/movie/data/network/MovieListNetwork.kt
new file mode 100644
index 0000000..ffedd77
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/movie/data/network/MovieListNetwork.kt
@@ -0,0 +1,8 @@
+package sample.latest.news.features.movie.data.network
+
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class MovieListNetwork(
+ val movies: List = emptyList()
+)
diff --git a/app/src/main/java/sample/latest/news/features/movie/data/network/MovieNetwork.kt b/app/src/main/java/sample/latest/news/features/movie/data/network/MovieNetwork.kt
new file mode 100644
index 0000000..fa482ab
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/movie/data/network/MovieNetwork.kt
@@ -0,0 +1,26 @@
+package sample.latest.news.features.movie.data.network
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+import sample.latest.news.features.movie.data.entity.MovieEntity
+
+@JsonClass(generateAdapter = true)
+data class MovieNetwork(
+ val id: Int = 0,
+ val name: String = "",
+ @Json(name = "eng_name")
+ val engName: String = "",
+ val story: String = "",
+ @Json(name = "general_download_page")
+ val generalDownloadPage: String = "",
+ val poster: String = ""
+) {
+ fun toMovieEntity() = MovieEntity(
+ id = id,
+ name = name,
+ engName = engName,
+ story = story,
+ generalDownloadPage = generalDownloadPage,
+ poster = poster
+ )
+}
diff --git a/app/src/main/java/sample/latest/news/features/movie/domain/GetMovieListLocal.kt b/app/src/main/java/sample/latest/news/features/movie/domain/GetMovieListLocal.kt
new file mode 100644
index 0000000..a380d22
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/movie/domain/GetMovieListLocal.kt
@@ -0,0 +1,10 @@
+package sample.latest.news.features.movie.domain
+
+import sample.latest.news.features.movie.data.MovieRepository
+import javax.inject.Inject
+
+class GetMovieListLocal @Inject constructor(
+ private val movieRepository: MovieRepository
+) {
+ operator fun invoke() = movieRepository.getMovieListLocal()
+}
diff --git a/app/src/main/java/sample/latest/news/features/movie/domain/GetMovieListRemote.kt b/app/src/main/java/sample/latest/news/features/movie/domain/GetMovieListRemote.kt
new file mode 100644
index 0000000..f52beb0
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/movie/domain/GetMovieListRemote.kt
@@ -0,0 +1,20 @@
+package sample.latest.news.features.movie.domain
+
+import sample.latest.news.features.movie.data.MovieRepository
+import javax.inject.Inject
+
+class GetMovieListRemote @Inject constructor(
+ private val movieRepository: MovieRepository
+) {
+ suspend operator fun invoke(
+ search: String,
+ page: Int,
+ perPageSize: Int,
+ type: String
+ ) = movieRepository.getMovieListRemote(
+ search = search,
+ page = page,
+ perPageSize = perPageSize,
+ type = type
+ )
+}
diff --git a/app/src/main/java/sample/latest/news/features/movie/domain/GetMovieLocal.kt b/app/src/main/java/sample/latest/news/features/movie/domain/GetMovieLocal.kt
new file mode 100644
index 0000000..f354052
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/movie/domain/GetMovieLocal.kt
@@ -0,0 +1,10 @@
+package sample.latest.news.features.movie.domain
+
+import sample.latest.news.features.movie.data.MovieRepository
+import javax.inject.Inject
+
+class GetMovieLocal @Inject constructor(
+ private val movieRepository: MovieRepository
+) {
+ operator fun invoke(id: Int) = movieRepository.getMovieLocal(id)
+}
diff --git a/app/src/main/java/sample/latest/news/features/movie/ui/MovieListScreen.kt b/app/src/main/java/sample/latest/news/features/movie/ui/MovieListScreen.kt
new file mode 100644
index 0000000..d15de48
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/movie/ui/MovieListScreen.kt
@@ -0,0 +1,286 @@
+package sample.latest.news.features.movie.ui
+
+import android.content.Context
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.GridItemSpan
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.grid.rememberLazyGridState
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavController
+import coil.compose.AsyncImage
+import coil.request.ImageRequest
+import sample.latest.news.R
+import sample.latest.news.core.theme.isLight
+import sample.latest.news.core.theme.w400
+import sample.latest.news.core.theme.x3
+import sample.latest.news.core.util.extensions.OnBottomReached
+import sample.latest.news.core.util.extensions.PerPageSize
+import sample.latest.news.core.util.extensions.parseServerErrorMessage
+import sample.latest.news.core.util.navigation.NavigationRoutes
+import sample.latest.news.core.util.navigation.safeNavigate
+import sample.latest.news.core.util.snackBar.showAppSnackBar
+import sample.latest.news.core.util.ui.AppBox
+import sample.latest.news.core.util.ui.AppCard
+import sample.latest.news.core.util.ui.AppCircularProgressIndicator
+import sample.latest.news.core.util.ui.AppSwipeRefresh
+import sample.latest.news.features.movie.data.entity.MovieEntity
+
+@OptIn(ExperimentalMaterialApi::class)
+@Composable
+fun MovieListScreen(
+ navController: NavController,
+ viewModel: MovieListViewModel
+) {
+ val context = LocalContext.current
+
+ val search by viewModel.search.collectAsState()
+
+ val movieList by viewModel.movieList.collectAsState(initial = null)
+
+ val snackBarHostState = remember { SnackbarHostState() }
+
+ val networkViewState by viewModel.networkViewState.collectAsState()
+
+ LaunchedEffect(networkViewState) {
+
+ if (networkViewState.showError) {
+
+ snackBarHostState.showAppSnackBar(
+ message = context.parseServerErrorMessage(networkViewState),
+ duration = SnackbarDuration.Short
+ )
+ }
+ }
+
+ val lazyGridState = rememberLazyGridState()
+
+ val gridColumns = 2
+
+ AppBox(
+ hostState = snackBarHostState,
+ onRefresh = viewModel::refresh
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ MovieListToolbarItem(
+ navController = navController,
+ viewModel = viewModel,
+ search = search
+ )
+
+ AppSwipeRefresh(
+ refreshing = networkViewState.showProgress,
+ onRefresh = viewModel::refresh
+ ) {
+ LazyVerticalGrid(
+ modifier = Modifier.fillMaxSize(),
+ columns = GridCells.Fixed(gridColumns),
+ contentPadding = PaddingValues(8.dp),
+ state = lazyGridState
+ ) {
+ items(
+ items = movieList ?: emptyList(),
+ key = { item -> item.id }
+ ) { item ->
+ MovieListItem(
+ context = context,
+ navController = navController,
+ item = item
+ )
+ }
+
+ if (networkViewState.showProgressMore) {
+
+ item(span = { GridItemSpan(gridColumns) }) {
+ MovieListProgressMoreItem()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ lazyGridState.OnBottomReached {
+
+ if (!networkViewState.showProgress || !networkViewState.showProgressMore) {
+
+ movieList?.let {
+ if (it.size >= PerPageSize)
+ viewModel.getNextPage()
+ }
+ }
+ }
+}
+
+@Composable
+fun MovieListToolbarItem(
+ navController: NavController,
+ viewModel: MovieListViewModel,
+ search: String
+) {
+ Row(
+ modifier = Modifier
+ .padding(16.dp)
+ .fillMaxWidth()
+ ) {
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .clip(MaterialTheme.shapes.small)
+ .background(MaterialTheme.colorScheme.surface)
+ .clickable {
+ navController.safeNavigate(NavigationRoutes.ThemeList.route)
+ },
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ modifier = Modifier.size(24.dp),
+ painter = painterResource(id = if (MaterialTheme.colorScheme.isLight()) R.drawable.ic_dark_mode_off else R.drawable.ic_dark_mode_on),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ BasicTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(48.dp),
+ value = search,
+ textStyle = MaterialTheme.typography.w400.x3.copy(
+ color = MaterialTheme.colorScheme.onSurface
+ ),
+ onValueChange = viewModel::setSearch,
+ cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Text,
+ imeAction = ImeAction.Done
+ ),
+ decorationBox = { innerTextField ->
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .clip(MaterialTheme.shapes.small)
+ .background(MaterialTheme.colorScheme.surface)
+ .padding(horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ modifier = Modifier.size(24.dp),
+ painter = painterResource(id = R.drawable.ic_search),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurface
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ ) {
+
+ if (search.isEmpty()) {
+ Text(
+ text = stringResource(id = R.string.txt_search),
+ style = MaterialTheme.typography.w400.x3,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+
+ innerTextField()
+ }
+ }
+ }
+ )
+ }
+}
+
+@Composable
+fun MovieListItem(
+ context: Context,
+ navController: NavController,
+ item: MovieEntity,
+ width: Dp? = null
+) {
+ AppCard(
+ modifier = Modifier
+ .padding(8.dp)
+ .then(
+ if (width != null) {
+ Modifier.width(width)
+ } else {
+ Modifier.fillMaxWidth()
+ }
+ )
+ .aspectRatio(3f / 4f)
+ ) {
+ AsyncImage(
+ model = ImageRequest.Builder(context)
+ .data(item.poster)
+ .crossfade(true)
+ .build(),
+ contentDescription = null,
+ modifier = Modifier
+ .fillMaxSize()
+ .clickable {
+ val route = NavigationRoutes.Movie.route + "/${item.id}"
+ navController.safeNavigate(route)
+ },
+ contentScale = ContentScale.Crop
+ )
+ }
+}
+
+@Composable
+fun MovieListProgressMoreItem() {
+ Box(
+ modifier = Modifier
+ .padding(8.dp)
+ .fillMaxWidth(),
+ contentAlignment = Alignment.Center
+ ) {
+ AppCircularProgressIndicator()
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/features/movie/ui/MovieListViewModel.kt b/app/src/main/java/sample/latest/news/features/movie/ui/MovieListViewModel.kt
new file mode 100644
index 0000000..d2d65df
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/movie/ui/MovieListViewModel.kt
@@ -0,0 +1,80 @@
+package sample.latest.news.features.movie.ui
+
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.launch
+import sample.latest.news.core.util.extensions.PerPageSize
+import sample.latest.news.core.util.viewModel.BaseViewModel
+import sample.latest.news.features.movie.domain.GetMovieListLocal
+import sample.latest.news.features.movie.domain.GetMovieListRemote
+import javax.inject.Inject
+
+@HiltViewModel
+class MovieListViewModel @Inject constructor(
+ getMovieListLocal: GetMovieListLocal,
+ private val getMovieListRemote: GetMovieListRemote
+) : BaseViewModel() {
+
+ private var getMovieListJob: Job? = null
+
+ private var currentPage = 1
+
+ private val _search = MutableStateFlow("")
+ val search = _search.asStateFlow()
+
+ val movieList = getMovieListLocal()
+ .flowOn(Dispatchers.IO)
+
+ init {
+ getData()
+ }
+
+ private fun getData(withDelay: Boolean = false) {
+
+ getMovieListJob?.let {
+ if (it.isActive)
+ it.cancel()
+ }
+
+ getMovieListJob = viewModelScope.launch(Dispatchers.IO) {
+
+ if (withDelay)
+ delay(500)
+
+ if (currentPage == 1)
+ networkLoading(MovieRequestTag.GetMovieList.name)
+ else networkMoreLoading(MovieRequestTag.GetMovieList.name)
+
+ observeNetworkState(
+ getMovieListRemote(
+ search = _search.value,
+ page = currentPage,
+ perPageSize = PerPageSize,
+ type = MovieType.Foreign.value
+ ),
+ MovieRequestTag.GetMovieList.name
+ )
+ }
+ }
+
+ fun refresh(withDelay: Boolean = false) {
+ currentPage = 1
+ getData(withDelay = withDelay)
+ }
+
+ fun getNextPage() {
+ currentPage++
+ getData()
+ }
+
+ fun setSearch(value: String) {
+ _search.value = value
+ refresh(withDelay = true)
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/features/movie/ui/MovieRequestTag.kt b/app/src/main/java/sample/latest/news/features/movie/ui/MovieRequestTag.kt
new file mode 100644
index 0000000..2246e65
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/movie/ui/MovieRequestTag.kt
@@ -0,0 +1,5 @@
+package sample.latest.news.features.movie.ui
+
+enum class MovieRequestTag {
+ GetMovieList
+}
diff --git a/app/src/main/java/sample/latest/news/features/movie/ui/MovieScreen.kt b/app/src/main/java/sample/latest/news/features/movie/ui/MovieScreen.kt
new file mode 100644
index 0000000..b85bb61
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/movie/ui/MovieScreen.kt
@@ -0,0 +1,519 @@
+package sample.latest.news.features.movie.ui
+
+import android.content.Context
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavController
+import coil.compose.AsyncImage
+import coil.request.ImageRequest
+import sample.latest.news.R
+import sample.latest.news.core.theme.w300
+import sample.latest.news.core.theme.w400
+import sample.latest.news.core.theme.w500
+import sample.latest.news.core.theme.w900
+import sample.latest.news.core.theme.x1
+import sample.latest.news.core.theme.x2
+import sample.latest.news.core.theme.x3
+import sample.latest.news.core.theme.x4
+import sample.latest.news.core.util.ui.AppButton
+import sample.latest.news.core.util.ui.AppDivider
+import sample.latest.news.core.util.ui.AppExpandableText
+import sample.latest.news.core.util.ui.AppIconButton
+import sample.latest.news.core.util.ui.DividerThickness
+import sample.latest.news.features.movie.data.entity.MovieEntity
+
+@Composable
+fun MovieScreen(
+ navController: NavController,
+ viewModel: MovieViewModel
+) {
+ val context = LocalContext.current
+
+ val movie by viewModel.movie.collectAsState(initial = null)
+
+ val movieSimilarList by viewModel.movieSimilarList.collectAsState(initial = null)
+
+ val showFullInfo by viewModel.showFullInfo.collectAsState()
+
+ Column(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ movie?.let { item ->
+
+ MovieVideoItem(
+ context = context,
+ navController = navController,
+ item = item
+ )
+
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .weight(1f)
+ ) {
+ item {
+ MovieInfoItem(
+ viewModel = viewModel,
+ item = item,
+ showFullInfo = showFullInfo
+ )
+ }
+
+ item {
+ MovieOptionListItem()
+ }
+
+ item {
+ MovieDescItem(
+ item = item
+ )
+ }
+
+ item {
+ MovieSubscribeItem(
+ context = context
+ )
+ }
+
+ item {
+ MovieAddCommentItem(
+ context = context
+ )
+ }
+
+ item {
+ MovieSimilarListItem(
+ context = context,
+ navController = navController,
+ movieSimilarList = movieSimilarList
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun MovieVideoItem(
+ context: Context,
+ navController: NavController,
+ item: MovieEntity
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(16f / 9f)
+ .background(MaterialTheme.colorScheme.surface)
+ ) {
+ AsyncImage(
+ model = ImageRequest.Builder(context)
+ .data(item.poster)
+ .crossfade(true)
+ .build(),
+ contentDescription = null,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop
+ )
+
+ Icon(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .size(48.dp),
+ painter = painterResource(id = R.drawable.ic_baseline_play_circle_outline),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimary
+ )
+
+ AppIconButton(
+ modifier = Modifier.align(Alignment.TopStart),
+ iconResource = R.drawable.ic_arrow_back,
+ color = MaterialTheme.colorScheme.onPrimary
+ ) {
+ navController.navigateUp()
+ }
+ }
+
+ AppDivider()
+}
+
+@Composable
+fun MovieInfoItem(
+ viewModel: MovieViewModel,
+ item: MovieEntity,
+ showFullInfo: Boolean
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = viewModel::toggleShowFullInfo)
+ .padding(16.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ modifier = Modifier.weight(1f),
+ text = item.name,
+ style = MaterialTheme.typography.w900.x4,
+ color = MaterialTheme.colorScheme.onBackground,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ Icon(
+ modifier = Modifier.size(24.dp),
+ painter = painterResource(id = if (showFullInfo) R.drawable.ic_baseline_keyboard_arrow_up else R.drawable.ic_baseline_keyboard_arrow_down),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onBackground
+ )
+ }
+
+ AnimatedVisibility(
+ visible = showFullInfo
+ ) {
+ Text(
+ modifier = Modifier.padding(top = 8.dp),
+ text = item.engName,
+ style = MaterialTheme.typography.w500.x2,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ }
+
+ Text(
+ modifier = Modifier.padding(top = 8.dp),
+ text = stringResource(R.string.txt_fake_view_and_date),
+ style = MaterialTheme.typography.w300.x1,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ }
+}
+
+@Composable
+fun MovieOptionListItem() {
+ LazyRow(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(36.dp),
+ contentPadding = PaddingValues(horizontal = 8.dp)
+ ) {
+ // Like
+ item {
+ Row(
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .fillMaxHeight()
+ .clip(MaterialTheme.shapes.extraLarge)
+ .background(MaterialTheme.colorScheme.surface),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxHeight()
+ .clickable { }
+ .padding(horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ modifier = Modifier.size(16.dp),
+ painter = painterResource(id = R.drawable.ic_thumb_up),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurface
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Text(
+ text = stringResource(id = R.string.txt_fake_like_numbers),
+ style = MaterialTheme.typography.w400.x1,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+
+ Box(
+ modifier = Modifier
+ .height(12.dp)
+ .width(DividerThickness)
+ .background(MaterialTheme.colorScheme.onSurface)
+ )
+
+ Box(
+ modifier = Modifier
+ .fillMaxHeight()
+ .clickable { }
+ .padding(horizontal = 16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ modifier = Modifier.size(16.dp),
+ painter = painterResource(id = R.drawable.ic_thumb_down),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+ }
+
+ // Share
+ item {
+ MovieOptionItem(
+ modifier = Modifier.padding(horizontal = 8.dp),
+ iconResource = R.drawable.ic_share,
+ textResource = R.string.label_share
+ ) {}
+ }
+
+ // Download
+ item {
+ MovieOptionItem(
+ modifier = Modifier.padding(horizontal = 8.dp),
+ iconResource = R.drawable.ic_cloud_download,
+ textResource = R.string.label_download
+ ) {}
+ }
+
+ // Save
+ item {
+ MovieOptionItem(
+ modifier = Modifier.padding(horizontal = 8.dp),
+ iconResource = R.drawable.ic_save,
+ textResource = R.string.label_save
+ ) {}
+ }
+ }
+}
+
+@Composable
+fun MovieOptionItem(
+ modifier: Modifier = Modifier,
+ iconResource: Int,
+ textResource: Int,
+ onClick: () -> Unit
+) {
+ Row(
+ modifier = modifier
+ .fillMaxHeight()
+ .clip(MaterialTheme.shapes.extraLarge)
+ .background(MaterialTheme.colorScheme.surface)
+ .clickable(onClick = onClick)
+ .padding(horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ modifier = Modifier.size(16.dp),
+ painter = painterResource(id = iconResource),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurface
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Text(
+ text = stringResource(id = textResource),
+ style = MaterialTheme.typography.w400.x1,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+}
+
+@Composable
+fun MovieDescItem(
+ item: MovieEntity
+) {
+ AppExpandableText(
+ modifier = Modifier.padding(16.dp),
+ text = item.story
+ )
+}
+
+@Composable
+fun MovieSubscribeItem(
+ context: Context
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ AsyncImage(
+ model = ImageRequest.Builder(context)
+ .data(stringResource(id = R.string.link_latest_news_logo))
+ .crossfade(true)
+ .build(),
+ contentDescription = null,
+ modifier = Modifier
+ .size(56.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.surface),
+ contentScale = ContentScale.Crop
+ )
+
+ Column(
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .weight(1f)
+ ) {
+ Text(
+ text = stringResource(id = R.string.label_latest_news),
+ style = MaterialTheme.typography.w900.x3,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ Text(
+ modifier = Modifier.padding(top = 4.dp),
+ text = stringResource(id = R.string.txt_fake_subscriber),
+ style = MaterialTheme.typography.w300.x1,
+ color = MaterialTheme.colorScheme.onBackground,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+
+ AppButton(
+ text = stringResource(id = R.string.label_subscribe),
+ isWrap = true,
+ wrapPadding = 16.dp,
+ height = 32.dp,
+ textStyle = MaterialTheme.typography.w500.x2
+ ) {}
+ }
+
+ AppDivider(top = 16.dp, end = 16.dp, start = 16.dp)
+}
+
+@Composable
+fun MovieAddCommentItem(
+ context: Context
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ modifier = Modifier.weight(1f),
+ text = stringResource(id = R.string.txt_fake_comments),
+ style = MaterialTheme.typography.w300.x2,
+ color = MaterialTheme.colorScheme.onBackground,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ Icon(
+ modifier = Modifier.size(24.dp),
+ painter = painterResource(id = R.drawable.ic_baseline_keyboard_arrow_down),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onBackground
+ )
+ }
+
+ Row(
+ modifier = Modifier
+ .padding(top = 16.dp)
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ AsyncImage(
+ model = ImageRequest.Builder(context)
+ .data(stringResource(id = R.string.link_avatar))
+ .crossfade(true)
+ .build(),
+ contentDescription = null,
+ modifier = Modifier
+ .size(44.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.surface),
+ contentScale = ContentScale.Crop
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .height(44.dp)
+ .clip(MaterialTheme.shapes.small)
+ .background(MaterialTheme.colorScheme.surface)
+ .padding(horizontal = 16.dp),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ Text(
+ text = stringResource(id = R.string.txt_write_comment),
+ style = MaterialTheme.typography.w300.x2,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun MovieSimilarListItem(
+ context: Context,
+ navController: NavController,
+ movieSimilarList: List?
+) {
+ Text(
+ modifier = Modifier.padding(top = 16.dp, end = 16.dp, start = 16.dp),
+ text = stringResource(id = R.string.label_similar_items),
+ style = MaterialTheme.typography.w900.x3,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+
+ LazyRow(
+ modifier = Modifier.fillMaxWidth(),
+ contentPadding = PaddingValues(8.dp)
+ ) {
+ items(
+ items = movieSimilarList ?: emptyList(),
+ key = { item -> item.id }
+ ) { item ->
+ MovieListItem(
+ context = context,
+ navController = navController,
+ item = item,
+ width = 152.dp
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/features/movie/ui/MovieType.kt b/app/src/main/java/sample/latest/news/features/movie/ui/MovieType.kt
new file mode 100644
index 0000000..9633d88
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/movie/ui/MovieType.kt
@@ -0,0 +1,6 @@
+package sample.latest.news.features.movie.ui
+
+enum class MovieType(val value: String) {
+ Iranian("iranian"),
+ Foreign("foreign")
+}
diff --git a/app/src/main/java/sample/latest/news/features/movie/ui/MovieViewModel.kt b/app/src/main/java/sample/latest/news/features/movie/ui/MovieViewModel.kt
new file mode 100644
index 0000000..c2f96b9
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/movie/ui/MovieViewModel.kt
@@ -0,0 +1,40 @@
+package sample.latest.news.features.movie.ui
+
+import androidx.lifecycle.SavedStateHandle
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import sample.latest.news.core.util.navigation.NavigationKey
+import sample.latest.news.core.util.viewModel.BaseViewModel
+import sample.latest.news.features.movie.domain.GetMovieListLocal
+import sample.latest.news.features.movie.domain.GetMovieLocal
+import javax.inject.Inject
+
+@HiltViewModel
+class MovieViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
+ getMovieListLocal: GetMovieListLocal,
+ getMovieLocal: GetMovieLocal
+) : BaseViewModel() {
+
+ private val id by lazy {
+ savedStateHandle.get(NavigationKey.KEY_ID)?.toIntOrNull() ?: 0
+ }
+
+ val movie = getMovieLocal(id)
+ .flowOn(Dispatchers.IO)
+
+ val movieSimilarList = getMovieListLocal().map {
+ it?.filter { item -> item.id != id }?.shuffled()
+ }.flowOn(Dispatchers.IO)
+
+ private val _showFullInfo = MutableStateFlow(false)
+ val showFullInfo = _showFullInfo.asStateFlow()
+
+ fun toggleShowFullInfo() {
+ _showFullInfo.value = !_showFullInfo.value
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/features/subscription/ui/SubscriptionScreen.kt b/app/src/main/java/sample/latest/news/features/subscription/ui/SubscriptionScreen.kt
new file mode 100644
index 0000000..8e23e29
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/subscription/ui/SubscriptionScreen.kt
@@ -0,0 +1,13 @@
+package sample.latest.news.features.subscription.ui
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+@Composable
+fun SubscriptionScreen() {
+ Box(
+ modifier = Modifier.fillMaxSize()
+ )
+}
diff --git a/app/src/main/java/sample/latest/news/features/theme/ui/ThemeListScreen.kt b/app/src/main/java/sample/latest/news/features/theme/ui/ThemeListScreen.kt
new file mode 100644
index 0000000..81e546c
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/theme/ui/ThemeListScreen.kt
@@ -0,0 +1,104 @@
+package sample.latest.news.features.theme.ui
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavController
+import sample.latest.news.R
+import sample.latest.news.core.theme.w400
+import sample.latest.news.core.theme.w600
+import sample.latest.news.core.theme.w900
+import sample.latest.news.core.theme.x2
+import sample.latest.news.core.theme.x4
+import sample.latest.news.core.util.ui.AppBottomSheetColumn
+import sample.latest.news.core.util.ui.AppDivider
+
+@Composable
+fun ThemeListScreen(
+ navController: NavController,
+ viewModel: ThemeListViewModel
+) {
+ val theme by viewModel.theme.collectAsState(initial = null)
+
+ val themeList = listOf(
+ ThemeType.SystemDefault.value,
+ ThemeType.Light.value,
+ ThemeType.Dark.value
+ )
+
+ AppBottomSheetColumn {
+
+ Text(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ text = stringResource(id = R.string.label_choose_theme),
+ style = MaterialTheme.typography.w900.x4,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ items(
+ items = themeList
+ ) { item ->
+ ThemeListItem(
+ item = item,
+ selected = item == theme
+ ) {
+ viewModel.updateTheme(item)
+ navController.navigateUp()
+ }
+
+ AppDivider(horizontal = 16.dp)
+ }
+ }
+ }
+}
+
+@Composable
+fun ThemeListItem(
+ item: Int,
+ selected: Boolean,
+ onClick: () -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(vertical = 24.dp, horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(id = ThemeUtil.getThemeName(item)),
+ style = MaterialTheme.typography.w400.x4,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+
+ if (selected) {
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ Text(
+ text = stringResource(id = R.string.label_selected),
+ style = MaterialTheme.typography.w600.x2,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/features/theme/ui/ThemeListViewModel.kt b/app/src/main/java/sample/latest/news/features/theme/ui/ThemeListViewModel.kt
new file mode 100644
index 0000000..ec8660f
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/theme/ui/ThemeListViewModel.kt
@@ -0,0 +1,24 @@
+package sample.latest.news.features.theme.ui
+
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.runBlocking
+import sample.latest.news.core.preferences.domain.DoUpdateThemePrefs
+import sample.latest.news.core.preferences.domain.GetThemePrefs
+import sample.latest.news.core.util.viewModel.BaseViewModel
+import javax.inject.Inject
+
+@HiltViewModel
+class ThemeListViewModel @Inject constructor(
+ getThemePrefs: GetThemePrefs,
+ private val updateThemePrefs: DoUpdateThemePrefs
+) : BaseViewModel() {
+
+ val theme = getThemePrefs()
+ .flowOn(Dispatchers.IO)
+
+ fun updateTheme(value: Int) {
+ runBlocking { updateThemePrefs(value) }
+ }
+}
diff --git a/app/src/main/java/sample/latest/news/features/theme/ui/ThemeType.kt b/app/src/main/java/sample/latest/news/features/theme/ui/ThemeType.kt
new file mode 100644
index 0000000..d073055
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/theme/ui/ThemeType.kt
@@ -0,0 +1,7 @@
+package sample.latest.news.features.theme.ui
+
+enum class ThemeType(val value: Int) {
+ SystemDefault(0),
+ Light(1),
+ Dark(2)
+}
diff --git a/app/src/main/java/sample/latest/news/features/theme/ui/ThemeUtil.kt b/app/src/main/java/sample/latest/news/features/theme/ui/ThemeUtil.kt
new file mode 100644
index 0000000..9eefdd0
--- /dev/null
+++ b/app/src/main/java/sample/latest/news/features/theme/ui/ThemeUtil.kt
@@ -0,0 +1,14 @@
+package sample.latest.news.features.theme.ui
+
+import sample.latest.news.R
+
+object ThemeUtil {
+
+ fun getThemeName(value: Int?): Int {
+ return when (value) {
+ ThemeType.Dark.value -> R.string.label_dark
+ ThemeType.Light.value -> R.string.label_light
+ else -> R.string.label_system_default
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml
new file mode 100644
index 0000000..3042918
--- /dev/null
+++ b/app/src/main/res/drawable/ic_arrow_back.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_baseline_add.xml b/app/src/main/res/drawable/ic_baseline_add.xml
new file mode 100644
index 0000000..f3b971b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_add.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_baseline_home.xml b/app/src/main/res/drawable/ic_baseline_home.xml
new file mode 100644
index 0000000..7f3899c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_home.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_down.xml b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_down.xml
new file mode 100644
index 0000000..9e345b8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_down.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_up.xml b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_up.xml
new file mode 100644
index 0000000..1227182
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_up.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_baseline_play_circle_outline.xml b/app/src/main/res/drawable/ic_baseline_play_circle_outline.xml
new file mode 100644
index 0000000..275dd58
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_play_circle_outline.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_baseline_subscriptions.xml b/app/src/main/res/drawable/ic_baseline_subscriptions.xml
new file mode 100644
index 0000000..31a65b9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_subscriptions.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_baseline_video_library.xml b/app/src/main/res/drawable/ic_baseline_video_library.xml
new file mode 100644
index 0000000..562e35d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_video_library.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_baseline_videocam.xml b/app/src/main/res/drawable/ic_baseline_videocam.xml
new file mode 100644
index 0000000..9ab527b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_videocam.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_cloud_download.xml b/app/src/main/res/drawable/ic_cloud_download.xml
new file mode 100644
index 0000000..2be6475
--- /dev/null
+++ b/app/src/main/res/drawable/ic_cloud_download.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_dark_mode_off.xml b/app/src/main/res/drawable/ic_dark_mode_off.xml
new file mode 100644
index 0000000..f10f6cb
--- /dev/null
+++ b/app/src/main/res/drawable/ic_dark_mode_off.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_dark_mode_on.xml b/app/src/main/res/drawable/ic_dark_mode_on.xml
new file mode 100644
index 0000000..cbeba0c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_dark_mode_on.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..07d5da9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..2b068d1
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_outline_home.xml b/app/src/main/res/drawable/ic_outline_home.xml
new file mode 100644
index 0000000..b5e5f2c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_outline_home.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_outline_subscriptions.xml b/app/src/main/res/drawable/ic_outline_subscriptions.xml
new file mode 100644
index 0000000..1024c0a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_outline_subscriptions.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_outline_video_library.xml b/app/src/main/res/drawable/ic_outline_video_library.xml
new file mode 100644
index 0000000..610804d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_outline_video_library.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_outline_videocam.xml b/app/src/main/res/drawable/ic_outline_videocam.xml
new file mode 100644
index 0000000..c6479fb
--- /dev/null
+++ b/app/src/main/res/drawable/ic_outline_videocam.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_save.xml b/app/src/main/res/drawable/ic_save.xml
new file mode 100644
index 0000000..d3c4115
--- /dev/null
+++ b/app/src/main/res/drawable/ic_save.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..b2211b1
--- /dev/null
+++ b/app/src/main/res/drawable/ic_search.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml
new file mode 100644
index 0000000..84f36cd
--- /dev/null
+++ b/app/src/main/res/drawable/ic_share.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_thumb_down.xml b/app/src/main/res/drawable/ic_thumb_down.xml
new file mode 100644
index 0000000..4757452
--- /dev/null
+++ b/app/src/main/res/drawable/ic_thumb_down.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_thumb_up.xml b/app/src/main/res/drawable/ic_thumb_up.xml
new file mode 100644
index 0000000..55a16b6
--- /dev/null
+++ b/app/src/main/res/drawable/ic_thumb_up.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/font/iran_yekan_x_w100.ttf b/app/src/main/res/font/iran_yekan_x_w100.ttf
new file mode 100644
index 0000000..6c61c93
Binary files /dev/null and b/app/src/main/res/font/iran_yekan_x_w100.ttf differ
diff --git a/app/src/main/res/font/iran_yekan_x_w200.ttf b/app/src/main/res/font/iran_yekan_x_w200.ttf
new file mode 100644
index 0000000..e4bf797
Binary files /dev/null and b/app/src/main/res/font/iran_yekan_x_w200.ttf differ
diff --git a/app/src/main/res/font/iran_yekan_x_w300.ttf b/app/src/main/res/font/iran_yekan_x_w300.ttf
new file mode 100644
index 0000000..1408ec2
Binary files /dev/null and b/app/src/main/res/font/iran_yekan_x_w300.ttf differ
diff --git a/app/src/main/res/font/iran_yekan_x_w400.ttf b/app/src/main/res/font/iran_yekan_x_w400.ttf
new file mode 100644
index 0000000..c96391a
Binary files /dev/null and b/app/src/main/res/font/iran_yekan_x_w400.ttf differ
diff --git a/app/src/main/res/font/iran_yekan_x_w500.ttf b/app/src/main/res/font/iran_yekan_x_w500.ttf
new file mode 100644
index 0000000..20d3300
Binary files /dev/null and b/app/src/main/res/font/iran_yekan_x_w500.ttf differ
diff --git a/app/src/main/res/font/iran_yekan_x_w600.ttf b/app/src/main/res/font/iran_yekan_x_w600.ttf
new file mode 100644
index 0000000..742779f
Binary files /dev/null and b/app/src/main/res/font/iran_yekan_x_w600.ttf differ
diff --git a/app/src/main/res/font/iran_yekan_x_w700.ttf b/app/src/main/res/font/iran_yekan_x_w700.ttf
new file mode 100644
index 0000000..85c722c
Binary files /dev/null and b/app/src/main/res/font/iran_yekan_x_w700.ttf differ
diff --git a/app/src/main/res/font/iran_yekan_x_w800.ttf b/app/src/main/res/font/iran_yekan_x_w800.ttf
new file mode 100644
index 0000000..fcb9df7
Binary files /dev/null and b/app/src/main/res/font/iran_yekan_x_w800.ttf differ
diff --git a/app/src/main/res/font/iran_yekan_x_w900.ttf b/app/src/main/res/font/iran_yekan_x_w900.ttf
new file mode 100644
index 0000000..1a258d2
Binary files /dev/null and b/app/src/main/res/font/iran_yekan_x_w900.ttf differ
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..6f3b755
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ 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..6f3b755
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..c209e78
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..b2dfe3d
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..4f0f1d6
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..62b611d
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..948a307
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..1b9a695
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..28d4b77
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9287f50
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..aa7d642
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9126ae3
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..a51dcf8
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #1C1C1C
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..5951873
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,39 @@
+
+ latest-news
+
+ https://upload.wikimedia.org/wikipedia/commons/thumb/a/ad/Akharinkhabar_pic.png/480px-Akharinkhabar_pic.png
+ https://platformboy.com/wp-content/uploads/2022/10/%D8%B9%DA%A9%D8%B3-%D9%BE%D8%B1%D9%88%D9%81%D8%A7%DB%8C%D9%84-%D9%85%D8%B1%D8%AF-%D8%B1%DB%8C%D8%B4-%D8%AF%D8%A7%D8%B1.png
+
+ ارتباط با سرور برقرار نیست :(
+ متاسفیم؛ بنظر میرسه مشکلی بهوجود اومده!
+ لطفا به اینترنت متصل شین.
+
+ جستجو…
+ نوشتن نظر…
+ 10 میلیون بازدید\u0020\u0020\u0020•\u0020\u0020\u00205 ماه قبل
+ 15.4 هزار
+ 11.5 میلیون مشترک
+ 15.6 هزار نفر نظر دادن
+
+ آخرین خبر
+ متوجه شدم
+ تلاش مجدد
+ اختیاری
+ پیشفرض سیستم
+ روشن
+ تاریک
+ انتخابشده
+ انتخاب تِم
+ خونه
+ فیلم
+ اشتراک
+ کتابخونه
+ اشتراکگذاری
+ دانلود
+ ذخیره
+ بیشتر
+ کمتر
+ خرید اشتراک
+ موارد مشابه
+
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..5fb4096
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..fa0f996
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..9ee9997
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/test/java/sample/latest/news/ExampleUnitTest.kt b/app/src/test/java/sample/latest/news/ExampleUnitTest.kt
new file mode 100644
index 0000000..51b3eb8
--- /dev/null
+++ b/app/src/test/java/sample/latest/news/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package sample.latest.news
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..e9fe3bf
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,7 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ id("com.android.application") version "8.1.4" apply false
+ id("org.jetbrains.kotlin.android") version "1.9.10" apply false
+ id("com.google.devtools.ksp") version "1.9.10-1.0.13" apply false
+ id("com.google.dagger.hilt.android") version "2.48.1" apply false
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..4eef783
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,26 @@
+# 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
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
+android.defaults.buildfeatures.buildconfig=true
+android.nonFinalResIds=false
+android.enableR8.fullMode=false
\ 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..a65fa3c
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Wed Nov 29 20:34:25 GMT+03:30 2023
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100644
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.kts b/settings.gradle.kts
new file mode 100644
index 0000000..dc54bac
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "latest-news"
+include(":app")
+
\ No newline at end of file