diff --git a/android/app/src/main/java/com/emergetools/HackerNewsApplication.kt b/android/app/src/main/java/com/emergetools/HackerNewsApplication.kt index c7025ee1..ac025071 100644 --- a/android/app/src/main/java/com/emergetools/HackerNewsApplication.kt +++ b/android/app/src/main/java/com/emergetools/HackerNewsApplication.kt @@ -2,19 +2,26 @@ package com.emergetools import android.app.Application import android.content.Context -import com.emergetools.hackernews.data.HackerNewsBaseClient +import com.emergetools.hackernews.data.HackerNewsBaseDataSource import com.emergetools.hackernews.data.HackerNewsSearchClient +import com.emergetools.hackernews.data.ItemRepository import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import java.time.Duration class HackerNewsApplication: Application() { private val json = Json { ignoreUnknownKeys = true } + private val httpClient = OkHttpClient.Builder() + .readTimeout(Duration.ofSeconds(30)) + .build() - val baseClient = HackerNewsBaseClient(json) - val searchClient = HackerNewsSearchClient(json) + private val baseClient = HackerNewsBaseDataSource(json, httpClient) + val searchClient = HackerNewsSearchClient(json, httpClient) + val itemRepository = ItemRepository(baseClient) } -fun Context.baseClient(): HackerNewsBaseClient { - return (this.applicationContext as HackerNewsApplication).baseClient +fun Context.itemRepository(): ItemRepository { + return (this.applicationContext as HackerNewsApplication).itemRepository } fun Context.searchClient(): HackerNewsSearchClient { diff --git a/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsBaseClient.kt b/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsBaseDataSource.kt similarity index 90% rename from android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsBaseClient.kt rename to android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsBaseDataSource.kt index 4707e6ed..717f1513 100644 --- a/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsBaseClient.kt +++ b/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsBaseDataSource.kt @@ -3,6 +3,7 @@ package com.emergetools.hackernews.data import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.kotlinx.serialization.asConverterFactory import retrofit2.http.GET @@ -34,10 +35,11 @@ interface HackerNewsBaseApi { suspend fun getItem(@Path("id") itemId: Long): Item } -class HackerNewsBaseClient(json: Json) { +class HackerNewsBaseDataSource(json: Json, client: OkHttpClient) { private val retrofit = Retrofit.Builder() .baseUrl(BASE_FIREBASE_URL) .addConverterFactory(json.asConverterFactory("application/json; charset=UTF8".toMediaType())) + .client(client) .build() val api = retrofit.create(HackerNewsBaseApi::class.java) diff --git a/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsSearchClient.kt b/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsSearchClient.kt index 2899b189..81b345b0 100644 --- a/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsSearchClient.kt +++ b/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsSearchClient.kt @@ -3,6 +3,7 @@ package com.emergetools.hackernews.data import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.kotlinx.serialization.asConverterFactory import retrofit2.http.GET @@ -26,10 +27,11 @@ interface HackerNewsAlgoliaApi { suspend fun getItem(@Path("id") itemId: Long): ItemResponse } -class HackerNewsSearchClient(json: Json) { +class HackerNewsSearchClient(json: Json, client: OkHttpClient) { private val retrofit = Retrofit.Builder() .baseUrl(BASE_SEARCH_URL) .addConverterFactory(json.asConverterFactory("application/json; charset=UTF8".toMediaType())) + .client(client) .build() val api: HackerNewsAlgoliaApi = retrofit.create(HackerNewsAlgoliaApi::class.java) diff --git a/android/app/src/main/java/com/emergetools/hackernews/data/ItemRepository.kt b/android/app/src/main/java/com/emergetools/hackernews/data/ItemRepository.kt new file mode 100644 index 00000000..fcd80bb3 --- /dev/null +++ b/android/app/src/main/java/com/emergetools/hackernews/data/ItemRepository.kt @@ -0,0 +1,44 @@ +package com.emergetools.hackernews.data + +import com.emergetools.hackernews.features.stories.FeedType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +typealias ItemId = Long +typealias Page = List + +class ItemRepository( + private val baseClient: HackerNewsBaseDataSource, +) { + suspend fun getFeedIds(type: FeedType): Page { + return withContext(Dispatchers.IO) { + when (type) { + FeedType.Top -> { + baseClient.api.getTopStoryIds() + } + FeedType.New -> { + baseClient.api.getNewStoryIds() + } + } + } + } + + suspend fun getItem(id: ItemId): Item { + return withContext(Dispatchers.IO) { + baseClient.api.getItem(id) + } + } + + suspend fun getPage(page: Page): List { + return withContext(Dispatchers.IO) { + val result = mutableListOf() + page.forEach { itemId -> + val item = baseClient.api.getItem(itemId) + result.add(item) + } + result.toList() + } + } +} + +fun MutableList.next() = removeFirst() diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesDomain.kt b/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesDomain.kt index 34355075..96f291bc 100644 --- a/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesDomain.kt +++ b/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesDomain.kt @@ -1,26 +1,29 @@ package com.emergetools.hackernews.features.stories +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import com.emergetools.hackernews.data.HackerNewsBaseClient +import com.emergetools.hackernews.data.Item +import com.emergetools.hackernews.data.ItemRepository +import com.emergetools.hackernews.data.Page +import com.emergetools.hackernews.data.next import com.emergetools.hackernews.features.comments.CommentsDestinations -import com.emergetools.hackernews.features.stories.StoriesAction.LoadStories -import kotlinx.coroutines.Dispatchers +import com.emergetools.hackernews.features.stories.StoriesAction.LoadItems import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext enum class FeedType(val label: String) { Top("Top"), New("New") } + data class StoriesState( val stories: List, - val feed: FeedType = FeedType.Top - + val feed: FeedType = FeedType.Top, + val loading: Boolean = true ) sealed class StoryItem(open val id: Long) { @@ -36,67 +39,69 @@ sealed class StoryItem(open val id: Long) { } sealed class StoriesAction { - data object LoadStories : StoriesAction() + data object LoadItems : StoriesAction() + data object LoadNextPage : StoriesAction() data class SelectStory(val id: Long) : StoriesAction() data class SelectComments(val id: Long) : StoriesAction() data class SelectFeed(val feed: FeedType) : StoriesAction() } -// TODO(rikin): Second pass at Navigation Setup sealed interface StoriesNavigation { data class GoToStory(val closeup: StoriesDestinations.Closeup) : StoriesNavigation data class GoToComments(val comments: CommentsDestinations.Comments) : StoriesNavigation } -class StoriesViewModel(private val baseClient: HackerNewsBaseClient) : ViewModel() { +class StoriesViewModel(private val itemRepository: ItemRepository) : ViewModel() { private val internalState = MutableStateFlow(StoriesState(stories = emptyList())) val state = internalState.asStateFlow() + // TODO: decide if this should be in the ViewModel or the Repository + private val pages = mutableListOf() + init { - actions(LoadStories) + actions(LoadItems) } fun actions(action: StoriesAction) { when (action) { - LoadStories -> { + LoadItems -> { viewModelScope.launch { - withContext(Dispatchers.IO) { - val ids = when(internalState.value.feed) { - FeedType.Top -> { - baseClient.api.getTopStoryIds() - } - FeedType.New -> { - baseClient.api.getNewStoryIds() - } - } + pages.addAll( + itemRepository + .getFeedIds(internalState.value.feed) + .chunked(FEED_PAGE_SIZE) + ) + val page = pages.next() + Log.d("Feed", "Loading first page: $page") + internalState.update { current -> + current.copy( + stories = page.map { StoryItem.Loading(it) }, + loading = true + ) + } - // now for each ID I need to load the item. - internalState.update { current -> - current.copy( - stories = ids.map { StoryItem.Loading(it) } + var newStories = itemRepository + .getPage(page) + .map { item -> + StoryItem.Content( + id = item.id, + title = item.title!!, + author = item.by!!, + score = item.score ?: 0, + commentCount = item.descendants ?: 0, + url = item.url ) } - ids.forEach { id -> - val item = baseClient.api.getItem(id) - internalState.update { current -> - current.copy( - stories = current.stories.map { - if (it.id == item.id) { - StoryItem.Content( - id = item.id, - title = item.title!!, - author = item.by!!, - score = item.score ?: 0, - commentCount = item.descendants ?: 0, - url = item.url - ) - } else { - it - } - } - ) - } - } + + if (pages.isNotEmpty()) { + newStories = newStories + StoryItem.Loading(0L) + } + + internalState.update { current -> + current.copy( + stories = newStories, + loading = false + ) } } } @@ -116,15 +121,54 @@ class StoriesViewModel(private val baseClient: HackerNewsBaseClient) : ViewModel stories = emptyList() ) } - actions(LoadStories) + actions(LoadItems) + } + + StoriesAction.LoadNextPage -> { + if (pages.isNotEmpty() && !state.value.loading) { + viewModelScope.launch { + val page = pages.next() + Log.d("Feed", "Loading next page: $page") + internalState.update { current -> + current.copy(loading = true) + } + + var storiesToAdd = itemRepository + .getPage(page) + .map { item -> + StoryItem.Content( + id = item.id, + title = item.title!!, + author = item.by!!, + score = item.score ?: 0, + commentCount = item.descendants ?: 0, + url = item.url + ) + } + + if (pages.isNotEmpty()) { + storiesToAdd = storiesToAdd + StoryItem.Loading(0L) + } + + internalState.update { current -> + val newStories = current.stories.subList(0, current.stories.lastIndex) + storiesToAdd + current.copy( + stories = newStories, + loading = false + ) + } + } + } } } } @Suppress("UNCHECKED_CAST") - class Factory(private val baseClient: HackerNewsBaseClient) : ViewModelProvider.Factory { + class Factory(private val itemRepository: ItemRepository) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return StoriesViewModel(baseClient) as T + return StoriesViewModel(itemRepository) as T } } } + +const val FEED_PAGE_SIZE = 20 diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesRouting.kt b/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesRouting.kt index cac5b71d..57fa9df9 100644 --- a/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesRouting.kt +++ b/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesRouting.kt @@ -12,9 +12,9 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.navigation import androidx.navigation.toRoute -import com.emergetools.baseClient import com.emergetools.hackernews.features.stories.StoriesDestinations.Closeup import com.emergetools.hackernews.features.stories.StoriesDestinations.Feed +import com.emergetools.itemRepository import kotlinx.serialization.Serializable @Serializable @@ -38,7 +38,7 @@ fun NavGraphBuilder.storiesGraph(navController: NavController) { val model = viewModel( factory = StoriesViewModel.Factory( - baseClient = context.baseClient() + itemRepository = context.itemRepository() ) ) val state by model.state.collectAsState() @@ -51,8 +51,8 @@ fun NavGraphBuilder.storiesGraph(navController: NavController) { is StoriesNavigation.GoToComments -> { navController.navigate(place.comments) } + is StoriesNavigation.GoToStory -> { -// navController.navigate(place.closeup) customTabsIntent.launchUrl(context, Uri.parse(place.closeup.url)) } } @@ -60,7 +60,7 @@ fun NavGraphBuilder.storiesGraph(navController: NavController) { ) } composable { entry -> - val closeup: Closeup = entry.toRoute() + val closeup: Closeup = entry.toRoute() StoryScreen(closeup.url) } } diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesScreen.kt b/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesScreen.kt index 53e341fb..1817bd7d 100644 --- a/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesScreen.kt +++ b/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesScreen.kt @@ -16,14 +16,19 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TabRow import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -51,6 +56,24 @@ fun StoriesScreen( actions: (StoriesAction) -> Unit, navigation: (StoriesNavigation) -> Unit ) { + fun LazyListState.atEndOfList(): Boolean { + return layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1 + } + + val listState = rememberLazyListState() + + val shouldLoadMore by remember { + derivedStateOf { + listState.atEndOfList() + } + } + + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) { + actions(StoriesAction.LoadNextPage) + } + } + Column( modifier = modifier.background(color = MaterialTheme.colorScheme.background), horizontalAlignment = Alignment.CenterHorizontally, @@ -61,6 +84,7 @@ fun StoriesScreen( onSelected = { actions(StoriesAction.SelectFeed(it)) } ) LazyColumn( + state = listState, modifier = Modifier .fillMaxWidth() .weight(1f) @@ -90,7 +114,7 @@ fun StoriesScreen( comments = CommentsDestinations.Comments(it.id) ) ) - } + }, ) } } @@ -121,15 +145,15 @@ private fun FeedSelection( containerColor = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onBackground, indicator = { tabPositions -> - if (selectedTab < tabPositions.size) { + if (selectedTab < tabPositions.size) { Box( modifier = Modifier .tabIndicatorOffset(tabPositions[selectedTab]) .height(2.dp) .drawBehind { val barWidth = size.width * 0.33f - val start = size.center.x - barWidth/2f - val end = size.center.x + barWidth/2f + val start = size.center.x - barWidth / 2f + val end = size.center.x + barWidth / 2f val bottom = size.height - 16f drawLine( start = Offset(start, bottom), @@ -151,8 +175,7 @@ private fun FeedSelection( .padding(8.dp) .clickable { onSelected(feedType) - } - , + }, textAlign = TextAlign.Center, text = feedType.label, style = MaterialTheme.typography.labelSmall, @@ -209,7 +232,7 @@ private fun StoryRowPreview() { url = "" ), onClick = {}, - onCommentClicked = {} + onCommentClicked = {}, ) } } @@ -221,7 +244,7 @@ private fun StoryRowLoadingPreview() { StoryRow( item = StoryItem.Loading(id = 1L), onClick = {}, - onCommentClicked = {} + onCommentClicked = {}, ) } } @@ -231,7 +254,7 @@ fun StoryRow( modifier: Modifier = Modifier, item: StoryItem, onClick: (StoryItem.Content) -> Unit, - onCommentClicked: (StoryItem.Content) -> Unit + onCommentClicked: (StoryItem.Content) -> Unit, ) { when (item) { is StoryItem.Content -> { @@ -245,7 +268,10 @@ fun StoryRow( } .padding(8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.CenterHorizontally) + horizontalArrangement = Arrangement.spacedBy( + 16.dp, + alignment = Alignment.CenterHorizontally + ) ) { Column( modifier = Modifier @@ -261,7 +287,7 @@ fun StoryRow( Text(text = "${item.score}", style = MaterialTheme.typography.labelSmall) Text(text = "•", style = MaterialTheme.typography.labelSmall) Text( - text = item.author , + text = item.author, color = HNOrange, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Medium @@ -292,6 +318,7 @@ fun StoryRow( } } } + is StoryItem.Loading -> { Row( modifier = modifier @@ -300,7 +327,10 @@ fun StoryRow( .background(color = MaterialTheme.colorScheme.background) .padding(8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.CenterHorizontally) + horizontalArrangement = Arrangement.spacedBy( + 16.dp, + alignment = Alignment.CenterHorizontally + ) ) { Column( modifier = Modifier @@ -311,15 +341,15 @@ fun StoryRow( Box( modifier = Modifier .fillMaxWidth(0.8f) - .height(20.dp) - .clip(CircleShape) + .height(18.dp) + .clip(RoundedCornerShape(4.dp)) .background(color = Color.LightGray) ) Box( modifier = Modifier .fillMaxWidth(0.45f) - .height(20.dp) - .clip(CircleShape) + .height(18.dp) + .clip(RoundedCornerShape(4.dp)) .background(color = Color.Gray) ) Row( @@ -330,14 +360,14 @@ fun StoryRow( modifier = Modifier .width(30.dp) .height(14.dp) - .clip(CircleShape) + .clip(RoundedCornerShape(4.dp)) .background(Color.DarkGray) ) Box( modifier = Modifier .width(40.dp) .height(14.dp) - .clip(CircleShape) + .clip(RoundedCornerShape(4.dp)) .background(HNOrange) ) }