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