Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<NewsApiArticleDto> = 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?,
)
Original file line number Diff line number Diff line change
@@ -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]"
Original file line number Diff line number Diff line change
@@ -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<String, NewsArticle>()

override suspend fun refresh(): Result<List<NewsArticle>> {
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)"
93 changes: 93 additions & 0 deletions app/src/main/java/com/plainstudio/stackcasino/di/NewsModule.kt
Original file line number Diff line number Diff line change
@@ -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/"
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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<List<NewsArticle>>

/**
* 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?
}
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading