From 857cb44e94929928c8f583088053d13a328d95ff Mon Sep 17 00:00:00 2001 From: wahid Date: Mon, 22 Jun 2026 17:14:56 +0300 Subject: [PATCH 1/6] Implement domain use cases and Result-based network handling - Added `GetAllNewsUseCase` and `GetHeadlinesUseCase` - Added custom `Result` sealed interface and utility extensions for state handling - Added Ktor `HttpClient` extension to wrap network responses in `Result` - Updated `NewsService` and `NewsRemoteDatasource` to return `Result` types - Added Metro DI `DomainModule` for use case injection - Updated repository implementation to handle new `Result` return types --- .../remote/datasource/NewsRemoteDatasource.kt | 5 ++- .../datasource/NewsRemoteDatasourceImpl.kt | 5 ++- .../data/remote/service/NewsService.kt | 23 ++++++----- .../data/repository/NesRepositoryImpl.kt | 5 ++- .../com/wahid/newscmp/di/DomainModule.kt | 24 +++++++++++ .../domain/usecase/GetAllNewsUseCase.kt | 16 ++++++++ .../domain/usecase/GetHeadlinesUseCase.kt | 14 +++++++ .../com/wahid/newscmp/utils/HttpClientExt.kt | 22 ++++++++++ .../kotlin/com/wahid/newscmp/utils/Result.kt | 40 +++++++++++++++++++ 9 files changed, 138 insertions(+), 16 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/di/DomainModule.kt create mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/domain/usecase/GetAllNewsUseCase.kt create mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/domain/usecase/GetHeadlinesUseCase.kt create mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/utils/HttpClientExt.kt create mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/utils/Result.kt diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/data/remote/datasource/NewsRemoteDatasource.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/data/remote/datasource/NewsRemoteDatasource.kt index 1ff9ce5..da7889c 100644 --- a/shared/src/commonMain/kotlin/com/wahid/newscmp/data/remote/datasource/NewsRemoteDatasource.kt +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/data/remote/datasource/NewsRemoteDatasource.kt @@ -1,8 +1,9 @@ package com.wahid.newscmp.data.remote.datasource import com.wahid.newscmp.data.remote.dto.NewsResponse +import com.wahid.newscmp.utils.Result interface NewsRemoteDatasource { - suspend fun getAllNews(query: Map): NewsResponse - suspend fun getHeadlinesNews(query: Map): NewsResponse + suspend fun getAllNews(query: Map): Result + suspend fun getHeadlinesNews(query: Map): Result } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/data/remote/datasource/NewsRemoteDatasourceImpl.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/data/remote/datasource/NewsRemoteDatasourceImpl.kt index 6f8d636..a24857a 100644 --- a/shared/src/commonMain/kotlin/com/wahid/newscmp/data/remote/datasource/NewsRemoteDatasourceImpl.kt +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/data/remote/datasource/NewsRemoteDatasourceImpl.kt @@ -2,6 +2,7 @@ package com.wahid.newscmp.data.remote.datasource import com.wahid.newscmp.data.remote.dto.NewsResponse import com.wahid.newscmp.data.remote.service.NewsService +import com.wahid.newscmp.utils.Result import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @@ -12,11 +13,11 @@ import dev.zacsweers.metro.Inject class NewsRemoteDatasourceImpl( private val newsService: NewsService ): NewsRemoteDatasource { - override suspend fun getAllNews(query: Map): NewsResponse { + override suspend fun getAllNews(query: Map): Result { return newsService.getAllNews(query = query) } - override suspend fun getHeadlinesNews(query: Map): NewsResponse { + override suspend fun getHeadlinesNews(query: Map): Result { return newsService.getHeadlinesNews(query = query) } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/data/remote/service/NewsService.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/data/remote/service/NewsService.kt index 310a28b..974b6c2 100644 --- a/shared/src/commonMain/kotlin/com/wahid/newscmp/data/remote/service/NewsService.kt +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/data/remote/service/NewsService.kt @@ -1,23 +1,26 @@ package com.wahid.newscmp.data.remote.service import com.wahid.newscmp.data.remote.dto.NewsResponse +import com.wahid.newscmp.utils.Result import com.wahid.newscmp.utils.getQueryUrlString +import com.wahid.newscmp.utils.getResults import dev.zacsweers.metro.Inject import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.request.get +import io.ktor.client.request.url @Inject class NewsService(private val httpClient: HttpClient) { - suspend fun getAllNews(query: Map): NewsResponse { - val url = "everything${query.getQueryUrlString()}" - return httpClient.get(urlString = url).body() - } + suspend fun getAllNews(query: Map): Result = + httpClient.getResults { + url("everything${query.getQueryUrlString()}") + } + + + suspend fun getHeadlinesNews(query: Map): Result = + httpClient.getResults { + url("top-headlines${query.getQueryUrlString()}") + } - suspend fun getHeadlinesNews(query: Map): NewsResponse { - val url = "top-headlines${query.getQueryUrlString()}" - return httpClient.get(urlString = url).body() - } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/data/repository/NesRepositoryImpl.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/data/repository/NesRepositoryImpl.kt index 35ca30c..ba7440d 100644 --- a/shared/src/commonMain/kotlin/com/wahid/newscmp/data/repository/NesRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/data/repository/NesRepositoryImpl.kt @@ -8,6 +8,7 @@ import com.wahid.newscmp.domain.repository.NewsRepository import com.wahid.newscmp.mappers.getCacheKey import com.wahid.newscmp.mappers.toDatabaseEntity import com.wahid.newscmp.mappers.toDomainModel +import com.wahid.newscmp.utils.getOrThrow import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @@ -43,7 +44,7 @@ class NesRepositoryImpl( } val articles = newsRemoteDatasource - .getAllNews(query = queryFilter) + .getAllNews(query = queryFilter).getOrThrow() .articles ?.mapNotNull { it @@ -76,7 +77,7 @@ class NesRepositoryImpl( */ override fun getHeadLinesNews(queryFilter: Map): Flow> = flow { val articles = - newsRemoteDatasource.getHeadlinesNews(query = queryFilter).articles?.mapNotNull { article -> + newsRemoteDatasource.getHeadlinesNews(query = queryFilter).getOrThrow().articles?.mapNotNull { article -> article?.toDomainModel() } articles?.let { diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/di/DomainModule.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/di/DomainModule.kt new file mode 100644 index 0000000..94f4078 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/di/DomainModule.kt @@ -0,0 +1,24 @@ +package com.wahid.newscmp.di + +import com.wahid.newscmp.domain.repository.NewsRepository +import com.wahid.newscmp.domain.usecase.GetAllNewsUseCase +import com.wahid.newscmp.domain.usecase.GetHeadlinesUseCase +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides + +@ContributesTo(AppScope::class) +interface DomainModule { + + @Provides + fun provideGetAllNewsUseCase(@Provides repository: NewsRepository): GetAllNewsUseCase = + GetAllNewsUseCase( + repository = repository + ) + + @Provides + fun provideHeadlinesUseCase(@Provides repository: NewsRepository): GetHeadlinesUseCase = + GetHeadlinesUseCase( + repository = repository + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/domain/usecase/GetAllNewsUseCase.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/domain/usecase/GetAllNewsUseCase.kt new file mode 100644 index 0000000..826cb01 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/domain/usecase/GetAllNewsUseCase.kt @@ -0,0 +1,16 @@ +package com.wahid.newscmp.domain.usecase + +import com.wahid.newscmp.domain.model.Article +import com.wahid.newscmp.domain.repository.NewsRepository +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.Flow + +@Inject +class GetAllNewsUseCase( + private val repository: NewsRepository +) { + operator fun invoke( + query: Map, + forceFetch: Boolean + ): Flow> = repository.getAllNews(queryFilter = query, forceFetch) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/domain/usecase/GetHeadlinesUseCase.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/domain/usecase/GetHeadlinesUseCase.kt new file mode 100644 index 0000000..fa9e7ca --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/domain/usecase/GetHeadlinesUseCase.kt @@ -0,0 +1,14 @@ +package com.wahid.newscmp.domain.usecase + +import com.wahid.newscmp.domain.model.Article +import com.wahid.newscmp.domain.repository.NewsRepository +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.Flow + +@Inject +class GetHeadlinesUseCase( + private val repository: NewsRepository +) { + operator fun invoke(query: Map): Flow> = + repository.getHeadLinesNews(queryFilter = query) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/utils/HttpClientExt.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/utils/HttpClientExt.kt new file mode 100644 index 0000000..d84d39e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/utils/HttpClientExt.kt @@ -0,0 +1,22 @@ +package com.wahid.newscmp.utils + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.request +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode + + +suspend inline fun HttpClient.getResults( + block: HttpRequestBuilder.() -> Unit +): Result = try { + val response = request(block) + if (response.status == HttpStatusCode.OK) { + Result.Success(response.body()) + } else { + Result.Error(Throwable("${response.status}: ${response.bodyAsText()}")) + } +} catch (e: Exception) { + Result.Error(e) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/utils/Result.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/utils/Result.kt new file mode 100644 index 0000000..ab4fe8d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/utils/Result.kt @@ -0,0 +1,40 @@ +package com.wahid.newscmp.utils + +sealed interface Result { + data class Success(val value: T) : Result + data object Loading : Result + class Error(val throwable: Throwable) : Result +} + + +inline fun Result.map(transform: (value:T) -> R): Result = + when (this) { + is Result.Loading -> Result.Loading + is Result.Success -> Result.Success(transform(value)) + is Result.Error -> Result.Error(throwable) + } + +fun Result.getOrNull(): T? = + if (this is Result.Success) value else null + +fun Result.getOrElse(default: T): T = + if (this is Result.Success) value else default + +fun Result.getOrElse(default: () -> T): T = + if (this is Result.Success) value else default() + +fun Result.getOrThrow(): T = when (this) { + is Result.Success -> value + is Result.Error -> throw throwable + is Result.Loading -> throw IllegalStateException("Result is still Loading") +} + +fun Result.fold( + onSuccess: (T) -> R, + onError: (Throwable) -> R, + onLoading: () -> R, +): R = when (this) { + is Result.Success -> onSuccess(value) + is Result.Error -> onError(throwable) + is Result.Loading -> onLoading() +} \ No newline at end of file From 55c49ff7f8373b41834544dbba928aaacd9fe403 Mon Sep 17 00:00:00 2001 From: wahid Date: Tue, 23 Jun 2026 00:11:41 +0300 Subject: [PATCH 2/6] Remove boilerplate Greeting and Platform code - Deleted Greeting and GreetingUtil - Deleted Platform interface and platform-specific implementations (Android and iOS) --- .../kotlin/com/wahid/newscmp/Platform.android.kt | 9 --------- .../src/commonMain/kotlin/com/wahid/newscmp/Greeting.kt | 9 --------- .../commonMain/kotlin/com/wahid/newscmp/GreetingUtil.kt | 4 ---- .../src/commonMain/kotlin/com/wahid/newscmp/Platform.kt | 7 ------- .../src/iosMain/kotlin/com/wahid/newscmp/Platform.ios.kt | 9 --------- 5 files changed, 38 deletions(-) delete mode 100644 shared/src/androidMain/kotlin/com/wahid/newscmp/Platform.android.kt delete mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/Greeting.kt delete mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/GreetingUtil.kt delete mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/Platform.kt delete mode 100644 shared/src/iosMain/kotlin/com/wahid/newscmp/Platform.ios.kt diff --git a/shared/src/androidMain/kotlin/com/wahid/newscmp/Platform.android.kt b/shared/src/androidMain/kotlin/com/wahid/newscmp/Platform.android.kt deleted file mode 100644 index 2e5fae3..0000000 --- a/shared/src/androidMain/kotlin/com/wahid/newscmp/Platform.android.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.wahid.newscmp - -import android.os.Build - -class AndroidPlatform : Platform { - override val name: String = "Android ${Build.VERSION.SDK_INT}" -} - -actual fun getPlatform(): Platform = AndroidPlatform() \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/Greeting.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/Greeting.kt deleted file mode 100644 index f5247c5..0000000 --- a/shared/src/commonMain/kotlin/com/wahid/newscmp/Greeting.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.wahid.newscmp - -class Greeting { - private val platform = getPlatform() - - fun greet(): String { - return sayHello(platform.name) - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/GreetingUtil.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/GreetingUtil.kt deleted file mode 100644 index a7b699f..0000000 --- a/shared/src/commonMain/kotlin/com/wahid/newscmp/GreetingUtil.kt +++ /dev/null @@ -1,4 +0,0 @@ -package com.wahid.newscmp - -fun sayHello(to: String): String = - "Hello, $to!" \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/Platform.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/Platform.kt deleted file mode 100644 index 04b9030..0000000 --- a/shared/src/commonMain/kotlin/com/wahid/newscmp/Platform.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.wahid.newscmp - -interface Platform { - val name: String -} - -expect fun getPlatform(): Platform \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/com/wahid/newscmp/Platform.ios.kt b/shared/src/iosMain/kotlin/com/wahid/newscmp/Platform.ios.kt deleted file mode 100644 index 7a6c08c..0000000 --- a/shared/src/iosMain/kotlin/com/wahid/newscmp/Platform.ios.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.wahid.newscmp - -import platform.UIKit.UIDevice - -class IOSPlatform: Platform { - override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion -} - -actual fun getPlatform(): Platform = IOSPlatform() \ No newline at end of file From 69fdc1623d146644e010280b748b4ba5d5762c5d Mon Sep 17 00:00:00 2001 From: wahid Date: Tue, 23 Jun 2026 00:13:26 +0300 Subject: [PATCH 3/6] Implement Navigation3 and update Article data models - Added `AppNavigationHost` using Navigation3 UI and `NavDisplay` - Defined navigation destinations in `StackEntries` and implemented `DestinationSavedStateSerializer` - Updated `Article` entity and domain model with `source` and `isFavorite` fields - Changed `ArticleEntity` primary key to `url` - Integrated dependencies for Navigation3, Coil3, Metro ViewModel, and Lucide icons - Added `MetroViewModelFactory` for dependency injection - Refactored `Article` mappers and updated repository to filter empty URLs --- gradle/libs.versions.toml | 27 +++- .../newscmp/data/local/room/entity/Article.kt | 3 +- .../data/repository/NesRepositoryImpl.kt | 5 +- .../wahid/newscmp/di/MetroViewModelFactory.kt | 20 +++ .../com/wahid/newscmp/domain/model/Article.kt | 5 + .../com/wahid/newscmp/mappers/ArticleExt.kt | 19 ++- .../DestinationSavedStateSerializer.kt | 22 +++ .../newscmp/presentation/navigation/NavHos.kt | 137 ++++++++++++++++++ .../presentation/navigation/StackEntries.kt | 14 ++ 9 files changed, 240 insertions(+), 12 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/di/MetroViewModelFactory.kt create mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/navigation/DestinationSavedStateSerializer.kt create mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/navigation/NavHos.kt create mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/navigation/StackEntries.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f2d1079..056c9b6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,8 @@ androidx-espresso = "3.7.0" androidx-lifecycle = "2.11.0-beta01" androidx-testExt = "1.3.0" composeMultiplatform = "1.11.1" +iconsMaterialSymbolsOutlinedCmp = "2.2.1" +iconsLucideCmp = "2.2.1" junit = "4.13.2" kotlin = "2.4.0" material3 = "1.11.0-alpha07" @@ -25,10 +27,15 @@ jetbrainsKotlinJvm = "2.4.0" room = "2.8.4" sqlite = "2.6.2" kermit = "2.0.5" - +multiplatform-nav3-ui = "1.1.1" +compose-multiplatform-adaptive = "1.3.0-beta02" +compose-multiplatform-lifecycle = "2.10.0" +coil3 = "3.5.0" [libraries] +icons-lucide-cmp = { module = "com.composables:icons-lucide-cmp", version.ref = "iconsLucideCmp" } +icons-material-symbols-outlined-cmp = { module = "com.composables:icons-material-symbols-outlined-cmp", version.ref = "iconsMaterialSymbolsOutlinedCmp" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } junit = { module = "junit:junit", version.ref = "junit" } @@ -55,15 +62,26 @@ ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "kto ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinSeralization" } #metro-compiler = { module = "dev.zacsweers.metro:compiler", version.ref = "metro" } +metro-viewmodel = { module = "dev.zacsweers.metro:metrox-viewmodel-compose", version.ref = "metro" } kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } #Room3 androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } + +#Navigation +jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "multiplatform-nav3-ui" } +jetbrains-material3-adaptiveNavigation3 = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation3", version.ref = "compose-multiplatform-adaptive" } +jetbrains-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "compose-multiplatform-lifecycle" } #androidx-room-sqlite-wrapper = { module = "androidx.room:room-sqlite-wrapper", version.ref = "room" } +#Coil3 +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil3" } +coil-compose-core = { module = "io.coil-kt.coil3:coil-compose-core", version.ref = "coil3" } +coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil3" } + [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } androidMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } @@ -75,3 +93,10 @@ metro-di = {id = "dev.zacsweers.metro", version.ref = "metro"} ksp = { id = "com.google.devtools.ksp", version.ref = "ksp"} android-library = { id = "com.android.library", version.ref = "agp" } androidx-room = { id = "androidx.room", version.ref = "room" } + +[bundles] +coil = [ + "coil-compose", + "coil-compose-core", + "coil-network-ktor3" +] \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/data/local/room/entity/Article.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/data/local/room/entity/Article.kt index 833a81c..72ed888 100644 --- a/shared/src/commonMain/kotlin/com/wahid/newscmp/data/local/room/entity/Article.kt +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/data/local/room/entity/Article.kt @@ -3,7 +3,7 @@ package com.wahid.newscmp.data.local.room.entity import androidx.room.Entity import kotlin.time.Instant -@Entity(tableName = "Article",primaryKeys = ["id"]) +@Entity(tableName = "Article",primaryKeys = ["url"]) data class ArticleEntity( val id: String, val isFavorite: Boolean, @@ -12,6 +12,7 @@ data class ArticleEntity( val description: String, val publishedAt: String, val title: String, + val source: String, val url: String, val urlToImage: String, val lastUpdate: Instant diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/data/repository/NesRepositoryImpl.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/data/repository/NesRepositoryImpl.kt index ba7440d..8486279 100644 --- a/shared/src/commonMain/kotlin/com/wahid/newscmp/data/repository/NesRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/data/repository/NesRepositoryImpl.kt @@ -5,7 +5,6 @@ import com.wahid.newscmp.data.remote.datasource.NewsRemoteDatasource import com.wahid.newscmp.di.IODispatcher import com.wahid.newscmp.domain.model.Article import com.wahid.newscmp.domain.repository.NewsRepository -import com.wahid.newscmp.mappers.getCacheKey import com.wahid.newscmp.mappers.toDatabaseEntity import com.wahid.newscmp.mappers.toDomainModel import com.wahid.newscmp.utils.getOrThrow @@ -48,7 +47,7 @@ class NesRepositoryImpl( .articles ?.mapNotNull { it - } + }?.filter { it.url?.isNotEmpty() == true } ?: run { emit(emptyList()) return@flow @@ -62,7 +61,7 @@ class NesRepositoryImpl( newsLocalDatasource.insert( articles.map { article -> article.toDatabaseEntity( - isFavorite = article.getCacheKey() in existingFavorites + isFavorite = article.source?.id in existingFavorites ) } ) diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/di/MetroViewModelFactory.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/di/MetroViewModelFactory.kt new file mode 100644 index 0000000..b1b5adb --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/di/MetroViewModelFactory.kt @@ -0,0 +1,20 @@ +package com.wahid.newscmp.di + +import androidx.lifecycle.ViewModel +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory +import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory +import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactory +import kotlin.reflect.KClass + +@Inject +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class MyViewModelFactory( + override val viewModelProviders: Map, () -> ViewModel>, + override val assistedFactoryProviders: Map, () -> ViewModelAssistedFactory>, + override val manualAssistedFactoryProviders: Map, () -> ManualViewModelAssistedFactory>, +) : MetroViewModelFactory() \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/domain/model/Article.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/domain/model/Article.kt index a7873d7..27a9099 100644 --- a/shared/src/commonMain/kotlin/com/wahid/newscmp/domain/model/Article.kt +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/domain/model/Article.kt @@ -1,13 +1,18 @@ package com.wahid.newscmp.domain.model +import kotlinx.serialization.Serializable + +@Serializable data class Article( val id: String, val author: String, + val isFavorite: Boolean, val content: String, val description: String, val publishedAt: String, val title: String, + val source: String, val url: String, val urlToImage: String ) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/mappers/ArticleExt.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/mappers/ArticleExt.kt index 91e83f5..5552416 100644 --- a/shared/src/commonMain/kotlin/com/wahid/newscmp/mappers/ArticleExt.kt +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/mappers/ArticleExt.kt @@ -4,28 +4,30 @@ import com.wahid.newscmp.data.local.room.entity.ArticleEntity import com.wahid.newscmp.domain.model.Article import kotlin.time.Clock -fun com.wahid.newscmp.data.remote.dto.Article.getCacheKey(): String { +/*fun com.wahid.newscmp.data.remote.dto.Article.getCacheKey(): String { source?.id?.let { return it } return "${hashCode() + Clock.System.now().nanosecondsOfSecond}" -} +}*/ fun com.wahid.newscmp.data.remote.dto.Article.toDomainModel(): Article { return Article( - id = getCacheKey(), + id = source?.id?:"", author = author ?: "No author info found", content = content ?: "No content", description = description ?: "No description", publishedAt = publishedAt ?: "No publishedAt info found", title = title ?: "No title", url = url ?: "", - urlToImage = urlToImage?: "" + urlToImage = urlToImage?: "", + isFavorite = false, + source = source?.name ?: "" ) } fun com.wahid.newscmp.data.remote.dto.Article.toDatabaseEntity( isFavorite: Boolean = false): ArticleEntity = ArticleEntity( - id = getCacheKey(), + id = source?.id?: "", isFavorite = isFavorite, author = author ?: "No author info found", content = content ?: "No content", @@ -34,7 +36,8 @@ fun com.wahid.newscmp.data.remote.dto.Article.toDatabaseEntity( isFavorite: Bool title = title ?: "No title", url = url ?: "", urlToImage = urlToImage?: "", - lastUpdate = Clock.System.now() + lastUpdate = Clock.System.now(), + source = source?.name ?: "" ) @@ -46,5 +49,7 @@ fun ArticleEntity.toDomainModel(): Article = Article( publishedAt = publishedAt, title = title, url = url, - urlToImage = urlToImage + urlToImage = urlToImage, + isFavorite = isFavorite, + source = source ) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/navigation/DestinationSavedStateSerializer.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/navigation/DestinationSavedStateSerializer.kt new file mode 100644 index 0000000..5bb8c99 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/navigation/DestinationSavedStateSerializer.kt @@ -0,0 +1,22 @@ +package com.wahid.newscmp.presentation.navigation + +import androidx.navigation3.runtime.NavKey +import androidx.savedstate.serialization.SavedStateConfiguration +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic + +object DestinationSavedStateSerializer { + + val serializer = SerializersModule { + polymorphic(NavKey::class){ + subclass(StackEntries.AllNews::class, StackEntries.AllNews.serializer()) + subclass(StackEntries.Headlines::class, StackEntries.Headlines.serializer()) + subclass(StackEntries.FavoriteNews::class, StackEntries.FavoriteNews.serializer()) + } + } + + val config = SavedStateConfiguration { + serializersModule = serializer + } + +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/navigation/NavHos.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/navigation/NavHos.kt new file mode 100644 index 0000000..3c41182 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/navigation/NavHos.kt @@ -0,0 +1,137 @@ +package com.wahid.newscmp.presentation.navigation + +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import com.wahid.newscmp.presentation.navigation.DestinationSavedStateSerializer.config +import com.wahid.newscmp.presentation.screen.allNews.AllNewsIntent +import com.wahid.newscmp.presentation.screen.allNews.AllNewsScreen +import com.wahid.newscmp.presentation.screen.allNews.AllNewsViewModel +import com.wahid.newscmp.presentation.screen.allNews.NewsTab +import com.wahid.newscmp.presentation.screen.component.NewsBottomBar +import com.wahid.newscmp.presentation.screen.component.NewsTopBar +import com.wahid.newscmp.utils.Colors.ColorBackground +import dev.zacsweers.metrox.viewmodel.metroViewModel + + +@Composable +fun AppNavigationHost(modifier: Modifier = Modifier) { + + val backStack = rememberNavBackStack(config, StackEntries.AllNews) + var selectedTab by remember { mutableStateOf(NewsTab.ALL_NEWS) } + + Scaffold( + containerColor = ColorBackground, + bottomBar = { + NewsBottomBar(selectedTab = selectedTab, onTabSelected = { + selectedTab = it + }) + }, + topBar = { + NewsTopBar() + } + ) { + NavDisplay( + modifier = modifier, + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() + ), + entryProvider = entryProvider { + entry { + val viewModel = metroViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + AllNewsScreen( + onNavigateToFavorites = { backStack.add(StackEntries.FavoriteNews) }, + onNavigateToHeadline = { backStack.add(StackEntries.Headlines) }, + modifier = Modifier.fillMaxSize().statusBarsPadding().padding( + top = 70.dp + ), + state = state, + onBookmarkClick = { TODO() }, + searchQuery = state.searchQuery, + onQueryChange = { + viewModel.onIntent( + intent = AllNewsIntent.SearchQueryChange( + it + ) + ) + }, + onClearClick = { + viewModel.onIntent( + intent = AllNewsIntent.SearchQueryChange( + "" + ) + ) + }, + onSearch = { viewModel.onIntent(intent = AllNewsIntent.Search) }, + ) + } + entry { entry -> + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + + } + } + entry { + + } + }, + + transitionSpec = { + // Slide in from right when navigating forward + slideInHorizontally(initialOffsetX = { it }) togetherWith + slideOutHorizontally(targetOffsetX = { -it }) + }, + popTransitionSpec = { + // Slide in from left when navigating back + // slideInHorizontally(initialOffsetX = { -it }) togetherWith slideOutHorizontally(targetOffsetX = { it }) + + // Slide new content up, keeping the old content in place underneath + slideInVertically( + initialOffsetY = { it }, + animationSpec = tween(700) + ) togetherWith ExitTransition.KeepUntilTransitionsFinished + }, + predictivePopTransitionSpec = { + // Slide in from left when navigating back + slideInHorizontally(initialOffsetX = { -it }) togetherWith + slideOutHorizontally(targetOffsetX = { it }) + }, + ) + + } + + +} + + + diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/navigation/StackEntries.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/navigation/StackEntries.kt new file mode 100644 index 0000000..61c32fe --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/navigation/StackEntries.kt @@ -0,0 +1,14 @@ +package com.wahid.newscmp.presentation.navigation + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +sealed interface StackEntries : NavKey { + @Serializable + data object AllNews : StackEntries + @Serializable + data object FavoriteNews : StackEntries + @Serializable + data object Headlines:StackEntries +} \ No newline at end of file From 53511b08dbfd3642f546f6491c8ce297d8c57dc8 Mon Sep 17 00:00:00 2001 From: wahid Date: Tue, 23 Jun 2026 00:14:12 +0300 Subject: [PATCH 4/6] Implement All News screen and ViewModel - Added `AllNewsViewModel`, `AllNewsIntent`, and `AllNewsUIState` for state management - Implemented `AllNewsScreen` with search functionality, category filtering, and loading/error states - Updated `AppGraph` to include `GetAllNewsUseCase`, `GetHeadlinesUseCase`, and `AllNewsViewModel` - Integrated `AppNavigationHost` in `App.kt` and configured `LocalMetroViewModelFactory` --- .../kotlin/com/wahid/newscmp/App.kt | 26 ++-- .../kotlin/com/wahid/newscmp/di/AppGraph.kt | 10 +- .../screen/allNews/AllNewsIntent.kt | 9 ++ .../screen/allNews/AllNewsScreen.kt | 113 +++++++++++++++++ .../screen/allNews/AllNewsUIState.kt | 10 ++ .../screen/allNews/AllNewsViewModel.kt | 115 ++++++++++++++++++ 6 files changed, 268 insertions(+), 15 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/allNews/AllNewsIntent.kt create mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/allNews/AllNewsScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/allNews/AllNewsUIState.kt create mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/allNews/AllNewsViewModel.kt diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/App.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/App.kt index bb06b23..1a0ec67 100644 --- a/shared/src/commonMain/kotlin/com/wahid/newscmp/App.kt +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/App.kt @@ -1,13 +1,16 @@ package com.wahid.newscmp +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import co.touchlab.kermit.Logger import com.wahid.newscmp.di.AppGraph +import com.wahid.newscmp.presentation.navigation.AppNavigationHost import dev.zacsweers.metro.createGraph -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.onEach +import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory val appGraph = createGraph() @@ -18,18 +21,13 @@ val logger = Logger.withTag("NewsScreen") @Preview fun App() { MaterialTheme { + CompositionLocalProvider( + LocalMetroViewModelFactory provides appGraph.metroViewModelFactory - LaunchedEffect(Unit){ - newsRepository.getAllNews(mapOf("q" to "bitcoin"),false) - .onEach { articles -> - logger.d { "Articles count: ${articles.size}" } - logger.d { "First: ${articles.firstOrNull()}" } - } - .catch { e -> logger.e(e) { "Flow error" } } - .collect { articles -> - // update UI state - } - + ) { + AppNavigationHost( + modifier = Modifier.fillMaxSize() + ) } } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/di/AppGraph.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/di/AppGraph.kt index a2e205d..a9398ac 100644 --- a/shared/src/commonMain/kotlin/com/wahid/newscmp/di/AppGraph.kt +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/di/AppGraph.kt @@ -5,16 +5,24 @@ import com.wahid.newscmp.data.local.room.NewsDatabase import com.wahid.newscmp.data.local.room.dao.NewsDao import com.wahid.newscmp.data.remote.datasource.NewsRemoteDatasource import com.wahid.newscmp.domain.repository.NewsRepository +import com.wahid.newscmp.domain.usecase.GetAllNewsUseCase +import com.wahid.newscmp.domain.usecase.GetHeadlinesUseCase +import com.wahid.newscmp.presentation.screen.allNews.AllNewsViewModel import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.DependencyGraph +import dev.zacsweers.metrox.viewmodel.ViewModelGraph import io.ktor.client.HttpClient @DependencyGraph(AppScope::class) -interface AppGraph{ +interface AppGraph: ViewModelGraph { val ktorClient: HttpClient val newsDatabase: NewsDatabase val newsDao: NewsDao val newsRemoteDatasource: NewsRemoteDatasource val newsLocalDatasource: NewsLocalDatasource val newsRepository: NewsRepository + val getAllNewsUseCase: GetAllNewsUseCase + val getHeadlinesUseCase: GetHeadlinesUseCase + val allNewsViewModel: AllNewsViewModel + } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/allNews/AllNewsIntent.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/allNews/AllNewsIntent.kt new file mode 100644 index 0000000..f2bcc3b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/allNews/AllNewsIntent.kt @@ -0,0 +1,9 @@ +package com.wahid.newscmp.presentation.screen.allNews + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey + +sealed class AllNewsIntent{ + data class SearchQueryChange (val newQuery: String): AllNewsIntent() + data object Search : AllNewsIntent() +} diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/allNews/AllNewsScreen.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/allNews/AllNewsScreen.kt new file mode 100644 index 0000000..e8beebc --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/allNews/AllNewsScreen.kt @@ -0,0 +1,113 @@ +package com.wahid.newscmp.presentation.screen.allNews + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Surface +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.wahid.newscmp.domain.model.Article +import com.wahid.newscmp.presentation.screen.component.AppSearchBar +import com.wahid.newscmp.presentation.screen.component.CategoryFilterRow +import com.wahid.newscmp.presentation.screen.component.ErrorState +import com.wahid.newscmp.utils.Colors.ColorAccent +import com.wahid.newscmp.utils.Colors.ColorBackground +import com.wahid.newscmp.utils.Colors.ColorChip + + +@Composable +fun AllNewsScreen( + onNavigateToFavorites: () -> Unit, + onNavigateToHeadline: () -> Unit, + state: AllNewsUIState, + onBookmarkClick: (Article) -> Unit = {}, + searchQuery: String = "", + onQueryChange: (String) -> Unit, + onClearClick: () -> Unit, + onSearch: (String) -> Unit, + modifier: Modifier = Modifier +) { + + Surface( + color = ColorBackground, + modifier = modifier + ) { + val categories = remember { + listOf("Technology", "Business", "Sports", "Health", "Science") + } + var selectedCategory by remember { mutableStateOf(categories[0]) } + Column( + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize() + ) { + CategoryFilterRow(categories = categories, selectedCategory, { selectedCategory = it }) + AppSearchBar( + query = searchQuery, + onSearch = onSearch, + onQueryChange = onQueryChange, + onClearClick = onClearClick, + modifier = Modifier.padding(horizontal = 12.dp) + ) + when { + state.isLoading -> { + LoadingState() + } + + state.errorMessage != null -> { + ErrorState( + message = state.errorMessage, + padding = PaddingValues(12.dp) + ) + } + + else -> { + + } + + + } + + } + + } + +} + + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun LoadingState() { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(8) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp), + contentAlignment = Alignment.Center + ) { + CircularWavyProgressIndicator( + color = ColorAccent, + trackColor = ColorChip + ) + } + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/allNews/AllNewsUIState.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/allNews/AllNewsUIState.kt new file mode 100644 index 0000000..d46c1d7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/allNews/AllNewsUIState.kt @@ -0,0 +1,10 @@ +package com.wahid.newscmp.presentation.screen.allNews + +import com.wahid.newscmp.domain.model.Article + +data class AllNewsUIState( + val isLoading: Boolean = false, + val news: List
= emptyList(), + val errorMessage: String? = null, + val searchQuery: String = "", +) diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/allNews/AllNewsViewModel.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/allNews/AllNewsViewModel.kt new file mode 100644 index 0000000..7721224 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/allNews/AllNewsViewModel.kt @@ -0,0 +1,115 @@ +package com.wahid.newscmp.presentation.screen.allNews + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.wahid.newscmp.domain.usecase.GetAllNewsUseCase +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.Inject +import dev.zacsweers.metrox.viewmodel.ViewModelKey +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.WhileSubscribed +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.io.IOException +import kotlin.time.Duration.Companion.milliseconds + + +@OptIn(FlowPreview::class) +@ContributesIntoMap(AppScope::class) +@ViewModelKey(AllNewsViewModel::class) +@Inject +class AllNewsViewModel( + private val getAllNewsUseCase: GetAllNewsUseCase +) : ViewModel() { + + private val localState = MutableStateFlow(AllNewsUIState()) + val state = localState.asStateFlow() + + + init { + viewModelScope.launch { + state.stateIn( + viewModelScope, + started = SharingStarted.WhileSubscribed(5000.milliseconds), + initialValue = AllNewsUIState() + ).debounce(3000.milliseconds) + .distinctUntilChanged() + .collect { + forceFetch(mapOf("q" to it.searchQuery), true) + } + } + +// fetch(emptyMap()) + forceFetch( + buildMap { + put( + key = "q", + value = "business" + ) + }, + forceFetch = true + ) + } + + fun onIntent(intent: AllNewsIntent) { + when (intent) { + is AllNewsIntent.SearchQueryChange -> { + localState.update { + it.copy(searchQuery = intent.newQuery) + } + } + + AllNewsIntent.Search -> { + onSearch() + } + } + } + + private fun onSearch() { + viewModelScope.launch { + state.debounce(3000.milliseconds) + .distinctUntilChanged() + .collect { + forceFetch(mapOf("q" to it.searchQuery), true) + } + } + } + + private fun fetch(newQuery: Map) { + forceFetch(newQuery = newQuery, false) + } + + private fun forceFetch(newQuery: Map, forceFetch: Boolean) { + viewModelScope.launch { + localState.update { + it.copy(isLoading = true) + } + try { + getAllNewsUseCase(query = newQuery, forceFetch = false) + .catch { error -> + localState.update { + it.copy(isLoading = false, errorMessage = error.message) + } + }.collect { news -> + localState.update { state -> + state.copy(isLoading = false, news = news) + } + } + } catch (e: IOException) { + localState.update { + it.copy(isLoading = false, errorMessage = e.message) + } + } + } + } + +} \ No newline at end of file From 6ea27a6ff3bab5b255b8393c5f6654c07ec60d48 Mon Sep 17 00:00:00 2001 From: wahid Date: Tue, 23 Jun 2026 00:15:03 +0300 Subject: [PATCH 5/6] Add news UI components and update project dependencies - Add Navigation 3, Adaptive Navigation, and Metro ViewModel dependencies - Integrate Coil for image loading and Lucide/Material Symbols icon libraries - Implement reusable UI components: AppSearchBar, FeaturedCard, CategoryFilterRow, and Error/Empty states - Add NewsTopBar, NewsBottomBar, and NewsTab navigation enum - Define application color palette in Colors.kt - Enable explicit-backing-fields compiler option --- shared/build.gradle.kts | 25 +++- .../presentation/screen/allNews/NewsTap.kt | 7 + .../screen/component/AppSearchBar.kt | 80 +++++++++++ .../screen/component/CategoryFilterRow.kt | 72 ++++++++++ .../screen/component/EmptyCategory.kt | 45 ++++++ .../screen/component/ErrorState.kt | 61 +++++++++ .../screen/component/FeaturedCard.kt | 129 ++++++++++++++++++ .../screen/component/NewsBottomBar.kt | 59 ++++++++ .../screen/component/NwesTopBar.kt | 64 +++++++++ .../kotlin/com/wahid/newscmp/utils/Colors.kt | 14 ++ 10 files changed, 555 insertions(+), 1 deletion(-) create mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/allNews/NewsTap.kt create mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/AppSearchBar.kt create mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/CategoryFilterRow.kt create mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/EmptyCategory.kt create mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/ErrorState.kt create mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/FeaturedCard.kt create mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/NewsBottomBar.kt create mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/NwesTopBar.kt create mode 100644 shared/src/commonMain/kotlin/com/wahid/newscmp/utils/Colors.kt diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index efc034e..f4fd801 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,5 +1,4 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { alias(libs.plugins.kotlinMultiplatform) @@ -27,6 +26,11 @@ kotlin { } } + compilerOptions { + freeCompilerArgs.add("-Xexplicit-backing-fields") + } + + androidLibrary { namespace = "com.wahid.newscmp.shared" compileSdk = libs.versions.android.compileSdk.get().toInt() @@ -60,6 +64,11 @@ kotlin { implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) + //navigation + implementation(libs.jetbrains.navigation3.ui) + implementation(libs.jetbrains.material3.adaptiveNavigation3) + implementation(libs.jetbrains.lifecycle.viewmodelNavigation3) + //Ktor implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.client.core) @@ -67,6 +76,12 @@ kotlin { implementation(libs.ktor.client.content.negotiation) implementation(libs.kotlinx.serialization) + //Metro + implementation(libs.metro.viewmodel) + + //Coil3 + implementation(libs.bundles.coil) + //Room3 implementation(libs.androidx.room.runtime) @@ -76,6 +91,14 @@ kotlin { implementation(libs.kermit) + + + implementation(libs.icons.material.symbols.outlined.cmp) + + // Lucide Icons (1.6k icons) + implementation(libs.icons.lucide.cmp) + + } iosMain.dependencies { implementation(libs.ktor.client.darwin) diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/allNews/NewsTap.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/allNews/NewsTap.kt new file mode 100644 index 0000000..536463f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/allNews/NewsTap.kt @@ -0,0 +1,7 @@ +package com.wahid.newscmp.presentation.screen.allNews + +enum class NewsTab(val label: String) { + ALL_NEWS("All News"), + HEADLINES("Headlines"), + FAVORITES("Favorites"), +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/AppSearchBar.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/AppSearchBar.kt new file mode 100644 index 0000000..2a1cca7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/AppSearchBar.kt @@ -0,0 +1,80 @@ +package com.wahid.newscmp.presentation.screen.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.composables.icons.lucide.Forward +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Search +import com.wahid.newscmp.utils.Colors.ColorAccent +import com.wahid.newscmp.utils.Colors.ColorDivider +import com.wahid.newscmp.utils.Colors.ColorSurface +import com.wahid.newscmp.utils.Colors.ColorTextPrimary + +@Composable +fun AppSearchBar( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, + placeholder: String = "Search...", + enabled: Boolean = true, + readOnly: Boolean = false, + onSearch: (String) -> Unit = {}, + onClearClick: () -> Unit = { onQueryChange("") } +) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = modifier.fillMaxWidth(), + enabled = enabled, + readOnly = readOnly, + singleLine = true, + placeholder = { + Text(placeholder) + }, + leadingIcon = { + Icon( + imageVector = Lucide.Search, + contentDescription = "Search" + ) + }, + trailingIcon = { + if (query.isNotBlank()) { + IconButton( + onClick = onClearClick + ) { + Icon( + imageVector = Lucide.Forward, + contentDescription = "Clear Search" + ) + } + } + }, + keyboardActions = KeyboardActions( + onSearch = { + onSearch(query) + } + ), + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = ColorSurface, + unfocusedContainerColor = ColorSurface, + + focusedBorderColor = ColorAccent, + unfocusedBorderColor = ColorDivider, + + focusedTextColor = ColorTextPrimary, + unfocusedTextColor = ColorTextPrimary, + + cursorColor = ColorAccent + ), + shape = RoundedCornerShape(16.dp) + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/CategoryFilterRow.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/CategoryFilterRow.kt new file mode 100644 index 0000000..ec96d2a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/CategoryFilterRow.kt @@ -0,0 +1,72 @@ +package com.wahid.newscmp.presentation.screen.component + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.wahid.newscmp.utils.Colors.ColorAccent +import com.wahid.newscmp.utils.Colors.ColorChip +import com.wahid.newscmp.utils.Colors.ColorTextMuted + + +@Composable +fun CategoryFilterRow( + categories: List, + selected: String, + onSelect: (String) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + categories.forEach { category -> + val isSelected = category == selected + val bgColor by animateColorAsState( + targetValue = if (isSelected) ColorAccent else ColorChip, + animationSpec = tween(200), + label = "chipBg_$category", + ) + val textColor by animateColorAsState( + targetValue = if (isSelected) Color.White else ColorTextMuted, + animationSpec = tween(200), + label = "chipText_$category", + ) + Box( + modifier = Modifier + .clip(CircleShape) + .background(bgColor) + .clickable { onSelect(category) } + .padding(horizontal = 16.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = category, + color = textColor, + fontSize = 13.sp, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + ) + } + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/EmptyCategory.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/EmptyCategory.kt new file mode 100644 index 0000000..4df84c7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/EmptyCategory.kt @@ -0,0 +1,45 @@ +package com.wahid.newscmp.presentation.screen.component + + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.wahid.newscmp.utils.Colors.ColorTextMuted +import com.wahid.newscmp.utils.Colors.ColorTextPrimary + +@Composable +fun EmptyCategory(category: String) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(height = 200.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text("📭", fontSize = 32.sp) + Text( + text = "No $category articles yet", + color = ColorTextPrimary, + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + ) + Text( + text = "Check back later for updates", + color = ColorTextMuted, + fontSize = 13.sp, + ) + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/ErrorState.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/ErrorState.kt new file mode 100644 index 0000000..9d6f44d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/ErrorState.kt @@ -0,0 +1,61 @@ +package com.wahid.newscmp.presentation.screen.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.MessageCircleWarning +import com.wahid.newscmp.utils.Colors.ColorAccent +import com.wahid.newscmp.utils.Colors.ColorBackground +import com.wahid.newscmp.utils.Colors.ColorTextMuted +import com.wahid.newscmp.utils.Colors.ColorTextPrimary + +@Composable +fun ErrorState(message: String, padding: PaddingValues) { + Box( + modifier = Modifier + .fillMaxSize() + .background(ColorBackground) + .padding(padding), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Lucide.MessageCircleWarning, + tint = ColorAccent, + contentDescription = "Error", + modifier = Modifier.size(42.dp), + ) + Text( + text = "Couldn't load news", + color = ColorTextPrimary, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + ) + Text( + text = message, + color = ColorTextMuted, + fontSize = 13.sp, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 32.dp), + ) + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/FeaturedCard.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/FeaturedCard.kt new file mode 100644 index 0000000..35d663b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/FeaturedCard.kt @@ -0,0 +1,129 @@ +package com.wahid.newscmp.presentation.screen.component + + + +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.Spacer +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.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import com.composables.icons.lucide.Bookmark +import com.composables.icons.lucide.Lucide +import com.composables.icons.materialsymbols.MaterialSymbols +import com.composables.icons.materialsymbols.outlined.Bookmark +import com.wahid.newscmp.domain.model.Article +import com.wahid.newscmp.utils.Colors.ColorAccent +import com.wahid.newscmp.utils.Colors.ColorBreaking +import com.wahid.newscmp.utils.Colors.ColorSurface + + +@Composable +private fun FeaturedCard( + article: Article, + onClick: () -> Unit, + onBookmark: () -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .height(240.dp) + .padding(horizontal = 16.dp, vertical = 6.dp) + .clickable(onClick = onClick), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = ColorSurface), + ) { + Box(modifier = Modifier.fillMaxSize()) { + + // Background image + AsyncImage( + model = article.urlToImage, + contentDescription = article.title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + + // Gradient scrim for text legibility + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colorStops = arrayOf( + 0.00f to Color.Transparent, + 0.45f to Color(0x550F0F16), + 1.00f to Color(0xEE0F0F16), + ) + ) + ) + ) + + // FEATURED pill — top start + Surface( + modifier = Modifier + .padding(12.dp) + .align(Alignment.TopStart), + shape = RoundedCornerShape(4.dp), + color = ColorBreaking, + ) { + Text( + text = "FEATURED", + color = Color.White, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.2.sp, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } + + // Bookmark icon — top end + IconButton( + onClick = onBookmark, + modifier = Modifier.align(Alignment.TopEnd), + ) { + Icon( + imageVector = if (article.isFavorite) Lucide.Bookmark else MaterialSymbols.Outlined.Bookmark, + contentDescription = if (article.isFavorite) "Remove bookmark" else "Add bookmark", + tint = if (article.isFavorite) ColorAccent else Color.White, + ) + } + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = 12.dp, end = 48.dp, bottom = 12.dp), + ) { + Spacer(Modifier.height(5.dp)) + Text( + text = article.title, + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 17.sp, + lineHeight = 23.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/NewsBottomBar.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/NewsBottomBar.kt new file mode 100644 index 0000000..95a7363 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/NewsBottomBar.kt @@ -0,0 +1,59 @@ +package com.wahid.newscmp.presentation.screen.component + +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.composables.icons.lucide.Heart +import com.composables.icons.lucide.HeartCrack +import com.composables.icons.lucide.HeartOff +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.MessageCircleHeart +import com.composables.icons.lucide.Newspaper +import com.composables.icons.lucide.TrendingUp +import com.wahid.newscmp.presentation.screen.allNews.NewsTab +import com.wahid.newscmp.utils.Colors.ColorAccent +import com.wahid.newscmp.utils.Colors.ColorSurface +import com.wahid.newscmp.utils.Colors.ColorTextMuted + +@Composable +fun NewsBottomBar( + selectedTab: NewsTab, + onTabSelected: (NewsTab) -> Unit, +) { + NavigationBar( + containerColor = ColorSurface, + tonalElevation = 0.dp, + ) { + NewsTab.entries.forEach { tab -> + val isSelected = tab == selectedTab + NavigationBarItem( + selected = isSelected, + onClick = { onTabSelected(tab) }, + icon = { + Icon( + imageVector = when (tab) { + NewsTab.ALL_NEWS -> Lucide.Newspaper + NewsTab.HEADLINES -> Lucide.TrendingUp + NewsTab.FAVORITES -> if (isSelected) Lucide.MessageCircleHeart else Lucide.Heart + }, + contentDescription = tab.label, + ) + }, + label = { Text(tab.label, fontSize = 11.sp) }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = ColorAccent, + selectedTextColor = ColorAccent, + unselectedIconColor = ColorTextMuted, + unselectedTextColor = ColorTextMuted, + indicatorColor = Color(0xFF252540), + ), + ) + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/NwesTopBar.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/NwesTopBar.kt new file mode 100644 index 0000000..5b30522 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/presentation/screen/component/NwesTopBar.kt @@ -0,0 +1,64 @@ +package com.wahid.newscmp.presentation.screen.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.wahid.newscmp.utils.Colors.ColorAccent +import com.wahid.newscmp.utils.Colors.ColorSurface +import com.wahid.newscmp.utils.Colors.ColorTextPrimary + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewsTopBar( +) { + TopAppBar( + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = Modifier + .size(30.dp) + .clip(CircleShape) + .background(ColorAccent), + contentAlignment = Alignment.Center, + ) { + Text( + text = "N", + color = Color.White, + fontWeight = FontWeight.ExtraBold, + fontSize = 15.sp, + ) + } + Text( + text = "NewsLine", + color = ColorTextPrimary, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + letterSpacing = (-0.5).sp, + ) + Spacer(Modifier.width(8.dp)) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = ColorSurface), + ) +} + diff --git a/shared/src/commonMain/kotlin/com/wahid/newscmp/utils/Colors.kt b/shared/src/commonMain/kotlin/com/wahid/newscmp/utils/Colors.kt new file mode 100644 index 0000000..36fa411 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wahid/newscmp/utils/Colors.kt @@ -0,0 +1,14 @@ +package com.wahid.newscmp.utils + +import androidx.compose.ui.graphics.Color + +object Colors { + val ColorBackground = Color(0xFF0F0F16) + val ColorSurface = Color(0xFF1B1B28) + val ColorChip = Color(0xFF252535) + val ColorAccent = Color(0xFF4A8CFF) + val ColorBreaking = Color(0xFFFF4757) + val ColorTextPrimary = Color(0xFFF0F0F6) + val ColorTextMuted = Color(0xFF80839A) + val ColorDivider = Color(0xFF252538) +} \ No newline at end of file From 660caaf45de15cf347ce34b65d4fb56dd51a4ae5 Mon Sep 17 00:00:00 2001 From: wahid Date: Tue, 23 Jun 2026 00:22:19 +0300 Subject: [PATCH 6/6] Add GitHub Actions workflows for Android and iOS builds - Added android.yml to build and upload debug APKs - Added ios.yml and build.yml to build and upload iOS simulator apps - Configured build triggers for pull requests and manual dispatch - Set up Gradle options and artifact uploading for build outputs --- .github/workflows/android.yml | 29 +++++++++++++++++++++++++++ .github/workflows/ios.yml | 37 +++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 .github/workflows/android.yml create mode 100644 .github/workflows/ios.yml diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..e00a24e --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,29 @@ +name: Build + +on: + pull_request: + branches: [ main ] + workflow_dispatch: + +env: + GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4096M -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" +jobs: + build-android: + name: Build Android + runs-on: ubuntu-latest + needs: test + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Gradle setup + uses: ./.github/actions/gradle-setup + + - name: Build Android debug APK + run: ./gradlew :mobile:assembleDebug + + - name: Upload Android debug APK + uses: actions/upload-artifact@v4 + with: + name: android-apk + path: mobile/build/outputs/apk/debug/*.apk diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 0000000..f9781b6 --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,37 @@ +name: Build + +on: + pull_request: + branches: [ main ] + workflow_dispatch: + +env: + GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4096M -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" +jobs: + #... + build-ios: + name: Build iOS simulator app + runs-on: macos-latest + needs: test + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Gradle setup + uses: ./.github/actions/gradle-setup + + - name: Build iOS simulator app + run: | + xcodebuild build \ + -project JetcasterMigration/JetcasterMigration.xcodeproj \ + -configuration Debug \ + -scheme JetcasterMigration \ + -sdk iphonesimulator \ + -derivedDataPath ./build \ + -verbose + + - name: Upload app folder + uses: actions/upload-artifact@v4 + with: + name: iphonesimulator-app + path: build/Build/Products/Debug-iphonesimulator/*