Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feed Loading Strategy #62

Merged
merged 3 commits into from
Jul 10, 2024
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
17 changes: 12 additions & 5 deletions android/app/src/main/java/com/emergetools/HackerNewsApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ItemId>

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<Item> {
return withContext(Dispatchers.IO) {
val result = mutableListOf<Item>()
page.forEach { itemId ->
val item = baseClient.api.getItem(itemId)
result.add(item)
}
result.toList()
}
}
}

fun MutableList<Page>.next() = removeFirst()
Original file line number Diff line number Diff line change
@@ -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<StoryItem>,
val feed: FeedType = FeedType.Top

val feed: FeedType = FeedType.Top,
val loading: Boolean = true
)

sealed class StoryItem(open val id: Long) {
Expand All @@ -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<Page>()

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> { 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
)
}
}
}
Expand All @@ -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> { 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 <T : ViewModel> create(modelClass: Class<T>): T {
return StoriesViewModel(baseClient) as T
return StoriesViewModel(itemRepository) as T
}
}
}

const val FEED_PAGE_SIZE = 20
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -38,7 +38,7 @@ fun NavGraphBuilder.storiesGraph(navController: NavController) {

val model = viewModel<StoriesViewModel>(
factory = StoriesViewModel.Factory(
baseClient = context.baseClient()
itemRepository = context.itemRepository()
)
)
val state by model.state.collectAsState()
Expand All @@ -51,16 +51,16 @@ 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))
}
}
}
)
}
composable<Closeup> { entry ->
val closeup: Closeup = entry.toRoute()
val closeup: Closeup = entry.toRoute()
StoryScreen(closeup.url)
}
}
Expand Down
Loading
Loading