diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4d8ef4c..7bef3a7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,6 +21,7 @@ val localProperties = } val googleWebClientId: String = localProperties.getProperty("GOOGLE_WEB_CLIENT_ID", "") val geminiApiKey: String = localProperties.getProperty("GEMINI_API_KEY", "") +val newsApiKey: String = localProperties.getProperty("NEWSAPI_KEY", "") android { namespace = "com.plainstudio.stackcasino" @@ -38,6 +39,7 @@ android { buildConfigField("String", "GOOGLE_WEB_CLIENT_ID", "\"$googleWebClientId\"") buildConfigField("String", "GEMINI_API_KEY", "\"$geminiApiKey\"") + buildConfigField("String", "NEWSAPI_KEY", "\"$newsApiKey\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") @@ -143,6 +145,14 @@ dependencies { implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.play.services) implementation(libs.google.generativeai) + implementation(libs.retrofit) + implementation(libs.retrofit.converter.moshi) + implementation(libs.okhttp.logging.interceptor) + implementation(libs.moshi) + implementation(libs.moshi.kotlin) + implementation(libs.glide) + implementation(libs.glide.compose) + ksp(libs.moshi.kotlin.codegen) ksp(libs.hilt.compiler) testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) diff --git a/app/src/main/java/com/plainstudio/stackcasino/data/news/NewsApiService.kt b/app/src/main/java/com/plainstudio/stackcasino/data/news/NewsApiService.kt new file mode 100644 index 0000000..c7f8300 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/data/news/NewsApiService.kt @@ -0,0 +1,54 @@ +package com.plainstudio.stackcasino.data.news + +import com.squareup.moshi.JsonClass +import retrofit2.http.GET +import retrofit2.http.Query + +/** + * Retrofit binding for NewsAPI.org EP-01 ("/everything"). + * + * The query is kept narrow at construction time + * ([NEWSAPI_CASINO_QUERY] in the Hilt module) because NewsAPI charges + * per-request and the free Developer tier caps at 100 / day. The + * single endpoint is enough for the second submission: search and + * filtering operate over the cached response, not over additional + * round-trips. + */ +interface NewsApiService { + @GET("everything") + suspend fun fetchEverything( + @Query("q") query: String, + @Query("language") language: String = "en", + @Query("sortBy") sortBy: String = "publishedAt", + @Query("pageSize") pageSize: Int = DEFAULT_PAGE_SIZE, + ): NewsApiResponseDto + + companion object { + const val DEFAULT_PAGE_SIZE = 50 + } +} + +@JsonClass(generateAdapter = true) +data class NewsApiResponseDto( + val status: String, + val totalResults: Int = 0, + val articles: List = emptyList(), + val code: String? = null, + val message: String? = null, +) + +@JsonClass(generateAdapter = true) +data class NewsApiArticleDto( + val source: NewsApiSourceDto, + val title: String?, + val description: String?, + val url: String?, + val urlToImage: String?, + val publishedAt: String?, +) + +@JsonClass(generateAdapter = true) +data class NewsApiSourceDto( + val id: String?, + val name: String?, +) diff --git a/app/src/main/java/com/plainstudio/stackcasino/data/news/NewsArticleMapper.kt b/app/src/main/java/com/plainstudio/stackcasino/data/news/NewsArticleMapper.kt new file mode 100644 index 0000000..7533b21 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/data/news/NewsArticleMapper.kt @@ -0,0 +1,36 @@ +package com.plainstudio.stackcasino.data.news + +import com.plainstudio.stackcasino.domain.news.NewsArticle + +/** + * Maps the network DTO into the domain model. Drops articles where + * NewsAPI returns nulls for fields that are not nullable on the + * domain side (title, url, publishedAt, source name) so the UI never + * has to defensively render empty cards. + * + * NewsAPI uses `"[Removed]"` as a sentinel for content stripped at + * indexing time; those articles are dropped too because they cannot + * be opened or read. + */ +internal fun NewsApiArticleDto.toDomainOrNull(): NewsArticle? { + val cleanTitle = title?.cleaned() + val cleanUrl = url?.cleaned() + val cleanPublishedAt = publishedAt?.cleaned() + val cleanSource = source.name?.cleaned() + if (anyMissing(cleanTitle, cleanUrl, cleanPublishedAt, cleanSource)) return null + return NewsArticle( + id = cleanUrl!!, + title = cleanTitle!!, + source = cleanSource!!, + description = description?.cleaned(), + url = cleanUrl, + imageUrl = urlToImage?.takeUnless { it.isBlank() }, + publishedAtIso = cleanPublishedAt!!, + ) +} + +private fun anyMissing(vararg values: String?): Boolean = values.any { it == null } + +private fun String.cleaned(): String? = takeUnless { isBlank() || this == REMOVED_SENTINEL } + +private const val REMOVED_SENTINEL = "[Removed]" diff --git a/app/src/main/java/com/plainstudio/stackcasino/data/news/NewsRepositoryImpl.kt b/app/src/main/java/com/plainstudio/stackcasino/data/news/NewsRepositoryImpl.kt new file mode 100644 index 0000000..82a2629 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/data/news/NewsRepositoryImpl.kt @@ -0,0 +1,65 @@ +package com.plainstudio.stackcasino.data.news + +import android.util.Log +import com.plainstudio.stackcasino.BuildConfig +import com.plainstudio.stackcasino.domain.news.NewsArticle +import com.plainstudio.stackcasino.domain.news.NewsRepository +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +/** + * NewsAPI-backed implementation of [NewsRepository]. + * + * Caches the last successful fetch in a [ConcurrentHashMap] keyed by + * article URL so [findById] can serve the detail screen without an + * extra round-trip. The cache is process-scoped: when Room lands in + * the next entrega it becomes the single source of truth and this + * map drops out. + * + * Failures are logged under the NewsRepo tag with full stack traces + * so a developer staring at logcat can tell a 401 (bad key) from a + * 429 (rate limit) without having to wrap their own try/catch. + */ +@Singleton +class NewsRepositoryImpl + @Inject + constructor( + private val service: NewsApiService, + ) : NewsRepository { + private val cache = ConcurrentHashMap() + + override suspend fun refresh(): Result> { + if (BuildConfig.NEWSAPI_KEY.isBlank()) { + Log.w(TAG, "NEWSAPI_KEY is empty; refusing to hit NewsAPI.") + return Result.failure(IllegalStateException("NEWSAPI_KEY missing")) + } + return runCatching { + val response = service.fetchEverything(query = NEWSAPI_CASINO_QUERY) + if (response.status != "ok") { + error("NewsAPI returned ${response.status}: ${response.code} ${response.message}") + } + response.articles.mapNotNull { it.toDomainOrNull() } + }.onSuccess { articles -> + cache.clear() + articles.forEach { cache[it.id] = it } + }.onFailure { throwable -> + Log.w(TAG, "NewsAPI refresh failed", throwable) + } + } + + override fun findById(id: String): NewsArticle? = cache[id] + + private companion object { + const val TAG = "NewsRepo" + } + } + +/** + * Query string shipped to NewsAPI EP-01. Matches the project's + * documented scope: anything at the intersection of casino / + * gambling vocabulary and the crypto / Polygon stack the app runs + * on top of. + */ +internal const val NEWSAPI_CASINO_QUERY = + "(casino OR gambling OR blackjack OR roulette) AND (crypto OR blockchain OR polygon OR USDC)" diff --git a/app/src/main/java/com/plainstudio/stackcasino/di/NewsModule.kt b/app/src/main/java/com/plainstudio/stackcasino/di/NewsModule.kt new file mode 100644 index 0000000..714c5fd --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/di/NewsModule.kt @@ -0,0 +1,93 @@ +package com.plainstudio.stackcasino.di + +import com.plainstudio.stackcasino.BuildConfig +import com.plainstudio.stackcasino.data.news.NewsApiService +import com.plainstudio.stackcasino.data.news.NewsRepositoryImpl +import com.plainstudio.stackcasino.domain.news.NewsRepository +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import javax.inject.Singleton + +/** + * DI bindings for the news slice. + * + * Wires the Retrofit + OkHttp + Moshi stack the TPO mandates for the + * second submission, attaches the NewsAPI key as a header on every + * request (so the [NewsApiService] interface stays free of header + * arguments), and binds [NewsRepositoryImpl] to the + * [NewsRepository] domain interface. + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class NewsModule { + @Binds + @Singleton + abstract fun bindNewsRepository(impl: NewsRepositoryImpl): NewsRepository + + companion object { + @Provides + @Singleton + fun provideMoshi(): Moshi = + Moshi + .Builder() + .add(KotlinJsonAdapterFactory()) + .build() + + @Provides + @Singleton + fun provideNewsApiKeyInterceptor(): Interceptor = + Interceptor { chain -> + val request = + chain + .request() + .newBuilder() + .header("X-Api-Key", BuildConfig.NEWSAPI_KEY) + .build() + chain.proceed(request) + } + + @Provides + @Singleton + fun provideOkHttpClient(apiKeyInterceptor: Interceptor): OkHttpClient { + // Basic logging in debug only; release builds keep the log + // noise minimal and never echo response bodies. + val loggingLevel = + if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC else HttpLoggingInterceptor.Level.NONE + val logging = HttpLoggingInterceptor().apply { level = loggingLevel } + return OkHttpClient + .Builder() + .addInterceptor(apiKeyInterceptor) + .addInterceptor(logging) + .build() + } + + @Provides + @Singleton + fun provideRetrofit( + client: OkHttpClient, + moshi: Moshi, + ): Retrofit = + Retrofit + .Builder() + .baseUrl(NEWSAPI_BASE_URL) + .client(client) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + + @Provides + @Singleton + fun provideNewsApiService(retrofit: Retrofit): NewsApiService = retrofit.create(NewsApiService::class.java) + + private const val NEWSAPI_BASE_URL = "https://newsapi.org/v2/" + } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/domain/news/NewsArticle.kt b/app/src/main/java/com/plainstudio/stackcasino/domain/news/NewsArticle.kt new file mode 100644 index 0000000..78ee4c8 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/domain/news/NewsArticle.kt @@ -0,0 +1,25 @@ +package com.plainstudio.stackcasino.domain.news + +/** + * Domain-side article. The data layer maps NewsAPI's JSON envelope to + * this shape before crossing the layer boundary so the feature layer + * never imports a Retrofit DTO directly. + * + * [id] is the article URL: NewsAPI does not expose a stable numeric + * identifier and the URL is already unique per article in their + * dataset, so reusing it lets the detail screen look the article up + * in the repository cache without an extra index. + * + * [imageUrl] and [description] are nullable because NewsAPI omits + * them for some sources (typically obscure ones); Glide falls back + * to a placeholder in that case. + */ +data class NewsArticle( + val id: String, + val title: String, + val source: String, + val description: String?, + val url: String, + val imageUrl: String?, + val publishedAtIso: String, +) diff --git a/app/src/main/java/com/plainstudio/stackcasino/domain/news/NewsRepository.kt b/app/src/main/java/com/plainstudio/stackcasino/domain/news/NewsRepository.kt new file mode 100644 index 0000000..05ddbdb --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/domain/news/NewsRepository.kt @@ -0,0 +1,31 @@ +package com.plainstudio.stackcasino.domain.news + +/** + * Domain boundary for the news feed. + * + * The implementation owns whichever cache strategy makes sense for + * the deliverable: the second submission keeps the latest fetch in + * memory; the final submission swaps the backing store for Room + * without touching the feature layer (offline-first as required by + * the TPO). + * + * Failures bubble up as a failed [Result] so the ViewModel decides + * whether to surface the Error state or render stale cached items + * underneath the error card. + */ +interface NewsRepository { + /** + * Returns the freshest set of articles. Always hits the network + * for now; the cache TTL guard ships with the Room layer in the + * next entrega. + */ + suspend fun refresh(): Result> + + /** + * Looks an article up by [id] inside whatever cache the last + * [refresh] populated. Returns null if the cache is empty or the + * article was never seen (e.g. user opened a deep link the cache + * does not cover yet). + */ + fun findById(id: String): NewsArticle? +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsArticleTime.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsArticleTime.kt new file mode 100644 index 0000000..9dd08d6 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsArticleTime.kt @@ -0,0 +1,58 @@ +package com.plainstudio.stackcasino.feature.news + +import java.time.Duration +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** + * Renders an ISO 8601 publishedAt timestamp as the mockup + * "MM/DD/YYYY - 2H AGO" string. + * + * Returns "JUST NOW" for anything under a minute, falls back to the + * absolute date alone if NewsAPI sent a malformed timestamp the + * parser can't handle (so the row never crashes on an edge entry). + */ +internal fun formatPublishedAt( + isoTimestamp: String, + now: Instant = Instant.now(), + zone: ZoneId = ZoneId.systemDefault(), +): String { + val parsed = + runCatching { Instant.parse(isoTimestamp) } + .getOrElse { return isoTimestamp.fallbackDate() ?: isoTimestamp } + val date = LocalDate.ofInstant(parsed, zone).format(DATE_FORMAT) + val relative = relativeLabel(Duration.between(parsed, now)) + return "$date · $relative" +} + +private fun relativeLabel(elapsed: Duration): String { + val minutes = elapsed.toMinutes() + val hours = elapsed.toHours() + val days = elapsed.toDays() + val weeks = days / DAYS_PER_WEEK + return when { + elapsed.isNegative || minutes < 1 -> "Just now" + minutes < MINUTES_PER_HOUR -> "${minutes}m ago" + hours < HOURS_PER_DAY -> "${hours}h ago" + days < DAYS_PER_WEEK -> "${days}d ago" + weeks < WEEKS_PER_MONTH -> "${weeks}w ago" + else -> "${weeks / WEEKS_PER_MONTH}mo ago" + } +} + +// Last-ditch parse for entries whose publishedAt is just the +// "YYYY-MM-DD" prefix without a time component; if even that fails +// the caller falls back to the raw string. +private fun String.fallbackDate(): String? = + runCatching { take(YYYY_MM_DD_LEN).let { LocalDate.parse(it).format(DATE_FORMAT) } }.getOrNull() + +private const val MINUTES_PER_HOUR = 60L +private const val HOURS_PER_DAY = 24L +private const val DAYS_PER_WEEK = 7L +private const val WEEKS_PER_MONTH = 4L +private const val YYYY_MM_DD_LEN = 10 + +private val DATE_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("M/d/yyyy", Locale.US) diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsDetailScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsDetailScreen.kt new file mode 100644 index 0000000..5d677bd --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsDetailScreen.kt @@ -0,0 +1,331 @@ +package com.plainstudio.stackcasino.feature.news + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.IosShare +import androidx.compose.material.icons.outlined.OpenInNew +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage +import com.bumptech.glide.integration.compose.placeholder +import com.plainstudio.stackcasino.R +import com.plainstudio.stackcasino.domain.news.NewsArticle +import com.plainstudio.stackcasino.ui.components.EmptyState +import com.plainstudio.stackcasino.ui.theme.AccentViolet +import com.plainstudio.stackcasino.ui.theme.StackcasinoTheme +import com.plainstudio.stackcasino.ui.theme.SurfaceBase +import com.plainstudio.stackcasino.ui.theme.SurfaceElevated +import com.plainstudio.stackcasino.ui.theme.SurfaceOutline +import com.plainstudio.stackcasino.ui.theme.SurfaceRaised +import com.plainstudio.stackcasino.ui.theme.TextHigh +import com.plainstudio.stackcasino.ui.theme.TextLow +import com.plainstudio.stackcasino.ui.theme.TextMedium + +/** + * Detail screen for a single news article (cu-13). + * + * * Reads the article id from the back-stack arguments, looks it up + * in the [com.plainstudio.stackcasino.domain.news.NewsRepository] + * cache and renders the hero + body or a "not found" empty state. + * * Top bar mirrors the assistant header: back chip, the article + * source as the title, and a share chip on the right that fires + * `Intent.ACTION_SEND` with `"$title - $url"`. + * * Article body ends with a "Read full article" button that opens + * the source URL in the browser via `Intent.ACTION_VIEW`. + */ +@Composable +fun NewsDetailScreen( + onBack: () -> Unit, + viewModel: NewsDetailViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + Surface(modifier = Modifier.fillMaxSize(), color = SurfaceBase) { + when (val current = state) { + NewsDetailUiState.NotFound -> NotFoundBody(onBack = onBack) + is NewsDetailUiState.Loaded -> LoadedBody(article = current.article, onBack = onBack) + } + } +} + +@Composable +private fun LoadedBody( + article: NewsArticle, + onBack: () -> Unit, +) { + val context = LocalContext.current + Column(modifier = Modifier.fillMaxSize()) { + DetailHeader( + sourceLabel = article.source, + onBack = onBack, + onShare = { context.shareArticle(article) }, + ) + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = BottomScrollPadding), + ) { + HeroImage(imageUrl = article.imageUrl) + ArticleBody(article = article, onReadFull = { context.openInBrowser(article.url) }) + } + } +} + +@Composable +private fun DetailHeader( + sourceLabel: String, + onBack: () -> Unit, + onShare: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = HeaderHorizontalPadding, vertical = HeaderVerticalPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + IconChip( + icon = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back", + onClick = onBack, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "NEWS", + color = TextLow, + fontSize = HeaderEyebrowFontSize, + letterSpacing = TrackedLetterSpacing, + ) + Text( + text = sourceLabel, + color = TextHigh, + fontSize = HeaderTitleFontSize, + fontWeight = FontWeight.Bold, + ) + } + IconChip( + icon = Icons.Outlined.IosShare, + contentDescription = "Share article", + onClick = onShare, + ) + } + Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(SurfaceOutline)) +} + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +private fun HeroImage(imageUrl: String?) { + Box(modifier = Modifier.fillMaxWidth().aspectRatio(HERO_ASPECT_RATIO).background(SurfaceElevated)) { + GlideImage( + model = imageUrl, + contentDescription = null, + loading = placeholder(R.drawable.ic_news_placeholder), + failure = placeholder(R.drawable.ic_news_placeholder), + modifier = Modifier.fillMaxSize(), + ) + } +} + +@Composable +private fun ArticleBody( + article: NewsArticle, + onReadFull: () -> Unit, +) { + Column(modifier = Modifier.padding(BodyPadding)) { + Text( + text = article.title, + color = TextHigh, + fontSize = TitleFontSize, + fontWeight = FontWeight.Bold, + lineHeight = TitleLineHeight, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = formatPublishedAt(article.publishedAtIso), + color = TextLow, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + if (!article.description.isNullOrBlank()) { + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = article.description, + color = TextMedium, + fontSize = BodyFontSize, + lineHeight = BodyLineHeight, + ) + } + Spacer(modifier = Modifier.height(24.dp)) + ReadFullArticleButton(onClick = onReadFull) + } +} + +@Composable +private fun ReadFullArticleButton(onClick: () -> Unit) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(AccentViolet) + .clickable(onClick = onClick) + .padding(ReadFullPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = "READ FULL ARTICLE", + color = Color.White, + fontSize = ReadFullFontSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + ) + Spacer(modifier = Modifier.size(8.dp)) + Icon( + imageVector = Icons.Outlined.OpenInNew, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(ReadFullIconSize), + ) + } +} + +@Composable +private fun NotFoundBody(onBack: () -> Unit) { + Box( + modifier = Modifier.fillMaxSize().padding(horizontal = ScreenHorizontalPadding), + contentAlignment = Alignment.Center, + ) { + EmptyState( + icon = { + Icon( + painter = painterResource(R.drawable.ic_news_placeholder), + contentDescription = null, + tint = TextLow, + modifier = Modifier.size(NotFoundIconSize), + ) + }, + title = "Article not found", + message = "The article isn't in your latest feed cache anymore. Refresh the news list and try again.", + actionLabel = "Back to news", + onAction = onBack, + ) + } +} + +@Composable +private fun IconChip( + icon: androidx.compose.ui.graphics.vector.ImageVector, + contentDescription: String, + onClick: () -> Unit, +) { + Box( + modifier = + Modifier + .size(IconChipSize) + .background(SurfaceRaised) + .border(width = 1.dp, color = SurfaceOutline) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = TextHigh, + modifier = Modifier.size(IconChipIconSize), + ) + } +} + +private fun Context.shareArticle(article: NewsArticle) { + val send = + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, article.title) + putExtra(Intent.EXTRA_TEXT, "${article.title} - ${article.url}") + } + startActivity(Intent.createChooser(send, null)) +} + +private fun Context.openInBrowser(url: String) { + val view = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + startActivity(view) +} + +private val HeaderHorizontalPadding = 16.dp +private val HeaderVerticalPadding = 12.dp +private val HeaderEyebrowFontSize = 9.sp +private val HeaderTitleFontSize = 18.sp + +private val IconChipSize = 36.dp +private val IconChipIconSize = 16.dp + +private const val HERO_ASPECT_RATIO = 16f / 9f +private val BodyPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp) +private val TitleFontSize = 22.sp +private val TitleLineHeight = 28.sp +private val BodyFontSize = 14.sp +private val BodyLineHeight = 22.sp +private val BottomScrollPadding = 32.dp + +private val ReadFullPadding = PaddingValues(vertical = 14.dp) +private val ReadFullFontSize = 12.sp +private val ReadFullIconSize = 14.dp +private val NotFoundIconSize = 24.dp + +@androidx.compose.ui.tooling.preview.Preview(showBackground = true, backgroundColor = 0xFF0B0B12, heightDp = 900) +@Composable +private fun NewsDetailLoadedPreview() { + StackcasinoTheme { + LoadedBody( + article = + NewsArticle( + id = "preview-id", + title = "Bitcoin Surges Past $70,000 as Institutional Interest Grows", + source = "CryptoNews", + description = + "Major fund managers continued to expand their crypto allocations " + + "this week as Bitcoin breached the $70,000 threshold.", + url = "https://example.com", + imageUrl = null, + publishedAtIso = "2026-04-16T07:30:00Z", + ), + onBack = {}, + ) + } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsDetailViewModel.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsDetailViewModel.kt new file mode 100644 index 0000000..cb478ad --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsDetailViewModel.kt @@ -0,0 +1,51 @@ +package com.plainstudio.stackcasino.feature.news + +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.plainstudio.stackcasino.domain.news.NewsArticle +import com.plainstudio.stackcasino.domain.news.NewsRepository +import com.plainstudio.stackcasino.navigation.Route +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +/** + * Detail-screen state machine. + * + * Only two real states: the article is in the repository cache and + * we render it, or it is not (the user opened the deep link before + * the feed loaded) and we render a "Not found" empty state. + * + * No fetch happens here: the second-submission scope keeps everything + * the user can navigate to under the umbrella of the most recent + * [NewsRepository.refresh] from the feed screen. + */ +@HiltViewModel +class NewsDetailViewModel + @Inject + constructor( + savedStateHandle: SavedStateHandle, + repository: NewsRepository, + ) : ViewModel() { + private val _uiState = + MutableStateFlow( + savedStateHandle + .get(Route.NewsDetail.ARG_ARTICLE_ID) + ?.let { Uri.decode(it) } + ?.let { repository.findById(it) } + ?.let { NewsDetailUiState.Loaded(it) } + ?: NewsDetailUiState.NotFound, + ) + val uiState: StateFlow = _uiState.asStateFlow() + } + +sealed interface NewsDetailUiState { + data object NotFound : NewsDetailUiState + + data class Loaded( + val article: NewsArticle, + ) : NewsDetailUiState +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsLoadingBody.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsLoadingBody.kt new file mode 100644 index 0000000..2f494b0 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsLoadingBody.kt @@ -0,0 +1,69 @@ +package com.plainstudio.stackcasino.feature.news + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.plainstudio.stackcasino.ui.components.Skeleton + +/** + * Loading state: skeleton placeholders shaped like the FEATURED hero + * and three list rows so the layout doesn't pop when the first fetch + * resolves. + */ +@Composable +internal fun NewsLoadingBody() { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = ScreenHorizontalPadding, vertical = SectionVerticalPadding), + verticalArrangement = Arrangement.spacedBy(SectionVerticalPadding), + ) { + FeaturedSkeleton() + Spacer(modifier = Modifier.height(4.dp)) + repeat(LIST_SKELETON_COUNT) { + ListRowSkeleton() + } + } +} + +@Composable +private fun FeaturedSkeleton() { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Skeleton(modifier = Modifier.fillMaxWidth().height(FeaturedSkeletonHeight)) + Skeleton(modifier = Modifier.fillMaxWidth(fraction = 0.75f).height(SkeletonTextLineHeight)) + Skeleton(modifier = Modifier.fillMaxWidth(fraction = 0.50f).height(SkeletonTextLineHeight)) + } +} + +@Composable +private fun ListRowSkeleton() { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Skeleton(modifier = Modifier.size(ListRowThumbSize)) + Column( + modifier = Modifier.weight(1f).padding(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Skeleton(modifier = Modifier.fillMaxWidth(fraction = 0.40f).height(SkeletonTextLineHeight)) + Skeleton(modifier = Modifier.fillMaxWidth().height(SkeletonTextLineHeight)) + Skeleton(modifier = Modifier.fillMaxWidth(fraction = 0.65f).height(SkeletonTextLineHeight)) + } + } +} + +private val FeaturedSkeletonHeight = 200.dp +private val ListRowThumbSize = 96.dp +private val SkeletonTextLineHeight = 10.dp +private const val LIST_SKELETON_COUNT = 3 diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsScreen.kt new file mode 100644 index 0000000..7734551 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsScreen.kt @@ -0,0 +1,249 @@ +package com.plainstudio.stackcasino.feature.news + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +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.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.plainstudio.stackcasino.ui.components.ErrorState +import com.plainstudio.stackcasino.ui.components.ErrorStateDefaults +import com.plainstudio.stackcasino.ui.components.FilterChip +import com.plainstudio.stackcasino.ui.components.FilterChipRow +import com.plainstudio.stackcasino.ui.components.gridBackground +import com.plainstudio.stackcasino.ui.theme.AccentViolet +import com.plainstudio.stackcasino.ui.theme.SurfaceBase +import com.plainstudio.stackcasino.ui.theme.SurfaceOutline +import com.plainstudio.stackcasino.ui.theme.SurfaceRaised +import com.plainstudio.stackcasino.ui.theme.TextHigh +import com.plainstudio.stackcasino.ui.theme.TextLow + +/** + * News screen reproducing the cu-12 mockup + * (mockup/js/screens/news.js). Owns: + * + * * the header + reactive search input + dynamic source filter chips, + * * a state branch on [NewsUiState] (Loading / Success / Error), + * * a featured-articles carousel (HorizontalPager) at the top of the + * Success body and a LazyColumn for the rest of the LATEST list. + * + * Per the TPO doc the screen mandates Loading / Success / Error + * states, Retrofit for the listing, reactive search + filter, Glide + * for image rendering and a parametric Detail route — all wired here. + */ +@Composable +fun NewsScreen( + onOpenArticle: (articleId: String) -> Unit, + viewModel: NewsViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + NewsContent( + state = state, + onQueryChange = viewModel::onQueryChange, + onSourceChange = viewModel::onSourceChange, + onRetry = viewModel::refresh, + onOpenArticle = onOpenArticle, + ) +} + +@Composable +private fun NewsContent( + state: NewsUiState, + onQueryChange: (String) -> Unit, + onSourceChange: (SourceFilter) -> Unit, + onRetry: () -> Unit, + onOpenArticle: (String) -> Unit, +) { + Surface(modifier = Modifier.fillMaxSize(), color = SurfaceBase) { + Column(modifier = Modifier.fillMaxSize().gridBackground()) { + Header() + HorizontalDivider() + SearchInput( + value = (state as? NewsUiState.Success)?.query.orEmpty(), + enabled = state is NewsUiState.Success, + onValueChange = onQueryChange, + ) + if (state is NewsUiState.Success) { + SourceChips( + sources = state.sources, + selected = state.selectedSource, + onSelect = onSourceChange, + ) + } + when (state) { + NewsUiState.Loading -> NewsLoadingBody() + is NewsUiState.Success -> + NewsSuccessBody(state = state, onOpenArticle = onOpenArticle) + is NewsUiState.Error -> NewsErrorBody(state = state, onRetry = onRetry) + } + } + } +} + +@Composable +private fun Header() { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding( + start = ScreenHorizontalPadding, + end = ScreenHorizontalPadding, + top = HeaderTopPadding, + bottom = HeaderBottomPadding, + ), + ) { + Text( + text = "News", + color = TextHigh, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + ) + } +} + +@Composable +private fun SearchInput( + value: String, + enabled: Boolean, + onValueChange: (String) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = ScreenHorizontalPadding, vertical = SearchVerticalPadding) + .background(SurfaceRaised) + .border(width = 1.dp, color = SurfaceOutline), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.width(SearchIconColumnWidth).height(SearchInputHeight), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Search, + contentDescription = null, + tint = TextLow, + modifier = Modifier.size(SearchIconSize), + ) + } + Box( + modifier = Modifier.weight(1f).padding(end = 12.dp), + contentAlignment = Alignment.CenterStart, + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + enabled = enabled, + textStyle = TextStyle(color = TextHigh, fontSize = SearchFontSize), + cursorBrush = SolidColor(AccentViolet), + modifier = Modifier.fillMaxWidth(), + ) + if (value.isEmpty()) { + Text( + text = "Search articles...", + color = TextLow, + fontSize = SearchFontSize, + ) + } + } + } +} + +@Composable +private fun SourceChips( + sources: List, + selected: SourceFilter, + onSelect: (SourceFilter) -> Unit, +) { + val chips = + buildList> { + add(FilterChip(key = SourceFilter.All, label = "All sources")) + sources.forEach { source -> + add(FilterChip(key = SourceFilter.Named(source), label = source)) + } + } + FilterChipRow( + chips = chips, + selected = selected, + onSelect = onSelect, + scrollable = true, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = ScreenHorizontalPadding, vertical = SourceChipsVerticalPadding), + ) +} + +@Composable +private fun NewsErrorBody( + state: NewsUiState.Error, + onRetry: () -> Unit, +) { + Box( + modifier = Modifier.fillMaxSize().padding(horizontal = ScreenHorizontalPadding), + contentAlignment = Alignment.Center, + ) { + ErrorState( + icon = { ErrorStateDefaults.OfflineIcon() }, + title = "Couldn't load articles", + message = state.message, + primaryActionLabel = "Retry", + onPrimaryAction = onRetry, + ) + } +} + +@Composable +internal fun HorizontalDivider() { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(1.dp) + .background(SurfaceOutline), + ) +} + +// --------------------------------------------------------------------------- +// Tokens shared across the news feature files. +// --------------------------------------------------------------------------- + +internal val ScreenHorizontalPadding = 16.dp +internal val SectionVerticalPadding = 16.dp +internal val MetaFontSize = 10.sp +internal val SmallMetaFontSize = 9.sp +internal val TrackedLetterSpacing = 1.2.sp + +private val HeaderTopPadding = 24.dp +private val HeaderBottomPadding = 16.dp +private val SearchVerticalPadding = 16.dp +private val SearchIconColumnWidth = 44.dp +private val SearchInputHeight = 40.dp +private val SearchIconSize = 16.dp +private val SearchFontSize = 14.sp +private val SourceChipsVerticalPadding = 8.dp diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsSuccessBody.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsSuccessBody.kt new file mode 100644 index 0000000..8d20f17 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsSuccessBody.kt @@ -0,0 +1,349 @@ +package com.plainstudio.stackcasino.feature.news + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Article +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.Color +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 com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage +import com.bumptech.glide.integration.compose.placeholder +import com.plainstudio.stackcasino.R +import com.plainstudio.stackcasino.domain.news.NewsArticle +import com.plainstudio.stackcasino.ui.components.EmptyState +import com.plainstudio.stackcasino.ui.theme.AccentViolet +import com.plainstudio.stackcasino.ui.theme.SurfaceElevated +import com.plainstudio.stackcasino.ui.theme.SurfaceOutline +import com.plainstudio.stackcasino.ui.theme.SurfaceRaised +import com.plainstudio.stackcasino.ui.theme.TextHigh +import com.plainstudio.stackcasino.ui.theme.TextLow +import com.plainstudio.stackcasino.ui.theme.TextMedium + +/** + * Success body: the FEATURED swipeable carousel (HorizontalPager + * over the first [CAROUSEL_SIZE] articles) followed by the LATEST + * LazyColumn for the rest. The carousel is the project's "carousel" + * deliverable; the LazyColumn is the TPO-required LazyColumn fed by + * the Retrofit response. + * + * Search + source filtering reshape [NewsUiState.Success.filtered] + * upstream; this composable just renders whatever subset is current + * and shows an [EmptyState] when the filter has no matches. + */ +@Composable +internal fun NewsSuccessBody( + state: NewsUiState.Success, + onOpenArticle: (String) -> Unit, +) { + val visible = state.filtered + if (visible.isEmpty()) { + EmptyFilterState() + return + } + val featured = visible.take(CAROUSEL_SIZE) + val latest = visible.drop(featured.size) + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = SectionVerticalPadding), + ) { + item("carousel") { + FeaturedCarousel(featured = featured, onOpenArticle = onOpenArticle) + } + if (latest.isNotEmpty()) { + item("latest-header") { + LatestSectionHeader() + } + items(items = latest, key = { it.id }) { article -> + LatestRow(article = article, onClick = { onOpenArticle(article.id) }) + } + } + item("footer") { + ListFooter(total = state.allArticles.size, visible = visible.size) + } + } +} + +@Composable +private fun FeaturedCarousel( + featured: List, + onOpenArticle: (String) -> Unit, +) { + val pagerState = rememberPagerState(pageCount = { featured.size }) + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = ScreenHorizontalPadding)) { + HorizontalPager( + state = pagerState, + contentPadding = PaddingValues(end = CarouselPeek), + pageSpacing = CarouselPageSpacing, + modifier = Modifier.fillMaxWidth(), + ) { page -> + FeaturedCard(article = featured[page], onClick = { onOpenArticle(featured[page].id) }) + } + Spacer(modifier = Modifier.height(CarouselIndicatorTopGap)) + CarouselIndicator(pageCount = featured.size, currentPage = pagerState.currentPage) + } +} + +@Composable +private fun CarouselIndicator( + pageCount: Int, + currentPage: Int, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + repeat(pageCount) { index -> + val isActive = index == currentPage + val width = if (isActive) IndicatorActiveWidth else IndicatorDotSize + Box( + modifier = + Modifier + .padding(horizontal = 3.dp) + .size(width = width, height = IndicatorDotSize) + .background(if (isActive) AccentViolet else SurfaceOutline), + ) + } + } +} + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +private fun FeaturedCard( + article: NewsArticle, + onClick: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .background(SurfaceRaised) + .border(width = 1.dp, color = SurfaceOutline) + .clickable(onClick = onClick), + ) { + Box(modifier = Modifier.fillMaxWidth().aspectRatio(FEATURED_ASPECT_RATIO).background(SurfaceElevated)) { + GlideImage( + model = article.imageUrl, + contentDescription = null, + loading = placeholder(R.drawable.ic_news_placeholder), + failure = placeholder(R.drawable.ic_news_placeholder), + modifier = Modifier.fillMaxSize(), + ) + Box( + modifier = + Modifier + .padding(BadgeOffset) + .background(AccentViolet) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text( + text = "FEATURED", + color = Color.White, + fontSize = BadgeFontSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + ) + } + } + Column(modifier = Modifier.padding(FeaturedTextPadding)) { + Text( + text = article.title, + color = TextHigh, + fontSize = FeaturedTitleFontSize, + fontWeight = FontWeight.SemiBold, + lineHeight = FeaturedTitleLineHeight, + maxLines = FEATURED_TITLE_MAX_LINES, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = article.source, + color = AccentViolet, + fontSize = MetaFontSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + ) + Text( + text = formatPublishedAt(article.publishedAtIso), + color = TextLow, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + } + } +} + +@Composable +private fun LatestSectionHeader() { + Text( + text = "LATEST", + color = TextMedium, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + modifier = + Modifier + .fillMaxWidth() + .padding( + start = ScreenHorizontalPadding, + end = ScreenHorizontalPadding, + top = LatestHeaderTopGap, + bottom = LatestHeaderBottomGap, + ), + ) +} + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +private fun LatestRow( + article: NewsArticle, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = ScreenHorizontalPadding, vertical = LatestRowVerticalPadding) + .background(SurfaceRaised) + .border(width = 1.dp, color = SurfaceOutline) + .clickable(onClick = onClick), + ) { + Box(modifier = Modifier.size(LatestThumbSize).background(SurfaceElevated)) { + GlideImage( + model = article.imageUrl, + contentDescription = null, + loading = placeholder(R.drawable.ic_news_placeholder), + failure = placeholder(R.drawable.ic_news_placeholder), + modifier = Modifier.fillMaxSize(), + ) + } + Column(modifier = Modifier.weight(1f).padding(LatestRowTextPadding)) { + Text( + text = article.source, + color = AccentViolet, + fontSize = SmallMetaFontSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = TrackedLetterSpacing, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = article.title, + color = TextHigh, + fontSize = LatestTitleFontSize, + fontWeight = FontWeight.SemiBold, + lineHeight = LatestTitleLineHeight, + maxLines = LATEST_TITLE_MAX_LINES, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = formatPublishedAt(article.publishedAtIso), + color = TextLow, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + } +} + +@Composable +private fun ListFooter( + total: Int, + visible: Int, +) { + val text = + if (visible == total) "$total ARTICLES CACHED" else "SHOWING $visible OF $total ARTICLES" + Box( + modifier = Modifier.fillMaxWidth().padding(vertical = FooterVerticalPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + color = TextLow, + fontSize = MetaFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } +} + +@Composable +private fun EmptyFilterState() { + Box( + modifier = Modifier.fillMaxSize().padding(horizontal = ScreenHorizontalPadding), + contentAlignment = Alignment.Center, + ) { + EmptyState( + icon = { + androidx.compose.material3.Icon( + imageVector = Icons.Outlined.Article, + contentDescription = null, + tint = TextLow, + modifier = Modifier.size(EmptyIconSize), + ) + }, + title = "No articles match", + message = "Try clearing the filters or your search query.", + ) + } +} + +// Surface a hint of the next page so users discover the carousel is +// swipeable; matches the typical mobile pager convention. +private val CarouselPeek = 24.dp +private val CarouselPageSpacing = 12.dp +private val CarouselIndicatorTopGap = 12.dp +private val IndicatorDotSize = 6.dp +private val IndicatorActiveWidth = 18.dp + +private const val FEATURED_ASPECT_RATIO = 16f / 9f +private val FeaturedTextPadding = 16.dp +private val FeaturedTitleFontSize = 16.sp +private val FeaturedTitleLineHeight = 22.sp +private const val FEATURED_TITLE_MAX_LINES = 3 + +private val BadgeOffset = 12.dp +private val BadgeFontSize = 9.sp + +private val LatestHeaderTopGap = 24.dp +private val LatestHeaderBottomGap = 8.dp +private val LatestRowVerticalPadding = 4.dp +private val LatestThumbSize = 112.dp +private val LatestRowTextPadding = 12.dp +private val LatestTitleFontSize = 14.sp +private val LatestTitleLineHeight = 20.sp +private const val LATEST_TITLE_MAX_LINES = 2 + +private val FooterVerticalPadding = 16.dp +private val EmptyIconSize = 24.dp + +private const val CAROUSEL_SIZE = 5 diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsUiState.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsUiState.kt new file mode 100644 index 0000000..2bb2a5b --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsUiState.kt @@ -0,0 +1,44 @@ +package com.plainstudio.stackcasino.feature.news + +import com.plainstudio.stackcasino.domain.news.NewsArticle + +/** + * UI state for the news feed. + * + * * [Loading] -> first fetch in flight with no cache to fall back on. + * * [Success] -> the fetch succeeded; [filtered] is the live filtered + * view (after applying [query] + [selectedSource]) over [allArticles]. + * * [Error] -> the fetch failed and the cache is empty; the screen + * shows the centred error card with a retry CTA. + * + * Search and source filtering happen in the ViewModel against the + * cached list so typing does not burn the daily NewsAPI quota. + */ +sealed interface NewsUiState { + data object Loading : NewsUiState + + data class Success( + val allArticles: List, + val filtered: List, + val sources: List, + val query: String, + val selectedSource: SourceFilter, + ) : NewsUiState + + data class Error( + val message: String, + ) : NewsUiState +} + +/** + * Either the wildcard "All" pill or a specific publisher name. Kept + * as a sealed type so the chip row can pattern-match without leaning + * on a magic null or sentinel string. + */ +sealed interface SourceFilter { + data object All : SourceFilter + + data class Named( + val name: String, + ) : SourceFilter +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsViewModel.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsViewModel.kt new file mode 100644 index 0000000..1f7efa6 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/news/NewsViewModel.kt @@ -0,0 +1,116 @@ +package com.plainstudio.stackcasino.feature.news + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.plainstudio.stackcasino.domain.news.NewsArticle +import com.plainstudio.stackcasino.domain.news.NewsRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Drives the news feed. + * + * * On construction the VM transitions to [NewsUiState.Loading] + * and triggers the first [NewsRepository.refresh]. + * * Success populates the cached articles list, computes the unique + * set of sources (used as filter chips) and recomputes the + * filtered view. + * * Failure transitions to [NewsUiState.Error] so the screen can + * render the retry card. + * + * Search and source filtering are applied in-memory over the cached + * articles; neither dispatches a new network call. The TPO limits us + * to 100 requests / day so we only refetch on explicit retry. + */ +@HiltViewModel +class NewsViewModel + @Inject + constructor( + private val repository: NewsRepository, + ) : ViewModel() { + private val _uiState = MutableStateFlow(NewsUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + refresh() + } + + fun refresh() { + _uiState.value = NewsUiState.Loading + viewModelScope.launch { + repository.refresh().fold( + onSuccess = { articles -> _uiState.value = articles.toSuccessState() }, + onFailure = { throwable -> + _uiState.value = + NewsUiState.Error( + message = + throwable.message + ?: "Couldn't reach NewsAPI right now. Try again in a moment.", + ) + }, + ) + } + } + + fun onQueryChange(query: String) { + _uiState.update { current -> + if (current is NewsUiState.Success) current.copy(query = query).recomputeFiltered() else current + } + } + + fun onSourceChange(source: SourceFilter) { + _uiState.update { current -> + if (current is NewsUiState.Success) { + current.copy(selectedSource = source).recomputeFiltered() + } else { + current + } + } + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private fun List.toSuccessState(): NewsUiState.Success { + val sources = + map { it.source } + .distinct() + .sortedBy { it.lowercase() } + return NewsUiState.Success( + allArticles = this, + filtered = this, + sources = sources, + query = "", + selectedSource = SourceFilter.All, + ) + } + + private fun NewsUiState.Success.recomputeFiltered(): NewsUiState.Success { + val trimmed = query.trim() + val filtered = + allArticles.filter { article -> + article.matches(selectedSource) && article.matches(trimmed) + } + return copy(filtered = filtered) + } + + private fun NewsArticle.matches(filter: SourceFilter): Boolean = + when (filter) { + SourceFilter.All -> true + is SourceFilter.Named -> source.equals(filter.name, ignoreCase = true) + } + + private fun NewsArticle.matches(query: String): Boolean = + if (query.isEmpty()) { + true + } else { + title.contains(query, ignoreCase = true) || + source.contains(query, ignoreCase = true) + } + } diff --git a/app/src/main/java/com/plainstudio/stackcasino/navigation/Route.kt b/app/src/main/java/com/plainstudio/stackcasino/navigation/Route.kt index f51e30e..4f5c2d2 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/navigation/Route.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/navigation/Route.kt @@ -53,7 +53,20 @@ sealed class Route( data object NewsDetail : Route("news/{articleId}") { const val ARG_ARTICLE_ID = "articleId" - fun build(articleId: String): String = "news/$articleId" + // NewsAPI returns the article URL as the only stable id, so the + // path arg has to survive the colons + slashes of a real URL. + // URLEncoder is used (vs android.net.Uri.encode) so the helper + // also works under plain JVM unit tests where the Android stub + // would return null. The +-to-%20 swap brings the output to + // RFC 3986 path encoding because URLEncoder encodes spaces as + // `+` (form-encoding) by default. + fun build(articleId: String): String { + val encoded = + java.net.URLEncoder + .encode(articleId, java.nio.charset.StandardCharsets.UTF_8) + .replace("+", "%20") + return "news/$encoded" + } } } diff --git a/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt b/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt index 8e019bf..431ae59 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt @@ -20,6 +20,8 @@ import com.plainstudio.stackcasino.feature.history.historyPreviewData import com.plainstudio.stackcasino.feature.lobby.LobbyScreen import com.plainstudio.stackcasino.feature.lobby.LobbyUiState import com.plainstudio.stackcasino.feature.lobby.previewLobbyData +import com.plainstudio.stackcasino.feature.news.NewsDetailScreen +import com.plainstudio.stackcasino.feature.news.NewsScreen import com.plainstudio.stackcasino.feature.wallet.WalletScreen import com.plainstudio.stackcasino.feature.wallet.previewWalletData @@ -88,29 +90,41 @@ fun StackNavHost( composable(Route.Assistant.path) { AssistantScreen(onBack = { navController.popBackStack() }) } + composable(Route.News.path) { + NewsScreen( + onOpenArticle = { articleId -> + navController.navigate(Route.NewsDetail.build(articleId)) { + launchSingleTop = true + } + }, + ) + } PLACEHOLDER_ROUTES.forEach { (route, label) -> placeholderRoute(route, label) } - composable( - route = Route.RoundDetail.path, - arguments = - listOf( - navArgument(Route.RoundDetail.ARG_ROUND_ID) { type = NavType.StringType }, - ), - ) { entry -> - val id = entry.requireStringArg(Route.RoundDetail.ARG_ROUND_ID) - Placeholder("Round Detail · $id") - } - composable( - route = Route.NewsDetail.path, - arguments = - listOf( - navArgument(Route.NewsDetail.ARG_ARTICLE_ID) { type = NavType.StringType }, - ), - ) { entry -> - val id = entry.requireStringArg(Route.NewsDetail.ARG_ARTICLE_ID) - Placeholder("News Detail · $id") - } + addParametricRoutes(navController) + } +} + +/** + * Routes with placeholders for their string argument. Kept out of the + * main [StackNavHost] body so the entry function stays under the + * detekt LongMethod budget and the parametric registrations group + * together for readers scanning the file top-down. + */ +private fun NavGraphBuilder.addParametricRoutes(navController: NavHostController) { + composable( + route = Route.RoundDetail.path, + arguments = listOf(navArgument(Route.RoundDetail.ARG_ROUND_ID) { type = NavType.StringType }), + ) { entry -> + val id = entry.requireStringArg(Route.RoundDetail.ARG_ROUND_ID) + Placeholder("Round Detail · $id") + } + composable( + route = Route.NewsDetail.path, + arguments = listOf(navArgument(Route.NewsDetail.ARG_ARTICLE_ID) { type = NavType.StringType }), + ) { + NewsDetailScreen(onBack = { navController.popBackStack() }) } } @@ -122,7 +136,6 @@ fun StackNavHost( private val PLACEHOLDER_ROUTES: List> = listOf( Route.HouseWallet to "House Wallet", - Route.News to "News", Route.Profile to "Profile", Route.Kyc to "KYC", Route.Coinflip to "Coinflip", diff --git a/app/src/main/res/drawable/ic_news_placeholder.xml b/app/src/main/res/drawable/ic_news_placeholder.xml new file mode 100644 index 0000000..1ac8462 --- /dev/null +++ b/app/src/main/res/drawable/ic_news_placeholder.xml @@ -0,0 +1,19 @@ + + + + diff --git a/app/src/test/java/com/plainstudio/stackcasino/data/news/NewsArticleMapperTest.kt b/app/src/test/java/com/plainstudio/stackcasino/data/news/NewsArticleMapperTest.kt new file mode 100644 index 0000000..8477517 --- /dev/null +++ b/app/src/test/java/com/plainstudio/stackcasino/data/news/NewsArticleMapperTest.kt @@ -0,0 +1,74 @@ +package com.plainstudio.stackcasino.data.news + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class NewsArticleMapperTest { + @Test + fun `maps a well-formed dto into the domain article using the url as id`() { + val dto = + NewsApiArticleDto( + source = NewsApiSourceDto(id = "cn", name = "CryptoNews"), + title = "Bitcoin surges past 70k", + description = "Institutional flows continue to drive demand.", + url = "https://cryptonews.example/btc-70k", + urlToImage = "https://cryptonews.example/cover.jpg", + publishedAt = "2026-04-16T07:30:00Z", + ) + + val article = dto.toDomainOrNull() + + assertEquals("https://cryptonews.example/btc-70k", article?.id) + assertEquals("https://cryptonews.example/btc-70k", article?.url) + assertEquals("Bitcoin surges past 70k", article?.title) + assertEquals("CryptoNews", article?.source) + } + + @Test + fun `drops the article when the title is missing`() { + val dto = baseDto().copy(title = null) + assertNull(dto.toDomainOrNull()) + } + + @Test + fun `drops the article when the title is the Removed sentinel`() { + val dto = baseDto().copy(title = "[Removed]") + assertNull(dto.toDomainOrNull()) + } + + @Test + fun `drops the article when the url is blank`() { + val dto = baseDto().copy(url = " ") + assertNull(dto.toDomainOrNull()) + } + + @Test + fun `drops the article when the source name is missing`() { + val dto = baseDto().copy(source = NewsApiSourceDto(id = null, name = null)) + assertNull(dto.toDomainOrNull()) + } + + @Test + fun `keeps the article and drops the description when description is the Removed sentinel`() { + val article = baseDto().copy(description = "[Removed]").toDomainOrNull() + assertNull(article?.description) + assertEquals("Bitcoin surges past 70k", article?.title) + } + + @Test + fun `keeps the article and drops the imageUrl when the image is blank`() { + val article = baseDto().copy(urlToImage = "").toDomainOrNull() + assertNull(article?.imageUrl) + } + + private fun baseDto(): NewsApiArticleDto = + NewsApiArticleDto( + source = NewsApiSourceDto(id = "cn", name = "CryptoNews"), + title = "Bitcoin surges past 70k", + description = "Summary.", + url = "https://cryptonews.example/btc-70k", + urlToImage = "https://cryptonews.example/cover.jpg", + publishedAt = "2026-04-16T07:30:00Z", + ) +} diff --git a/app/src/test/java/com/plainstudio/stackcasino/feature/news/NewsViewModelTest.kt b/app/src/test/java/com/plainstudio/stackcasino/feature/news/NewsViewModelTest.kt new file mode 100644 index 0000000..b7052bd --- /dev/null +++ b/app/src/test/java/com/plainstudio/stackcasino/feature/news/NewsViewModelTest.kt @@ -0,0 +1,179 @@ +package com.plainstudio.stackcasino.feature.news + +import app.cash.turbine.test +import com.plainstudio.stackcasino.domain.news.NewsArticle +import com.plainstudio.stackcasino.domain.news.NewsRepository +import com.plainstudio.stackcasino.testing.MainDispatcherRule +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class NewsViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val repository = mockk() + + private fun viewModel(): NewsViewModel = NewsViewModel(repository) + + private val sampleArticles = + listOf( + article(id = "a", title = "Bitcoin surges past 70k", source = "CryptoNews"), + article(id = "b", title = "Polygon zkEVM milestone", source = "Polygon Post"), + article(id = "c", title = "Blackjack splitting math", source = "CryptoNews"), + ) + + private fun article( + id: String, + title: String, + source: String, + ): NewsArticle = + NewsArticle( + id = id, + title = title, + source = source, + description = null, + url = "https://example.com/$id", + imageUrl = null, + publishedAtIso = "2026-04-16T07:30:00Z", + ) + + // --- initial state ---------------------------------------------------- + + @Test + fun `initial state is Loading while the first refresh is in flight`() = + runTest { + // Gate the repository so Loading is observable. + val gate = CompletableDeferred>>() + coEvery { repository.refresh() } coAnswers { gate.await() } + val vm = viewModel() + + vm.uiState.test { + assertEquals(NewsUiState.Loading, awaitItem()) + gate.complete(Result.success(sampleArticles)) + val success = awaitItem() as NewsUiState.Success + assertEquals(3, success.allArticles.size) + assertEquals(3, success.filtered.size) + } + } + + @Test + fun `success builds the sources list deduped and sorted case-insensitive`() = + runTest { + coEvery { repository.refresh() } returns Result.success(sampleArticles) + val vm = viewModel() + + val state = vm.uiState.value as NewsUiState.Success + assertEquals(listOf("CryptoNews", "Polygon Post"), state.sources) + } + + // --- search filtering ------------------------------------------------ + + @Test + fun `onQueryChange filters by title substring case-insensitive`() = + runTest { + coEvery { repository.refresh() } returns Result.success(sampleArticles) + val vm = viewModel() + + vm.onQueryChange("polygon") + + val state = vm.uiState.value as NewsUiState.Success + assertEquals(listOf("b"), state.filtered.map { it.id }) + } + + @Test + fun `onQueryChange matches source name in addition to titles`() = + runTest { + coEvery { repository.refresh() } returns Result.success(sampleArticles) + val vm = viewModel() + + vm.onQueryChange("cryptonews") + + val state = vm.uiState.value as NewsUiState.Success + assertEquals(listOf("a", "c"), state.filtered.map { it.id }) + } + + @Test + fun `blank query restores the unfiltered list`() = + runTest { + coEvery { repository.refresh() } returns Result.success(sampleArticles) + val vm = viewModel() + + vm.onQueryChange("polygon") + vm.onQueryChange("") + + val state = vm.uiState.value as NewsUiState.Success + assertEquals(3, state.filtered.size) + } + + // --- source filtering ------------------------------------------------ + + @Test + fun `onSourceChange narrows the list to the picked source`() = + runTest { + coEvery { repository.refresh() } returns Result.success(sampleArticles) + val vm = viewModel() + + vm.onSourceChange(SourceFilter.Named("CryptoNews")) + + val state = vm.uiState.value as NewsUiState.Success + assertEquals(listOf("a", "c"), state.filtered.map { it.id }) + } + + @Test + fun `source filter and search compose so both must match`() = + runTest { + coEvery { repository.refresh() } returns Result.success(sampleArticles) + val vm = viewModel() + + vm.onSourceChange(SourceFilter.Named("CryptoNews")) + vm.onQueryChange("blackjack") + + val state = vm.uiState.value as NewsUiState.Success + assertEquals(listOf("c"), state.filtered.map { it.id }) + } + + @Test + fun `SourceFilter All restores the cross-source list`() = + runTest { + coEvery { repository.refresh() } returns Result.success(sampleArticles) + val vm = viewModel() + + vm.onSourceChange(SourceFilter.Named("Polygon Post")) + vm.onSourceChange(SourceFilter.All) + + val state = vm.uiState.value as NewsUiState.Success + assertEquals(3, state.filtered.size) + } + + // --- error + retry -------------------------------------------------- + + @Test + fun `refresh failure surfaces Error state with the underlying message`() = + runTest { + coEvery { repository.refresh() } returns Result.failure(RuntimeException("network down")) + + val vm = viewModel() + + val state = vm.uiState.value as NewsUiState.Error + assertTrue(state.message.contains("network down")) + } + + @Test + fun `refresh after an error retries the repository`() = + runTest { + coEvery { repository.refresh() } returnsMany + listOf(Result.failure(RuntimeException("first")), Result.success(sampleArticles)) + val vm = viewModel() + assertTrue(vm.uiState.value is NewsUiState.Error) + + vm.refresh() + + assertTrue(vm.uiState.value is NewsUiState.Success) + } +} diff --git a/app/src/test/java/com/plainstudio/stackcasino/navigation/RouteTest.kt b/app/src/test/java/com/plainstudio/stackcasino/navigation/RouteTest.kt index 322d3e6..adff20b 100644 --- a/app/src/test/java/com/plainstudio/stackcasino/navigation/RouteTest.kt +++ b/app/src/test/java/com/plainstudio/stackcasino/navigation/RouteTest.kt @@ -22,7 +22,16 @@ class RouteTest { @Test fun newsDetail_build_inserts_id_into_path_pattern() { + // Simple ids stay readable. assertEquals("news/article-7", Route.NewsDetail.build("article-7")) + // NewsAPI returns the article URL as the id; the build helper + // must URL-encode it so the path arg survives the Compose + // Navigation matcher (colons and slashes would otherwise + // collide with the route template). + assertEquals( + "news/https%3A%2F%2Fexample.com%2Fa%2Fb", + Route.NewsDetail.build("https://example.com/a/b"), + ) assertTrue( "Pattern must contain the argument placeholder.", Route.NewsDetail.path.contains("{${Route.NewsDetail.ARG_ARTICLE_ID}}"),