diff --git a/app/src/main/java/com/pinakes/app/data/network/AuthInterceptor.kt b/app/src/main/java/com/pinakes/app/data/network/AuthInterceptor.kt index 0ebd110..d967171 100644 --- a/app/src/main/java/com/pinakes/app/data/network/AuthInterceptor.kt +++ b/app/src/main/java/com/pinakes/app/data/network/AuthInterceptor.kt @@ -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 { diff --git a/app/src/main/java/com/pinakes/app/ui/common/DateFormat.kt b/app/src/main/java/com/pinakes/app/ui/common/DateFormat.kt index a5a24c0..094dbf1 100644 --- a/app/src/main/java/com/pinakes/app/ui/common/DateFormat.kt +++ b/app/src/main/java/com/pinakes/app/ui/common/DateFormat.kt @@ -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() diff --git a/app/src/main/java/com/pinakes/app/ui/screens/bookclub/BookClubHomeScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/bookclub/BookClubHomeScreen.kt index c17ad7c..9ab0384 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/bookclub/BookClubHomeScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/bookclub/BookClubHomeScreen.kt @@ -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() } } @@ -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( diff --git a/app/src/main/java/com/pinakes/app/ui/screens/bookclub/BookClubHomeViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/bookclub/BookClubHomeViewModel.kt index d58f903..1808794 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/bookclub/BookClubHomeViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/bookclub/BookClubHomeViewModel.kt @@ -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, ) @@ -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, ) } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/bookclub/BookClubUi.kt b/app/src/main/java/com/pinakes/app/ui/screens/bookclub/BookClubUi.kt index b2e04d3..257e18e 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/bookclub/BookClubUi.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/bookclub/BookClubUi.kt @@ -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 @@ -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() } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/bookclub/ClubDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/bookclub/ClubDetailScreen.kt index a7c544d..256146a 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/bookclub/ClubDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/bookclub/ClubDetailScreen.kt @@ -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() } } @@ -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 }, ) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/bookclub/ClubDetailViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/bookclub/ClubDetailViewModel.kt index a3e3036..4ab2111 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/bookclub/ClubDetailViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/bookclub/ClubDetailViewModel.kt @@ -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, ) @@ -84,10 +86,13 @@ 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) + } } } } @@ -95,17 +100,20 @@ class ClubDetailViewModel @Inject constructor( fun vote(pollId: Int, optionIds: List) { 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) + } } } } @@ -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) + } } } } @@ -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) } diff --git a/i18n/de.json b/i18n/de.json index 3bc71ad..247704e 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -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", diff --git a/i18n/en.json b/i18n/en.json index 81abf84..0d9f76b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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", diff --git a/i18n/fr.json b/i18n/fr.json index f5d7a3f..1bbaff8 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -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", diff --git a/i18n/it.json b/i18n/it.json index 5e8293d..487a07a 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -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",