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
2 changes: 1 addition & 1 deletion _contract/MOBILE_API_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ All FKs respect existing `utenti`/`libri` schema. Follow the soft-delete rule on
- `POST /auth/logout` — revoke current token.
- `GET /me` — profile. `PATCH /me` — edit profile. `POST /me/password` — change password.
- `GET /me/devices` — list devices. `DELETE /me/devices/{id}` — revoke a device.
- `GET /catalog/search` — filters: `q`, `author`, `publisher`, `genre` (cascade id), `language`, `available` (bool); cursor pagination.
- `GET /catalog/search` — filters: `q`, `author`, `publisher`, `genre` (cascade id), `language`, `available` (bool); `sort`: `newest` (default), `oldest`, `title_asc`, `title_desc`; cursor pagination.
- `GET /catalog/books/{id}` — full detail + personal history.
- `GET /catalog/books/{id}/availability` — per-day availability calendar for the loan/reservation date picker.
- `GET /catalog/books/{id}/reviews` — aggregate rating (`average`, `count`, `distribution`) + the user's own review (`mine`) + `can_review` (has the user borrowed the title) + cursor-paginated `items` (other users' reviews). Same feature as the web review view.
Expand Down
15 changes: 15 additions & 0 deletions _contract/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1758,6 +1758,21 @@
},
"description": "If true, return only books with at least one loanable copy"
},
{
"name": "sort",
"in": "query",
"schema": {
"type": "string",
"enum": [
"newest",
"oldest",
"title_asc",
"title_desc"
],
"default": "newest"
},
"description": "Sort order: newest, oldest, title_asc, or title_desc"
},
{
"name": "cursor",
"in": "query",
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ interface PinakesApi {
@Query("available") available: Boolean? = null,
@Query("cursor") cursor: String? = null,
@Query("limit") limit: Int? = null, // 1..50, default 20
// newest (default) | oldest | title_asc | title_desc
@Query("sort") sort: String? = null,
): Envelope<List<BookSummary>>

@GET("catalog/books/{id}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ class CatalogRepository(
filters: SearchFilters,
cursor: String? = null,
limit: Int = 20,
// newest (default) | oldest | title_asc | title_desc; null → server default (newest).
sort: String? = null,
): ApiResult<SearchPage> {
val api = network.api()
return when (
Expand All @@ -98,6 +100,7 @@ class CatalogRepository(
available = filters.availableOnly,
cursor = cursor,
limit = limit.coerceIn(1, 50),
sort = sort?.takeIf { it.isNotBlank() },
)
}
) {
Expand Down
14 changes: 9 additions & 5 deletions app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ fun HomeScreen(
HomePhase.Loading -> Column(Modifier.fillMaxSize()) {
HomeHeader(libraryName = state.libraryName, catalogueMode = catalogueMode)
Column(Modifier.padding(horizontal = Spacing.lg)) {
SectionHeader(showSeeAll = false, onSeeAll = {})
SectionHeader(showSeeAll = false, catalogueMode = catalogueMode, onSeeAll = {})
Spacer(Modifier.height(Spacing.md))
repeat(4) {
BookCardSkeleton()
Expand Down Expand Up @@ -103,7 +103,7 @@ fun HomeScreen(
item { HomeHeader(libraryName = state.libraryName, catalogueMode = catalogueMode) }
item {
Box(Modifier.padding(horizontal = Spacing.lg)) {
SectionHeader(showSeeAll = true, onSeeAll = onBrowseCatalog)
SectionHeader(showSeeAll = true, catalogueMode = catalogueMode, onSeeAll = onBrowseCatalog)
}
}
items(state.available, key = { it.id }) { book ->
Expand Down Expand Up @@ -178,20 +178,24 @@ private fun HomeHeader(libraryName: String?, catalogueMode: Boolean) {
}

@Composable
private fun SectionHeader(showSeeAll: Boolean, onSeeAll: () -> Unit) {
private fun SectionHeader(showSeeAll: Boolean, catalogueMode: Boolean, onSeeAll: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
text = stringResource(R.string.home_section_available),
// In CATALOGUE-ONLY MODE the shelf isn't about borrowing — it's the newest
// titles added to the library, so label it "Recently added" to match.
text = if (catalogueMode) stringResource(R.string.home_section_recent)
else stringResource(R.string.home_section_available),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = stringResource(R.string.home_section_available_subtitle),
text = if (catalogueMode) stringResource(R.string.home_section_recent_subtitle)
else stringResource(R.string.home_section_available_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Expand Down
60 changes: 51 additions & 9 deletions app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,19 @@ import com.pinakes.app.data.model.BookSummary
import com.pinakes.app.data.network.ApiResult
import com.pinakes.app.data.repository.CatalogRepository
import com.pinakes.app.data.repository.SearchFilters
import com.pinakes.app.data.store.FeatureStore
import com.pinakes.app.data.store.SessionStore
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

Expand All @@ -31,6 +36,7 @@ data class HomeUiState(
class HomeViewModel @Inject constructor(
private val catalog: CatalogRepository,
private val session: SessionStore,
private val features: FeatureStore,
) : ViewModel() {

private val _state = MutableStateFlow(
Expand All @@ -47,23 +53,52 @@ class HomeViewModel @Inject constructor(

init {
observeCache()
refresh()
observeCatalogueMode()
}

/**
* Offline fallback: render the "Available now" shelf from the locally-cached catalog
* snapshot immediately (works with no network), filtering to currently-loanable
* copies. Only a partial answer — the cache holds the first unfiltered page — so it is
* superseded as soon as [refresh] gets the server-side availability query through.
* `catalogueMode` alone, de-duplicated, so downstream only reacts when the lending↔catalogue
* flag actually flips — not on every unrelated `/health` emission that leaves it unchanged.
*/
private fun catalogueMode(): Flow<Boolean> =
features.features.map { it.catalogueMode }.distinctUntilChanged()

/**
* Drive [refresh] off [catalogueMode]: once on the initial (persisted) value, then again each
* time the flag flips. This is what makes the shelf reactive — on a cold start `/health` can
* resolve AFTER the first refresh, and a mode switch can land at any time; either way the
* server-side shelf query (availableOnly vs unfiltered) is re-driven to match. [collectLatest]
* abandons a stale re-drive when the flag flips again mid-flight.
*/
private fun observeCatalogueMode() {
viewModelScope.launch {
catalogueMode().collectLatest { refresh() }
}
}

/**
* Offline fallback: render the home shelf from the locally-cached catalog snapshot
* immediately. Loan mode keeps the "Available now" shelf filtered to currently-loanable
* copies; catalogue-only mode labels the shelf "Recently added", so the cache fallback
* must stay unfiltered. Only a partial answer — the cache holds the first unfiltered page —
* so it is superseded as soon as [refresh] gets the server-side query through.
*/
private fun observeCache() {
viewModelScope.launch {
catalog.observeCachedCatalog().collectLatest { books ->
// Re-run the filter whenever EITHER the cache OR catalogueMode changes, so a mode flip
// that lands before the first server-side shelf resolves still re-labels/re-filters the
// offline fallback instead of leaving it wrong until [refresh] gets through.
combine(
catalog.observeCachedCatalog(),
catalogueMode(),
) { books, catalogueMode -> books to catalogueMode }.collectLatest { (books, catalogueMode) ->
if (hasFreshShelf) return@collectLatest
val available = books.filter { it.available }
val shelfBooks =
if (catalogueMode) books
else books.filter { it.available }
_state.update {
it.copy(
available = available,
available = shelfBooks,
// Keep the loading skeleton until the first [refresh]
// resolves when the cache is still empty (cold start):
// an empty first emission must not flash the
Expand All @@ -86,7 +121,14 @@ class HomeViewModel @Inject constructor(
*/
fun refresh() {
viewModelScope.launch {
val shelf = async { catalog.search(SearchFilters(availableOnly = true), limit = SHELF_LIMIT) }
// Catalogue-only mode labels the shelf "Recently added / latest additions", so
// query the newest titles unfiltered (no availability filter) to make the label
// honest. Loan mode keeps the availability filter behind the "Available now" shelf.
// Both lean on the server's default newest-first sort.
val shelfFilters =
if (features.features.value.catalogueMode) SearchFilters()
else SearchFilters(availableOnly = true)
val shelf = async { catalog.search(shelfFilters, limit = SHELF_LIMIT) }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
launch { catalog.refreshCatalog() }
when (val res = shelf.await()) {
is ApiResult.Success -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,18 @@ fun SearchFilterSheet(

Spacer(Modifier.height(Spacing.lg))

// --- Genre (top-level catalog genres from /catalog/genres) ---
// --- Genre (cascade from /catalog/genres) ---
// The backend `genre` filter matches at ANY level, so whatever node the user
// selects — a top category or a sub-category several levels deep — its id is
// sent as `genre`. Selecting a top category still means "everything under it".
if (state.genres.isNotEmpty()) {
SectionLabel(stringResource(R.string.filters_section_genre))
// Root→selected node path; drives which sub-category rows are revealed and
// which chip is highlighted at each level (a breadcrumb of the chosen branch).
val path: List<GenreNode> =
state.selectedGenreId?.let { genrePath(state.genres, it) } ?: emptyList()

// Level 0: "All" + top categories.
FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) {
FilterChip(
selected = state.selectedGenreId == null,
Expand All @@ -119,13 +128,34 @@ fun SearchFilterSheet(
)
state.genres.forEach { g: GenreNode ->
FilterChip(
selected = state.selectedGenreId == g.id,
selected = path.getOrNull(0)?.id == g.id,
onClick = { onGenreChange(g.id) },
label = { Text(g.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
colors = chipColors,
)
}
}

// Deeper levels: for each selected node that has children, drill into its
// sub-categories. Tapping a sub-category narrows the filter to that id.
path.forEachIndexed { level, node ->
if (node.children.isNotEmpty()) {
Spacer(Modifier.height(Spacing.md))
// Label the revealed child row with its parent's name so the user can
// tell which level of the drill-down each set of chips belongs to.
SectionLabel(stringResource(R.string.filters_section_within, node.name))
FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) {
node.children.forEach { child: GenreNode ->
FilterChip(
selected = path.getOrNull(level + 1)?.id == child.id,
onClick = { onGenreChange(child.id) },
label = { Text(child.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
colors = chipColors,
)
}
}
}
}
Spacer(Modifier.height(Spacing.lg))
}

Expand Down Expand Up @@ -194,6 +224,30 @@ fun SearchFilterSheet(
}
}

/** Guard against a pathological or cyclic genre tree overflowing the stack. */
private const val MAX_GENRE_DEPTH = 8

/**
* Depth-first search for [id] in the genre tree, returning the root→node path (inclusive)
* or an empty list if not found. Used to reveal the selected branch's sub-category rows.
*
* Bounded by [MAX_GENRE_DEPTH] and a visited-id set so a cyclic or unexpectedly deep tree
* (e.g. from a malformed server payload) can't cause unbounded recursion / a StackOverflow.
*/
private fun genrePath(nodes: List<GenreNode>, id: Int): List<GenreNode> {
fun walk(level: List<GenreNode>, depth: Int, visited: MutableSet<Int>): List<GenreNode> {
if (depth > MAX_GENRE_DEPTH) return emptyList()
for (node in level) {
if (!visited.add(node.id)) continue // repeat id (cycle) → don't descend again
if (node.id == id) return listOf(node)
val sub = walk(node.children, depth + 1, visited)
if (sub.isNotEmpty()) return listOf(node) + sub
}
return emptyList()
}
return walk(nodes, 0, mutableSetOf())
}

@Composable
private fun SectionLabel(text: String) {
Text(
Expand Down
Loading
Loading