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
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,16 @@ class AuthInterceptor(private val session: SessionStore) : Interceptor {
} else {
original
}
return chain.proceed(request)
val response = chain.proceed(request)
// App-wide session expiry: a 401 on an authenticated request means the bearer
// token is no longer valid. Clear it so SessionStore.authState flips and the nav
// host routes every screen (Book Club included) back to login, instead of leaving
// the user on a permanent retryable error. Public endpoints returned early above,
// so this only fires for genuinely-authenticated calls.
if (response.code == 401 && !token.isNullOrBlank()) {
session.clearToken()
}
return response
}

companion object {
Expand Down
16 changes: 12 additions & 4 deletions app/src/main/java/com/pinakes/app/ui/common/DateFormat.kt
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,19 @@ object DateFormat {
}

/**
* True when [raw] parses to a moment strictly in the past (device clock). False for
* null/unparseable input — callers use this to gate optimistic UI, so unknown ≠ expired.
* True when [raw] parses to a moment strictly in the past. False for null/unparseable
* input — callers use this to gate optimistic UI, so unknown ≠ expired.
*
* TZ contract: an ISO-8601 instant (the bridge sends deadlines like `closes_at` as UTC
* `…Z` via its `isoUtc()` helper) is compared as an absolute [Instant], so the verdict is
* timezone-independent and correct regardless of the device zone. Only a bare wall-clock
* DATETIME (no zone) falls back to a best-effort device-local comparison.
*/
fun isPast(raw: String?): Boolean =
parseServerDateTime(raw)?.isBefore(LocalDateTime.now()) == true
fun isPast(raw: String?): Boolean {
if (raw.isNullOrBlank()) return false
parseInstant(raw)?.let { return it.isBefore(Instant.now()) }
return parseServerDateTime(raw)?.isBefore(LocalDateTime.now()) == true
}

private fun parseInstant(iso: String): Instant? = runCatching { Instant.parse(iso) }.getOrNull()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ fun BookClubHomeScreen(
val snackbarHost = remember { SnackbarHostState() }

val snackbarMessage = state.snackbarRes?.let { stringResource(it) }
LaunchedEffect(snackbarMessage) {
// Key on the nonce, not the message: two identical consecutive messages must both show.
LaunchedEffect(state.snackbarNonce) {
snackbarMessage?.let { snackbarHost.showSnackbar(it); vm.consumeSnackbar() }
}

Expand Down Expand Up @@ -94,6 +95,11 @@ fun BookClubHomeScreen(
title = stringResource(R.string.book_club_empty_title),
subtitle = stringResource(R.string.book_club_empty_subtitle),
icon = Icons.Outlined.Groups,
// Explicit refresh: the empty state fills the viewport and isn't
// scrollable, so the PullToRefreshBox gesture never fires on it —
// a user who joins a club on the web needs a button to reload.
actionLabel = stringResource(R.string.book_club_refresh),
onAction = vm::refresh,
)
} else {
LazyColumn(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ data class BookClubHomeUiState(
val refreshing: Boolean = false,
/** Partial-failure notice (e.g. the dashboard fetch failed while clubs loaded). */
val snackbarRes: Int? = null,
/** Bumped on every snackbar post so two identical consecutive messages still both show. */
val snackbarNonce: Int = 0,
/** The plugin was deactivated server-side (confirmed via health re-probe). */
val pluginGone: Boolean = false,
)
Expand Down Expand Up @@ -71,6 +73,7 @@ class BookClubHomeViewModel @Inject constructor(
content = UiState.Success(BookClubHome(dashboard = dashboard, clubs = clubsRes.data)),
refreshing = false,
snackbarRes = if (dashboardFailed) R.string.book_club_error_dashboard else it.snackbarRes,
snackbarNonce = if (dashboardFailed) it.snackbarNonce + 1 else it.snackbarNonce,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.pinakes.app.ui.screens.bookclub
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.compose.ui.graphics.Color
import com.pinakes.app.R
Expand Down Expand Up @@ -37,10 +38,16 @@ fun privacyLabelRes(privacy: String): Int = when (privacy) {

/** Open a web URL (deep-links the flows the Book Club API marks as web-only). */
fun openWeb(context: Context, url: String) {
if (url.isBlank()) return
if (url.isBlank()) {
Toast.makeText(context, context.getString(R.string.book_club_no_browser), Toast.LENGTH_SHORT).show()
return
}
runCatching {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}.onFailure {
// No browser / no handling activity — tell the user instead of silently no-op-ing.
Toast.makeText(context, context.getString(R.string.book_club_no_browser), Toast.LENGTH_SHORT).show()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ fun ClubDetailScreen(onNavigateUp: () -> Unit) {
val snackbarHost = remember { SnackbarHostState() }

val snackbarMessage = state.snackbar ?: state.snackbarRes?.let { stringResource(it) }
LaunchedEffect(snackbarMessage) {
// Key on the nonce, not the message: two identical consecutive messages must both show.
LaunchedEffect(state.snackbarNonce) {
snackbarMessage?.let { snackbarHost.showSnackbar(it); vm.consumeSnackbar() }
}

Expand Down Expand Up @@ -116,12 +117,19 @@ fun ClubDetailScreen(onNavigateUp: () -> Unit) {
}

progressBook?.let { book ->
// Keep the dialog open (showing its `saving` loading state) until the save round-trip
// finishes — the VM clears progressBookId back to null when the network call returns
// (success shows a snackbar, failure the error), and only then do we dismiss.
var saveRequested by remember(book.id) { mutableStateOf(false) }
LaunchedEffect(state.progressBookId, saveRequested) {
if (saveRequested && state.progressBookId == null) progressBook = null
}
ProgressDialog(
book = book,
saving = state.progressBookId == book.id,
onSave = { percent, finished ->
saveRequested = true
vm.updateProgress(book.id, percent, finished)
progressBook = null
},
onDismiss = { progressBook = null },
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ data class ClubDetailUiState(
val progressBookId: Int? = null,
val snackbar: String? = null,
val snackbarRes: Int? = null,
/** Bumped on every snackbar post so two identical consecutive messages still both show. */
val snackbarNonce: Int = 0,
/** The plugin was deactivated server-side (confirmed via health re-probe). */
val pluginGone: Boolean = false,
)
Expand Down Expand Up @@ -84,28 +86,34 @@ class ClubDetailViewModel @Inject constructor(
is ApiResult.Success -> {
val res2 = if (res.data.status == "pending") R.string.book_club_join_pending
else R.string.book_club_join_active
_state.update { it.copy(joining = false, snackbarRes = res2) }
_state.update { it.copy(joining = false).snackRes(res2) }
load(initial = false)
}
is ApiResult.Failure -> _state.update { it.copy(joining = false).withError(res) }
is ApiResult.Failure -> {
_state.update { it.copy(joining = false).withError(res) }
reloadIfPluginGone(res)
}
}
}
}

fun vote(pollId: Int, optionIds: List<Int>) {
if (_state.value.votingPollId != null) return
if (optionIds.isEmpty()) {
_state.update { it.copy(snackbarRes = R.string.book_club_vote_empty) }
_state.update { it.snackRes(R.string.book_club_vote_empty) }
return
}
_state.update { it.copy(votingPollId = pollId) }
viewModelScope.launch {
when (val res = repo.vote(slug, pollId, optionIds)) {
is ApiResult.Success -> {
_state.update { it.copy(votingPollId = null, snackbarRes = R.string.book_club_vote_saved) }
_state.update { it.copy(votingPollId = null).snackRes(R.string.book_club_vote_saved) }
load(initial = false)
}
is ApiResult.Failure -> _state.update { it.copy(votingPollId = null).withError(res) }
is ApiResult.Failure -> {
_state.update { it.copy(votingPollId = null).withError(res) }
reloadIfPluginGone(res)
}
}
}
}
Expand All @@ -116,10 +124,13 @@ class ClubDetailViewModel @Inject constructor(
viewModelScope.launch {
when (val res = repo.rsvp(slug, meetingId, response)) {
is ApiResult.Success -> {
_state.update { it.copy(rsvpMeetingId = null, snackbarRes = R.string.book_club_rsvp_saved) }
_state.update { it.copy(rsvpMeetingId = null).snackRes(R.string.book_club_rsvp_saved) }
load(initial = false)
}
is ApiResult.Failure -> _state.update { it.copy(rsvpMeetingId = null).withError(res) }
is ApiResult.Failure -> {
_state.update { it.copy(rsvpMeetingId = null).withError(res) }
reloadIfPluginGone(res)
}
}
}
}
Expand All @@ -130,18 +141,37 @@ class ClubDetailViewModel @Inject constructor(
viewModelScope.launch {
when (val res = repo.progress(slug, clubBookId, percent, finished)) {
is ApiResult.Success -> {
_state.update { it.copy(progressBookId = null, snackbarRes = R.string.book_club_progress_saved) }
_state.update { it.copy(progressBookId = null).snackRes(R.string.book_club_progress_saved) }
load(initial = false)
}
is ApiResult.Failure -> _state.update { it.copy(progressBookId = null).withError(res) }
is ApiResult.Failure -> {
_state.update { it.copy(progressBookId = null).withError(res) }
reloadIfPluginGone(res)
}
}
}
}

/**
* When an action (join/vote/rsvp/progress) fails with 404 and the plugin is confirmed
* gone, reload so the load() path flips the screen to the friendly "gone" EmptyState —
* keeping mid-session deactivation degradation consistent with the initial-load path.
*/
private suspend fun reloadIfPluginGone(res: ApiResult.Failure) {
if ((res.httpStatus == 404 || res.code == ErrorCodes.NOT_FOUND) && repo.confirmGone()) {
load(initial = false)
}
}

fun consumeSnackbar() = _state.update { it.copy(snackbar = null, snackbarRes = null) }

/** Post a string-resource snackbar; the nonce bump makes identical consecutive messages re-fire. */
private fun ClubDetailUiState.snackRes(res: Int): ClubDetailUiState =
copy(snackbar = null, snackbarRes = res, snackbarNonce = snackbarNonce + 1)

/** Prefer the server's (localized) message; fall back to a generic string when it's blank. */
private fun ClubDetailUiState.withError(failure: ApiResult.Failure): ClubDetailUiState =
if (failure.message.isNotBlank()) copy(snackbar = failure.message, snackbarRes = null)
else copy(snackbar = null, snackbarRes = R.string.book_club_action_error)
(if (failure.message.isNotBlank()) copy(snackbar = failure.message, snackbarRes = null)
else copy(snackbar = null, snackbarRes = R.string.book_club_action_error))
.copy(snackbarNonce = snackbarNonce + 1)
}
2 changes: 2 additions & 0 deletions i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,8 @@
"book_club_gone_title": "Book Club nicht verfügbar",
"book_club_gone_subtitle": "Der Book Club ist in dieser Bibliothek nicht mehr aktiv.",
"book_club_empty_title": "Noch keine Lesekreise",
"book_club_refresh": "Aktualisieren",
"book_club_no_browser": "Keine App zum Öffnen dieses Links verfügbar.",
"book_club_empty_subtitle": "Sobald deine Bibliothek einen Lesekreis eröffnet, findest du ihn hier.",
"book_club_section_reading": "Deine Lektüre",
"book_club_section_my_clubs": "Deine Clubs",
Expand Down
2 changes: 2 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,8 @@
"book_club_gone_title": "Book Club unavailable",
"book_club_gone_subtitle": "The Book Club is no longer active on this library.",
"book_club_empty_title": "No book clubs yet",
"book_club_refresh": "Refresh",
"book_club_no_browser": "No app available to open this link.",
"book_club_empty_subtitle": "When your library opens a reading group, you'll find it here.",
"book_club_section_reading": "Your reading",
"book_club_section_my_clubs": "Your clubs",
Expand Down
2 changes: 2 additions & 0 deletions i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,8 @@
"book_club_gone_title": "Book Club indisponible",
"book_club_gone_subtitle": "Le Book Club n'est plus actif sur cette bibliothèque.",
"book_club_empty_title": "Aucun club de lecture",
"book_club_refresh": "Actualiser",
"book_club_no_browser": "Aucune application disponible pour ouvrir ce lien.",
"book_club_empty_subtitle": "Lorsque votre bibliothèque ouvrira un club de lecture, il apparaîtra ici.",
"book_club_section_reading": "Vos lectures",
"book_club_section_my_clubs": "Vos clubs",
Expand Down
2 changes: 2 additions & 0 deletions i18n/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,8 @@
"book_club_gone_title": "Book Club non disponibile",
"book_club_gone_subtitle": "Il Book Club non è più attivo su questa biblioteca.",
"book_club_empty_title": "Ancora nessun gruppo di lettura",
"book_club_refresh": "Aggiorna",
"book_club_no_browser": "Nessuna app disponibile per aprire questo link.",
"book_club_empty_subtitle": "Quando la biblioteca aprirà un gruppo di lettura, lo troverai qui.",
"book_club_section_reading": "Le tue letture",
"book_club_section_my_clubs": "I tuoi club",
Expand Down
Loading