From 56bd477d0fd3226e145f276c8f31e66236c683ab Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 10:40:08 +0000 Subject: [PATCH 1/4] feat(book-club): surface the Book Club plugin in the app Integrate the server-side Book Club plugin's mobile surface (/api/v1/bookclub/*) so members can use it from the Android app whenever the plugin (and its `mobile` module) is active for their instance. - Auto-discovery + gating: probe GET /api/v1/bookclub/health (public, no token) alongside /health; cache the flag in an encrypted store and show a Book Club entry in Profile only when the plugin answers 2xx (404 hides it). Confirm-before-showing, like public registration. - Data layer: BookClubApi (Retrofit) + BookClubRepository + BookClubStore, reusing the same base URL and bearer token as the core client. The plugin uses a different envelope ({success,data,error}); a dedicated bookClubCall maps it into the shared ApiResult/ErrorCodes. - Screens: Book Club home (reading dashboard, your clubs, discover directory) and club detail (reading list with state chips + progress, polls, meetings). Actions wired end-to-end: join, vote (simple/multi/ weighted, ballot pre-seeded from my_option_ids), RSVP and reading progress. Guests are read-only; advanced poll modes and proposing a title deep-link to the web page per the API contract. - i18n: new strings added to all four locale JSONs (it/en/fr/de), in parity. Verified: assembleDebug, lintDebug and assembleRelease (R8/minify) all BUILD SUCCESSFUL. --- README.md | 12 +- STATUS.md | 23 + .../pinakes/app/data/model/BookClubModels.kt | 279 ++++++++++ .../pinakes/app/data/network/BookClubApi.kt | 83 +++ .../app/data/network/BookClubResult.kt | 70 +++ .../pinakes/app/data/network/NetworkModule.kt | 46 +- .../app/data/repository/AuthRepository.kt | 5 + .../app/data/repository/BookClubRepository.kt | 78 +++ .../pinakes/app/data/store/BookClubStore.kt | 52 ++ .../main/java/com/pinakes/app/di/AppModule.kt | 17 +- .../com/pinakes/app/ui/common/AppViewModel.kt | 5 + .../pinakes/app/ui/navigation/MainScaffold.kt | 4 + .../app/ui/navigation/PinakesNavHost.kt | 23 + .../com/pinakes/app/ui/navigation/Routes.kt | 6 + .../ui/screens/bookclub/BookClubHomeScreen.kt | 274 ++++++++++ .../screens/bookclub/BookClubHomeViewModel.kt | 76 +++ .../app/ui/screens/bookclub/BookClubUi.kt | 71 +++ .../ui/screens/bookclub/ClubDetailScreen.kt | 507 ++++++++++++++++++ .../screens/bookclub/ClubDetailViewModel.kt | 136 +++++ .../app/ui/screens/profile/ProfileScreen.kt | 10 + i18n/de.json | 57 +- i18n/en.json | 57 +- i18n/fr.json | 57 +- i18n/it.json | 57 +- 24 files changed, 1988 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/pinakes/app/data/model/BookClubModels.kt create mode 100644 app/src/main/java/com/pinakes/app/data/network/BookClubApi.kt create mode 100644 app/src/main/java/com/pinakes/app/data/network/BookClubResult.kt create mode 100644 app/src/main/java/com/pinakes/app/data/repository/BookClubRepository.kt create mode 100644 app/src/main/java/com/pinakes/app/data/store/BookClubStore.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/bookclub/BookClubHomeScreen.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/bookclub/BookClubHomeViewModel.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/bookclub/BookClubUi.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/bookclub/ClubDetailScreen.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/bookclub/ClubDetailViewModel.kt diff --git a/README.md b/README.md index ef3d5fd..307a3a3 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ catalogue-only mode, push availability). | **Ebooks** | In-app PDF reader (PdfRenderer); other formats open externally | | **My Library** | Loans grouped into Overdue / On loan / Ready & scheduled / Pending, with due dates; reservations with cancel | | **Wishlist** | Add and remove titles | +| **Book Club** | When the instance runs the **Book Club** plugin: browse your clubs and the directory, open a club's reading list / polls / meetings, join, vote in-app (simple / multi / weighted ballots), RSVP to meetings and track your reading progress — advanced poll modes and proposing a title deep-link to the web | | **Profile** | Edit profile, change password, device list, theme switcher, language switcher, logout | | **Notifications** | Loan due/overdue, reservation ready, book available | | **Themes** | Material 3 light and dark, light by default; pick light/dark/system in Profile | @@ -125,6 +126,15 @@ admin enables it once: `/api/v1/health` and the interactive docs at `/api/v1/docs` are always reachable. See the [Mobile API documentation](https://fabiodalez-dev.github.io/Pinakes/#/admin/mobile-api). +### Book Club (optional plugin) + +If the instance runs the **Book Club** plugin (with its `mobile` module enabled), +the app auto-discovers it via `GET /api/v1/bookclub/health` (a 2xx after login) and +adds a **Book Club** entry to the Profile tab; otherwise the section stays hidden. +It reuses the same bearer token — no extra login — and talks to the plugin's +`/api/v1/bookclub/*` surface. Advanced poll modes (stars / ranking / elimination) +and proposing a title open the corresponding web page. + ## Internationalization Translations live in `i18n/{en,it,fr,de}.json` (one flat key/value map per @@ -158,7 +168,7 @@ app/src/main/java/com/pinakes/app/ ├── theme/ Color / Type / Theme (magenta #D70161 brand, light default) ├── components/ BookCard, AvailabilityChip, AudioPlayer, bottom bar … ├── navigation/ NavHost + scaffold - └── screens/ onboarding, login, home, search, detail, library, wishlist, profile … + └── screens/ onboarding, login, home, search, detail, library, wishlist, profile, bookclub … i18n/ en/it/fr/de.json (source of truth for strings) _contract/ OpenAPI snapshot + API spec the app is built against ``` diff --git a/STATUS.md b/STATUS.md index f4e7bb6..ba2a998 100644 --- a/STATUS.md +++ b/STATUS.md @@ -81,6 +81,29 @@ two genuine bugs that a build-only check could not have caught: availability logic (`loanable_now || copies_available > 0`). Verified: titles, authors and the green *Available* / red *On loan* chips now render correctly. +## Book Club plugin integration + +The optional server-side **Book Club** plugin is now surfaced in the app. It is +**auto-discovered and gated**: after login the app probes `GET /api/v1/bookclub/health` +(public, no token) and only shows the section when the plugin + its `mobile` module are +active for the instance (a 404 hides it). The flag is cached in an encrypted store and +refreshed alongside `/health`, so the entry never flickers and a first-run/offline probe +keeps it hidden — same "confirm before showing" rule as public registration. + +- **Data layer** — `BookClubApi` (Retrofit) + `BookClubRepository` + `BookClubStore`, all + reusing the same base URL and bearer token as the core client. The plugin uses a + **different envelope** (`{success, data, error}` vs the core `{data, meta, error}`), handled + by a dedicated `bookClubCall` that maps into the shared `ApiResult`/`ErrorCodes`. +- **Screens** — a **Book Club home** (your reading dashboard, your clubs, discover directory, + reached from Profile) and a **club detail** (reading list with state chips + progress, + polls, meetings). Actions wired end-to-end: **join**, **vote** (simple / multi / weighted, + with the ballot pre-seeded from `my_option_ids`), **RSVP** (yes / maybe / no), and + **reading progress**. Guests are read-only. Advanced poll modes and proposing a title + deep-link to the web page (per the API contract). +- **i18n** — all new strings added to the 4 locale JSONs (it/en/fr/de), in parity. +- **Build** — `assembleDebug`, `lintDebug` and `assembleRelease` (R8/minify — exercises the + keep rules for the new `@Serializable` models) all **BUILD SUCCESSFUL**. + ## Partial / TODO - **Push (UnifiedPush): STUBBED — not wired.** The data layer is ready (`/me/push/subscribe`, diff --git a/app/src/main/java/com/pinakes/app/data/model/BookClubModels.kt b/app/src/main/java/com/pinakes/app/data/model/BookClubModels.kt new file mode 100644 index 0000000..53bff8e --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/model/BookClubModels.kt @@ -0,0 +1,279 @@ +package com.pinakes.app.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Models for the Book Club plugin's mobile surface (`/api/v1/bookclub/…`). + * + * IMPORTANT: this surface uses a DIFFERENT envelope from the core Mobile API. + * The Book Club controller returns `{"success":true,"data":{...}}` on success and + * `{"success":false,"error":{"code","message"}}` on failure — no `meta`, no top-level + * `data:null` on errors. [BookClubEnvelope] mirrors that; see `bookClubCall`. + * + * Field names mirror the live JSON emitted by + * `storage/plugins/book-club/src/MobileApiController.php` (English snake_case). + */ +@Serializable +data class BookClubEnvelope( + val success: Boolean = false, + val data: T? = null, + val error: BookClubError? = null, +) + +@Serializable +data class BookClubError( + val code: String = "unknown", + val message: String = "", +) + +// ---------- Discovery ---------- +// GET /bookclub/health (no token). A 2xx means the plugin + mobile module are active. +@Serializable +data class BookClubHealth( + val plugin: String = "", + val enabled: Boolean = false, + val version: String = "", + val requires: List = emptyList(), + val endpoints: List = emptyList(), +) + +// ---------- Clubs list ---------- +// GET /bookclub/clubs → { my_clubs, directory } +@Serializable +data class BookClubClubs( + @SerialName("my_clubs") val myClubs: List = emptyList(), + val directory: List = emptyList(), +) + +@Serializable +data class MyClub( + val id: Int = 0, + val slug: String = "", + val name: String = "", + val color: String = "", + val privacy: String = "", // public | private | invite | hidden + @SerialName("member_status") val memberStatus: String = "", // active | pending | banned + val role: String = "member", // owner | moderator | member | guest +) { + val isPending: Boolean get() = memberStatus == "pending" +} + +@Serializable +data class DirectoryClub( + val id: Int = 0, + val slug: String = "", + val name: String = "", + val description: String = "", + val color: String = "", + val privacy: String = "", + @SerialName("member_count") val memberCount: Int = 0, + @SerialName("max_members") val maxMembers: Int? = null, +) { + val isFull: Boolean get() = maxMembers != null && memberCount >= maxMembers +} + +// ---------- Club detail ---------- +// GET /bookclub/clubs/{slug} +@Serializable +data class BookClubDetail( + val club: ClubInfo = ClubInfo(), + @SerialName("my_membership") val myMembership: Membership? = null, + val workflow: List = emptyList(), + val books: List = emptyList(), + val polls: List = emptyList(), + val meetings: List = emptyList(), +) { + /** Guests are read-only: hide vote/propose/RSVP/progress actions. */ + val isActiveMember: Boolean get() = myMembership?.status == "active" + val isGuest: Boolean get() = myMembership?.role == "guest" + /** Can take part (vote, RSVP, track progress): active member that is not a guest. */ + val canParticipate: Boolean get() = isActiveMember && !isGuest + val canJoin: Boolean + get() = myMembership == null && (club.privacy == "public" || club.privacy == "private") +} + +@Serializable +data class ClubInfo( + val id: Int = 0, + val slug: String = "", + val name: String = "", + val description: String = "", + val rules: String = "", // members-only; blank otherwise + val color: String = "", + val privacy: String = "", + @SerialName("member_count") val memberCount: Int = 0, + @SerialName("max_members") val maxMembers: Int? = null, +) + +@Serializable +data class Membership( + val status: String = "", // active | pending | banned + val role: String = "member", +) + +@Serializable +data class WorkflowState( + val key: String = "", + val label: String = "", + val color: String = "", +) + +@Serializable +data class ClubBook( + val id: Int = 0, + @SerialName("libro_id") val libroId: Int = 0, + val title: String = "", + val authors: String = "", + @SerialName("cover_url") val coverUrl: String = "", + val state: String = "", + @SerialName("state_label") val stateLabel: String = "", + @SerialName("state_color") val stateColor: String = "#6b7280", + @SerialName("is_current") val isCurrent: Boolean = false, + @SerialName("reading_starts") val readingStarts: String? = null, + @SerialName("reading_ends") val readingEnds: String? = null, + val motivation: String = "", + @SerialName("my_progress") val myProgress: ReadingProgress? = null, +) + +@Serializable +data class ReadingProgress( + val percent: Int = 0, + val finished: Boolean = false, +) + +@Serializable +data class ClubPoll( + val id: Int = 0, + val title: String = "", + val mode: String = "", // simple | multi | weighted | stars | ranking | elimination + val status: String = "", // open | closed | ... + @SerialName("closes_at") val closesAt: String? = null, + @SerialName("votes_per_member") val votesPerMember: Int = 1, + @SerialName("voter_count") val voterCount: Int = 0, + @SerialName("my_option_ids") val myOptionIds: List = emptyList(), + val options: List = emptyList(), + @SerialName("votable_in_app") val votableInApp: Boolean = false, +) { + val isOpen: Boolean get() = status == "open" + /** simple = single choice; multi/weighted = up to [votesPerMember]. */ + val maxChoices: Int get() = if (mode == "simple") 1 else votesPerMember.coerceAtLeast(1) +} + +@Serializable +data class PollOption( + val id: Int = 0, + @SerialName("club_book_id") val clubBookId: Int = 0, + val title: String = "", + val score: Double = 0.0, +) + +@Serializable +data class ClubMeeting( + val id: Int = 0, + val title: String = "", + @SerialName("starts_at") val startsAt: String = "", + @SerialName("ends_at") val endsAt: String? = null, + val kind: String = "", // in_person | online | hybrid + val status: String = "", // scheduled | ... + val location: String = "", + @SerialName("video_url") val videoUrl: String = "", + val agenda: String = "", + @SerialName("book_title") val bookTitle: String = "", + @SerialName("yes_count") val yesCount: Int = 0, + val seats: Int? = null, + @SerialName("my_rsvp") val myRsvp: String? = null, // yes | no | maybe | null +) { + val isFull: Boolean get() = seats != null && yesCount >= seats +} + +// ---------- Personal dashboard ---------- +// GET /bookclub/me/dashboard → { clubs: [...] } +@Serializable +data class BookClubDashboard( + val clubs: List = emptyList(), +) + +@Serializable +data class DashboardCard( + val club: DashboardClub = DashboardClub(), + @SerialName("current_books") val currentBooks: List = emptyList(), + @SerialName("next_meeting") val nextMeeting: DashboardMeeting? = null, + @SerialName("open_polls") val openPolls: List = emptyList(), +) + +@Serializable +data class DashboardClub( + val id: Int = 0, + val slug: String = "", + val name: String = "", + val color: String = "", + @SerialName("member_status") val memberStatus: String = "", + val role: String = "member", +) + +@Serializable +data class DashboardBook( + val id: Int = 0, + val title: String = "", + val authors: String = "", + @SerialName("cover_url") val coverUrl: String = "", + @SerialName("reading_ends") val readingEnds: String? = null, + @SerialName("my_progress") val myProgress: ReadingProgress? = null, +) + +@Serializable +data class DashboardMeeting( + val id: Int = 0, + val title: String = "", + @SerialName("starts_at") val startsAt: String = "", +) + +@Serializable +data class DashboardPoll( + val id: Int = 0, + val title: String = "", + @SerialName("closes_at") val closesAt: String? = null, +) + +// ---------- Action request bodies ---------- +@Serializable +data class ClubVoteRequest(val options: List) + +@Serializable +data class ClubRsvpRequest(val response: String) // yes | no | maybe + +@Serializable +data class ClubProgressRequest( + val percent: Int, + val finished: Boolean? = null, +) + +@Serializable +data class ClubProposalRequest( + @SerialName("libro_id") val libroId: Int, + val motivation: String? = null, +) + +// ---------- Action responses ---------- +@Serializable +data class JoinResult(val status: String = "") // active | pending + +@Serializable +data class VoteResult( + @SerialName("poll_id") val pollId: Int = 0, + val options: List = emptyList(), +) + +@Serializable +data class RsvpResult( + @SerialName("meeting_id") val meetingId: Int = 0, + val response: String = "", +) + +@Serializable +data class ProgressResult( + @SerialName("club_book_id") val clubBookId: Int = 0, + val percent: Int = 0, + val finished: Boolean = false, +) diff --git a/app/src/main/java/com/pinakes/app/data/network/BookClubApi.kt b/app/src/main/java/com/pinakes/app/data/network/BookClubApi.kt new file mode 100644 index 0000000..33141ef --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/network/BookClubApi.kt @@ -0,0 +1,83 @@ +package com.pinakes.app.data.network + +import com.pinakes.app.data.model.BookClubClubs +import com.pinakes.app.data.model.BookClubDashboard +import com.pinakes.app.data.model.BookClubDetail +import com.pinakes.app.data.model.BookClubEnvelope +import com.pinakes.app.data.model.BookClubHealth +import com.pinakes.app.data.model.ClubProgressRequest +import com.pinakes.app.data.model.ClubProposalRequest +import com.pinakes.app.data.model.ClubRsvpRequest +import com.pinakes.app.data.model.ClubVoteRequest +import com.pinakes.app.data.model.JoinResult +import com.pinakes.app.data.model.ProgressResult +import com.pinakes.app.data.model.RsvpResult +import com.pinakes.app.data.model.VoteResult +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.POST +import retrofit2.http.Path + +/** + * Book Club plugin endpoints, exposed by the plugin's `mobile` module under + * `/api/v1/bookclub/…`. Base URL is the same instance origin + `/api/v1/` used by + * [PinakesApi], and the authenticated calls carry the SAME bearer token (injected by + * [AuthInterceptor]) — the app authenticates once via the core Mobile API. + * + * These responses use the Book Club envelope ([BookClubEnvelope]: `{success, data, error}`), + * NOT the core `{data, meta, error}` one — so they are wrapped with `bookClubCall`, not `apiCall`. + */ +interface BookClubApi { + + /** Discovery — no token. 2xx means the section is available; 404 means the plugin is off. */ + @GET("bookclub/health") + @Headers(NO_AUTH) + suspend fun health(): BookClubEnvelope + + // ---- Reads ---- + @GET("bookclub/clubs") + suspend fun clubs(): BookClubEnvelope + + @GET("bookclub/clubs/{slug}") + suspend fun clubDetail(@Path("slug") slug: String): BookClubEnvelope + + @GET("bookclub/me/dashboard") + suspend fun dashboard(): BookClubEnvelope + + // ---- Actions (mirror the web rules server-side) ---- + @POST("bookclub/clubs/{slug}/join") + suspend fun join(@Path("slug") slug: String): BookClubEnvelope + + @POST("bookclub/clubs/{slug}/proposals") + suspend fun propose( + @Path("slug") slug: String, + @Body body: ClubProposalRequest, + ): BookClubEnvelope + + @POST("bookclub/clubs/{slug}/polls/{pollId}/vote") + suspend fun vote( + @Path("slug") slug: String, + @Path("pollId") pollId: Int, + @Body body: ClubVoteRequest, + ): BookClubEnvelope + + @POST("bookclub/clubs/{slug}/meetings/{meetingId}/rsvp") + suspend fun rsvp( + @Path("slug") slug: String, + @Path("meetingId") meetingId: Int, + @Body body: ClubRsvpRequest, + ): BookClubEnvelope + + @POST("bookclub/clubs/{slug}/books/{clubBookId}/progress") + suspend fun progress( + @Path("slug") slug: String, + @Path("clubBookId") clubBookId: Int, + @Body body: ClubProgressRequest, + ): BookClubEnvelope + + companion object { + /** Same value-less marker [AuthInterceptor] uses to skip bearer injection on public calls. */ + const val NO_AUTH = "X-Pinakes-No-Auth: true" + } +} diff --git a/app/src/main/java/com/pinakes/app/data/network/BookClubResult.kt b/app/src/main/java/com/pinakes/app/data/network/BookClubResult.kt new file mode 100644 index 0000000..a29639a --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/network/BookClubResult.kt @@ -0,0 +1,70 @@ +package com.pinakes.app.data.network + +import com.pinakes.app.data.model.BookClubEnvelope +import kotlinx.serialization.json.Json +import retrofit2.HttpException +import java.io.IOException + +/** + * Bridges the Book Club envelope (`{success, data, error}`) into the shared [ApiResult] the + * whole app already switches on — the analogue of [apiCall] for [BookClubApi]. + * + * Success mapping: + * - `error != null` → [ApiResult.Failure] carrying the plugin's error code + message. + * - `data != null` → [ApiResult.Success]. + * - no data (no-content ok) → [ApiResult.Success] of `Unit` (e.g. the `propose` body we ignore). + * + * Failure mapping mirrors [apiCall]: HTTP errors (4xx/5xx) arrive as [HttpException]; the plugin + * error body is parsed for its `code`/`message`, falling back to a status-derived code so the + * caller can still branch (and so [ApiResult.Failure.httpStatus] tells `404` — "section off" — + * apart from a network error). + */ +private val bookClubErrorJson = Json { ignoreUnknownKeys = true; isLenient = true } + +suspend fun bookClubCall(block: suspend () -> BookClubEnvelope): ApiResult = try { + val envelope = block() + when { + envelope.error != null -> + ApiResult.Failure(envelope.error!!.code, envelope.error!!.message, httpStatus = 200) + envelope.data != null -> ApiResult.Success(envelope.data!!) + else -> { + @Suppress("UNCHECKED_CAST") + ApiResult.Success(Unit as T) + } + } +} catch (e: HttpException) { + e.toBookClubFailure() +} catch (e: IOException) { + ApiResult.Failure(ErrorCodes.NETWORK, e.message ?: "Network error", 0) +} catch (e: Throwable) { + ApiResult.Failure(ErrorCodes.UNKNOWN, e.message ?: "Unexpected error", 0) +} + +private fun HttpException.toBookClubFailure(): ApiResult.Failure { + val status = code() + val retryAfter = response()?.headers()?.get("Retry-After")?.toLongOrNull() + val body = try { response()?.errorBody()?.string() } catch (_: Throwable) { null } + val parsed = parseBookClubErrorBody(body, status) + return parsed.copy(retryAfterSeconds = parsed.retryAfterSeconds ?: retryAfter) +} + +/** Parse a Book Club error envelope; fall back to a status-derived code/message when absent. */ +internal fun parseBookClubErrorBody(body: String?, status: Int): ApiResult.Failure { + val error = body?.takeIf { it.isNotBlank() }?.let { + runCatching { bookClubErrorJson.decodeFromString>(it).error }.getOrNull() + } + val code = error?.code?.takeIf { it.isNotBlank() } ?: bookClubCodeForStatus(status) + val message = error?.message?.takeIf { it.isNotBlank() } ?: "" + return ApiResult.Failure(code = code, message = message, httpStatus = status) +} + +private fun bookClubCodeForStatus(status: Int): String = when (status) { + 400, 422 -> ErrorCodes.VALIDATION + 401 -> ErrorCodes.UNAUTHORIZED + 403 -> ErrorCodes.FORBIDDEN + 404 -> ErrorCodes.NOT_FOUND + 409 -> ErrorCodes.CONFLICT + 429 -> ErrorCodes.RATE_LIMITED + in 500..599 -> ErrorCodes.SERVER_ERROR + else -> ErrorCodes.UNKNOWN +} diff --git a/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt b/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt index 5f78788..e6adc84 100644 --- a/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt +++ b/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt @@ -43,20 +43,22 @@ class NetworkModule(private val session: SessionStore) { @Volatile private var cachedBaseUrl: String? = null + @Volatile + private var cachedRetrofit: Retrofit? = null + @Volatile private var cachedApi: PinakesApi? = null - /** - * Returns a [PinakesApi] bound to [baseUrl] (must end with `/api/v1/`). Falls back to the - * persisted instance URL when [baseUrl] is null. Throws if no base URL is available — call - * sites in onboarding pass an explicit URL; authenticated repositories rely on the stored one. - */ + @Volatile + private var cachedBookClubApi: BookClubApi? = null + + /** Shared Retrofit for a given base URL, rebuilt only when the instance URL changes. */ @Synchronized - fun api(baseUrl: String? = null): PinakesApi { + private fun retrofit(baseUrl: String?): Retrofit { val target = baseUrl ?: session.instanceUrl requireNotNull(target) { "No instance URL configured. Complete onboarding first." } val normalized = if (target.endsWith("/")) target else "$target/" - val existing = cachedApi + val existing = cachedRetrofit if (existing != null && normalized == cachedBaseUrl) return existing val contentType = "application/json".toMediaType() @@ -65,17 +67,41 @@ class NetworkModule(private val session: SessionStore) { .client(okHttpClient) .addConverterFactory(json.asConverterFactory(contentType)) .build() - val api = retrofit.create(PinakesApi::class.java) cachedBaseUrl = normalized - cachedApi = api - return api + cachedRetrofit = retrofit + cachedApi = null + cachedBookClubApi = null + return retrofit + } + + /** + * Returns a [PinakesApi] bound to [baseUrl] (must end with `/api/v1/`). Falls back to the + * persisted instance URL when [baseUrl] is null. Throws if no base URL is available — call + * sites in onboarding pass an explicit URL; authenticated repositories rely on the stored one. + */ + @Synchronized + fun api(baseUrl: String? = null): PinakesApi { + val retrofit = retrofit(baseUrl) + return cachedApi ?: retrofit.create(PinakesApi::class.java).also { cachedApi = it } + } + + /** + * Returns the [BookClubApi] bound to the same instance base URL as [api]. The Book Club + * plugin lives under `/api/v1/bookclub/…` and reuses the same bearer token. + */ + @Synchronized + fun bookClubApi(baseUrl: String? = null): BookClubApi { + val retrofit = retrofit(baseUrl) + return cachedBookClubApi ?: retrofit.create(BookClubApi::class.java).also { cachedBookClubApi = it } } /** Drop the cached Retrofit so the next [api] call rebuilds against a new instance URL. */ @Synchronized fun invalidate() { cachedBaseUrl = null + cachedRetrofit = null cachedApi = null + cachedBookClubApi = null } companion object { diff --git a/app/src/main/java/com/pinakes/app/data/repository/AuthRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/AuthRepository.kt index b1e894a..ab3e6b5 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/AuthRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/AuthRepository.kt @@ -19,6 +19,7 @@ class AuthRepository( private val network: NetworkModule, private val session: SessionStore, private val features: FeatureStore, + private val bookClub: BookClubRepository, ) { /** @@ -68,6 +69,9 @@ class AuthRepository( is ApiResult.Success -> features.update(res.data) is ApiResult.Failure -> { /* keep last-known flags; never lock the user out */ } } + // Probe the optional Book Club plugin surface too, so the section shows/hides in step + // with the instance's plugin state. Best-effort: a failure keeps the last-known flag. + bookClub.refreshAvailability() } suspend fun login(email: String, password: String): ApiResult { @@ -132,6 +136,7 @@ class AuthRepository( fun forgetInstance() { session.clearAll() features.clear() + bookClub.clearAvailability() network.invalidate() } diff --git a/app/src/main/java/com/pinakes/app/data/repository/BookClubRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/BookClubRepository.kt new file mode 100644 index 0000000..bf93e49 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/repository/BookClubRepository.kt @@ -0,0 +1,78 @@ +package com.pinakes.app.data.repository + +import com.pinakes.app.data.model.BookClubClubs +import com.pinakes.app.data.model.BookClubDashboard +import com.pinakes.app.data.model.BookClubDetail +import com.pinakes.app.data.model.ClubProgressRequest +import com.pinakes.app.data.model.ClubProposalRequest +import com.pinakes.app.data.model.ClubRsvpRequest +import com.pinakes.app.data.model.ClubVoteRequest +import com.pinakes.app.data.model.JoinResult +import com.pinakes.app.data.model.ProgressResult +import com.pinakes.app.data.model.RsvpResult +import com.pinakes.app.data.model.VoteResult +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes +import com.pinakes.app.data.network.NetworkModule +import com.pinakes.app.data.network.bookClubCall +import com.pinakes.app.data.store.BookClubStore +import com.pinakes.app.data.store.SessionStore + +/** + * Book Club plugin surface (`/api/v1/bookclub/…`): availability discovery, reads (clubs, + * detail, personal dashboard) and the write actions (join, propose, vote, RSVP, progress). + * + * Availability is probed once per `/health` refresh and cached in [BookClubStore] so the app + * only shows the section when the plugin's `mobile` module is active for this instance. + */ +class BookClubRepository( + private val network: NetworkModule, + private val store: BookClubStore, + private val session: SessionStore, +) { + + /** + * Re-probe `GET /bookclub/health`. A 2xx flips the section on; an explicit 404 flips it off; + * a network/other failure keeps the last-known flag (never hide a working section on a blip). + * No token is required, so this is safe to call at app start alongside the core health refresh. + */ + suspend fun refreshAvailability() { + if (!session.hasInstance()) return + when (val res = bookClubCall { network.bookClubApi().health() }) { + is ApiResult.Success -> store.setAvailable(true) + is ApiResult.Failure -> if (res.httpStatus == 404 || res.code == ErrorCodes.NOT_FOUND) { + store.setAvailable(false) + } + } + } + + suspend fun clubs(): ApiResult = + bookClubCall { network.bookClubApi().clubs() } + + suspend fun clubDetail(slug: String): ApiResult = + bookClubCall { network.bookClubApi().clubDetail(slug) } + + suspend fun dashboard(): ApiResult = + bookClubCall { network.bookClubApi().dashboard() } + + suspend fun join(slug: String): ApiResult = + bookClubCall { network.bookClubApi().join(slug) } + + suspend fun propose(slug: String, libroId: Int, motivation: String?): ApiResult = + bookClubCall { network.bookClubApi().propose(slug, ClubProposalRequest(libroId, motivation)) } + + suspend fun vote(slug: String, pollId: Int, optionIds: List): ApiResult = + bookClubCall { network.bookClubApi().vote(slug, pollId, ClubVoteRequest(optionIds)) } + + suspend fun rsvp(slug: String, meetingId: Int, response: String): ApiResult = + bookClubCall { network.bookClubApi().rsvp(slug, meetingId, ClubRsvpRequest(response)) } + + suspend fun progress(slug: String, clubBookId: Int, percent: Int, finished: Boolean?): ApiResult = + bookClubCall { network.bookClubApi().progress(slug, clubBookId, ClubProgressRequest(percent, finished)) } + + /** Forget the availability flag (instance forgotten). */ + fun clearAvailability() = store.clear() + + /** Origin (scheme://host[:port]) of the instance, for deep-linking web-only flows. */ + fun webBaseUrl(): String = session.instanceOrigin.orEmpty() +} diff --git a/app/src/main/java/com/pinakes/app/data/store/BookClubStore.kt b/app/src/main/java/com/pinakes/app/data/store/BookClubStore.kt new file mode 100644 index 0000000..10ccf4d --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/store/BookClubStore.kt @@ -0,0 +1,52 @@ +package com.pinakes.app.data.store + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Whether the current instance exposes the Book Club plugin's mobile surface. + * + * Sourced from `GET /api/v1/bookclub/health` (2xx → available, 404 → off) and persisted so the + * Profile entry point doesn't flicker between launches. Like `registration_enabled` in + * [FeatureStore], the default is DISABLED until discovery confirms it — a first-run or offline + * probe keeps the section hidden rather than showing a dead entry. + */ +class BookClubStore(context: Context) { + + private val prefs: SharedPreferences = run { + val masterKey = MasterKey.Builder(context.applicationContext) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + context.applicationContext, + FILE_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + private val _available = MutableStateFlow(prefs.getBoolean(KEY_AVAILABLE, false)) + val available: StateFlow = _available.asStateFlow() + + fun setAvailable(value: Boolean) { + prefs.edit().putBoolean(KEY_AVAILABLE, value).apply() + _available.value = value + } + + /** Reset when the instance is forgotten. */ + fun clear() { + prefs.edit().clear().apply() + _available.value = false + } + + companion object { + private const val FILE_NAME = "pinakes_bookclub_secure" + private const val KEY_AVAILABLE = "available" + } +} diff --git a/app/src/main/java/com/pinakes/app/di/AppModule.kt b/app/src/main/java/com/pinakes/app/di/AppModule.kt index 110eee2..7072aa7 100644 --- a/app/src/main/java/com/pinakes/app/di/AppModule.kt +++ b/app/src/main/java/com/pinakes/app/di/AppModule.kt @@ -5,6 +5,7 @@ import com.pinakes.app.data.local.AppDatabase import com.pinakes.app.data.local.CatalogDao import com.pinakes.app.data.network.NetworkModule import com.pinakes.app.data.repository.AuthRepository +import com.pinakes.app.data.repository.BookClubRepository import com.pinakes.app.data.repository.CatalogRepository import com.pinakes.app.data.repository.LibraryRepository import com.pinakes.app.data.repository.MessagesRepository @@ -12,6 +13,7 @@ import com.pinakes.app.data.repository.NotificationsRepository import com.pinakes.app.data.repository.ProfileRepository import com.pinakes.app.data.repository.ReviewsRepository import com.pinakes.app.data.repository.WishlistRepository +import com.pinakes.app.data.store.BookClubStore import com.pinakes.app.data.store.FeatureStore import com.pinakes.app.data.store.SessionStore import com.pinakes.app.data.store.ThemeStore @@ -39,6 +41,9 @@ object AppModule { @Provides @Singleton fun features(@ApplicationContext context: Context): FeatureStore = FeatureStore(context) + @Provides @Singleton + fun bookClubStore(@ApplicationContext context: Context): BookClubStore = BookClubStore(context) + @Provides @Singleton fun network(session: SessionStore): NetworkModule = NetworkModule(session) @@ -53,8 +58,16 @@ object AppModule { CatalogRepository(network, dao) @Provides @Singleton - fun authRepository(network: NetworkModule, session: SessionStore, features: FeatureStore): AuthRepository = - AuthRepository(network, session, features) + fun bookClubRepository(network: NetworkModule, store: BookClubStore, session: SessionStore): BookClubRepository = + BookClubRepository(network, store, session) + + @Provides @Singleton + fun authRepository( + network: NetworkModule, + session: SessionStore, + features: FeatureStore, + bookClub: BookClubRepository, + ): AuthRepository = AuthRepository(network, session, features, bookClub) @Provides @Singleton fun libraryRepository(network: NetworkModule): LibraryRepository = LibraryRepository(network) diff --git a/app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt b/app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt index 545942c..70fb9b3 100644 --- a/app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt @@ -1,6 +1,7 @@ package com.pinakes.app.ui.common import androidx.lifecycle.ViewModel +import com.pinakes.app.data.store.BookClubStore import com.pinakes.app.data.store.FeatureStore import com.pinakes.app.data.store.SessionStore import com.pinakes.app.data.store.ThemeStore @@ -21,6 +22,7 @@ import javax.inject.Inject class AppViewModel @Inject constructor( session: SessionStore, featureStore: FeatureStore, + bookClubStore: BookClubStore, private val themeStore: ThemeStore, ) : ViewModel() { @@ -28,5 +30,8 @@ class AppViewModel @Inject constructor( val features = featureStore.features val themeMode = themeStore.mode + /** Whether the instance exposes the optional Book Club plugin (gates the Profile entry). */ + val bookClubAvailable = bookClubStore.available + fun setThemeMode(mode: ThemeMode) = themeStore.setMode(mode) } diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/MainScaffold.kt b/app/src/main/java/com/pinakes/app/ui/navigation/MainScaffold.kt index 0f2545e..dc9bfff 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/MainScaffold.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/MainScaffold.kt @@ -39,9 +39,11 @@ fun MainScaffold( onOpenNotifications: () -> Unit, onOpenContact: () -> Unit, onOpenMyReviews: () -> Unit, + onOpenBookClub: () -> Unit, ) { val app: AppViewModel = hiltViewModel() val features by app.features.collectAsStateWithLifecycle() + val bookClubAvailable by app.bookClubAvailable.collectAsStateWithLifecycle() var tab by rememberSaveable { mutableStateOf(PinakesTab.Home) } @@ -95,6 +97,8 @@ fun MainScaffold( onOpenNotifications = onOpenNotifications, onOpenContact = onOpenContact, onOpenMyReviews = onOpenMyReviews, + onOpenBookClub = onOpenBookClub, + showBookClub = bookClubAvailable, ) } } diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt b/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt index d999389..91d0d51 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt @@ -18,6 +18,8 @@ import androidx.navigation.NavType import androidx.hilt.navigation.compose.hiltViewModel import com.pinakes.app.data.store.AuthState import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.screens.bookclub.BookClubHomeScreen +import com.pinakes.app.ui.screens.bookclub.ClubDetailScreen import com.pinakes.app.ui.screens.contact.ContactScreen import com.pinakes.app.ui.screens.detail.BookDetailScreen import com.pinakes.app.ui.screens.login.ForgotPasswordScreen @@ -95,6 +97,7 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { onOpenNotifications = { navController.navigate(Routes.NOTIFICATIONS) }, onOpenContact = { navController.navigate(Routes.CONTACT) }, onOpenMyReviews = { navController.navigate(Routes.MY_REVIEWS) }, + onOpenBookClub = { navController.navigate(Routes.BOOK_CLUB) }, ) } @@ -140,5 +143,25 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { onOpenBook = { id -> navController.navigate(Routes.bookDetail(id)) }, ) } + + composable( + Routes.BOOK_CLUB, + enterTransition = slideIn, + popExitTransition = slideOut, + ) { + BookClubHomeScreen( + onNavigateUp = { navController.popBackStack() }, + onOpenClub = { slug -> navController.navigate(Routes.clubDetail(slug)) }, + ) + } + + composable( + route = Routes.CLUB_DETAIL, + arguments = listOf(navArgument(Routes.ARG_CLUB_SLUG) { type = NavType.StringType }), + enterTransition = slideIn, + popExitTransition = slideOut, + ) { + ClubDetailScreen(onNavigateUp = { navController.popBackStack() }) + } } } diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt b/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt index 01afe6d..690057c 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt @@ -23,6 +23,12 @@ object Routes { fun bookDetail(bookId: Int): String = "book/$bookId" const val ARG_BOOK_ID = "bookId" + // Book Club (optional plugin) + const val BOOK_CLUB = "book-club" + const val CLUB_DETAIL = "book-club/{slug}" + fun clubDetail(slug: String): String = "book-club/$slug" + const val ARG_CLUB_SLUG = "slug" + /** Graph hosting the bottom-nav + nested authed screens. */ const val MAIN_GRAPH = "main" } 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 new file mode 100644 index 0000000..c5a3144 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/bookclub/BookClubHomeScreen.kt @@ -0,0 +1,274 @@ +package com.pinakes.app.ui.screens.bookclub + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.outlined.Groups +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.pinakes.app.R +import com.pinakes.app.data.model.DashboardCard +import com.pinakes.app.data.model.DirectoryClub +import com.pinakes.app.data.model.MyClub +import com.pinakes.app.ui.common.UiState +import com.pinakes.app.ui.common.resolvedMessage +import com.pinakes.app.ui.components.EmptyState +import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.theme.Spacing + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BookClubHomeScreen( + onNavigateUp: () -> Unit, + onOpenClub: (String) -> Unit, +) { + val vm: BookClubHomeViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.book_club_title), onNavigateUp = onNavigateUp) }, + ) { padding -> + PullToRefreshBox( + isRefreshing = state.refreshing, + onRefresh = vm::refresh, + modifier = Modifier.fillMaxSize().padding(padding), + ) { + when (val content = state.content) { + is UiState.Loading -> LoadingState(label = stringResource(R.string.book_club_loading)) + is UiState.Error -> ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh) + is UiState.Success -> { + val home = content.data + if (home.isEmpty) { + EmptyState( + title = stringResource(R.string.book_club_empty_title), + subtitle = stringResource(R.string.book_club_empty_subtitle), + icon = Icons.Outlined.Groups, + ) + } else { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(Spacing.lg), + verticalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + if (home.dashboard.isNotEmpty()) { + item { SectionHeader(stringResource(R.string.book_club_section_reading)) } + items(home.dashboard, key = { "dash-${it.club.id}" }) { card -> + DashboardCardView(card = card, onClick = { onOpenClub(card.club.slug) }) + } + } + if (home.clubs.myClubs.isNotEmpty()) { + item { SectionHeader(stringResource(R.string.book_club_section_my_clubs)) } + items(home.clubs.myClubs, key = { "mine-${it.id}" }) { club -> + MyClubRow(club = club, onClick = { onOpenClub(club.slug) }) + } + } + if (home.clubs.directory.isNotEmpty()) { + item { SectionHeader(stringResource(R.string.book_club_section_discover)) } + items(home.clubs.directory, key = { "dir-${it.id}" }) { club -> + DirectoryClubRow(club = club, onClick = { onOpenClub(club.slug) }) + } + } + } + } + } + } + } + } +} + +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = Spacing.sm), + ) +} + +@Composable +private fun ClubColorDot(color: String, modifier: Modifier = Modifier) { + Box( + modifier + .size(12.dp) + .background(parseHexColor(color, MaterialTheme.colorScheme.primary), CircleShape), + ) +} + +@Composable +private fun DashboardCardView(card: DashboardCard, onClick: () -> Unit) { + Surface( + onClick = onClick, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(Spacing.lg)) { + Row(verticalAlignment = Alignment.CenterVertically) { + ClubColorDot(card.club.color) + Spacer(Modifier.width(Spacing.sm)) + Text( + card.club.name, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + card.currentBooks.forEach { book -> + Spacer(Modifier.height(Spacing.sm)) + Text( + stringResource(R.string.book_club_reading_now, book.title), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val progress = book.myProgress + if (progress != null) { + Spacer(Modifier.height(Spacing.xs)) + LinearProgressIndicator( + progress = { (progress.percent.coerceIn(0, 100)) / 100f }, + modifier = Modifier.fillMaxWidth(), + ) + Text( + stringResource(R.string.book_club_progress_percent, progress.percent), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + card.nextMeeting?.let { meeting -> + Spacer(Modifier.height(Spacing.sm)) + Text( + stringResource(R.string.book_club_next_meeting, meeting.title, clubDateTime(meeting.startsAt)), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (card.openPolls.isNotEmpty()) { + Spacer(Modifier.height(Spacing.xs)) + Text( + stringResource(R.string.book_club_open_polls, card.openPolls.size), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } +} + +@Composable +private fun MyClubRow(club: MyClub, onClick: () -> Unit) { + Surface( + onClick = onClick, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Row(Modifier.padding(Spacing.lg), verticalAlignment = Alignment.CenterVertically) { + ClubColorDot(club.color) + Spacer(Modifier.width(Spacing.md)) + Column(Modifier.weight(1f)) { + Text( + club.name, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val subtitle = if (club.isPending) stringResource(R.string.book_club_membership_pending) + else stringResource(roleLabelRes(club.role)) + Text(subtitle, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun DirectoryClubRow(club: DirectoryClub, onClick: () -> Unit) { + Surface( + onClick = onClick, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Row(Modifier.padding(Spacing.lg), verticalAlignment = Alignment.CenterVertically) { + ClubColorDot(club.color) + Spacer(Modifier.width(Spacing.md)) + Column(Modifier.weight(1f)) { + Text( + club.name, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (club.description.isNotBlank()) { + Text( + club.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + val members = if (club.maxMembers != null) { + stringResource(R.string.book_club_members_count_max, club.memberCount, club.maxMembers) + } else { + stringResource(R.string.book_club_members_count, club.memberCount) + } + Text(members, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} 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 new file mode 100644 index 0000000..3e032ec --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/bookclub/BookClubHomeViewModel.kt @@ -0,0 +1,76 @@ +package com.pinakes.app.ui.screens.bookclub + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.BookClubClubs +import com.pinakes.app.data.model.DashboardCard +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.BookClubRepository +import com.pinakes.app.ui.common.UiState +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** Landing data: the personal dashboard cards on top, then the clubs directory. */ +data class BookClubHome( + val dashboard: List = emptyList(), + val clubs: BookClubClubs = BookClubClubs(), +) { + val isEmpty: Boolean + get() = dashboard.isEmpty() && clubs.myClubs.isEmpty() && clubs.directory.isEmpty() +} + +data class BookClubHomeUiState( + val content: UiState = UiState.Loading, + val refreshing: Boolean = false, +) + +@HiltViewModel +class BookClubHomeViewModel @Inject constructor( + private val repo: BookClubRepository, +) : ViewModel() { + + private val _state = MutableStateFlow(BookClubHomeUiState()) + val state: StateFlow = _state.asStateFlow() + + init { load(initial = true) } + + fun refresh() = load(initial = false) + + private fun load(initial: Boolean) { + if (initial) _state.update { it.copy(content = UiState.Loading) } + else _state.update { it.copy(refreshing = true) } + viewModelScope.launch { + // The two reads are independent — fetch them together. + val clubsDeferred = async { repo.clubs() } + val dashboardDeferred = async { repo.dashboard() } + val clubsRes = clubsDeferred.await() + val dashboardRes = dashboardDeferred.await() + + when (clubsRes) { + is ApiResult.Success -> { + val dashboard = (dashboardRes as? ApiResult.Success)?.data?.clubs ?: emptyList() + _state.update { + it.copy( + content = UiState.Success(BookClubHome(dashboard = dashboard, clubs = clubsRes.data)), + refreshing = false, + ) + } + } + is ApiResult.Failure -> _state.update { + it.copy( + content = if (it.content is UiState.Success) it.content + else UiState.Error(clubsRes.message, clubsRes.code, R.string.book_club_error_load), + refreshing = false, + ) + } + } + } + } +} 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 new file mode 100644 index 0000000..7fe85bd --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/bookclub/BookClubUi.kt @@ -0,0 +1,71 @@ +package com.pinakes.app.ui.screens.bookclub + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.annotation.StringRes +import androidx.compose.ui.graphics.Color +import com.pinakes.app.R +import com.pinakes.app.ui.common.DateFormat +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale + +/** + * Parse a `#RRGGBB` (or `#AARRGGBB`) club/state colour into a Compose [Color]. + * Falls back to [fallback] on any malformed value so the UI never crashes on server data. + */ +fun parseHexColor(hex: String?, fallback: Color): Color { + if (hex.isNullOrBlank()) return fallback + return runCatching { Color(android.graphics.Color.parseColor(hex.trim())) }.getOrDefault(fallback) +} + +private val clubDateTimeFormatter: DateTimeFormatter = + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT).withLocale(Locale.getDefault()) + +/** + * Book Club timestamps come straight from the DB as `yyyy-MM-dd HH:mm:ss` (local wall-clock, no + * zone), unlike the core API's ISO-8601 UTC. Render those directly; delegate anything else to the + * shared [DateFormat] so ISO strings and date-only values still format correctly. + */ +fun clubDateTime(raw: String?): String { + if (raw.isNullOrBlank()) return "—" + if (raw.contains(' ') && !raw.contains('T')) { + val normalized = if (raw.length == 16) "$raw:00" else raw // "yyyy-MM-dd HH:mm" → add seconds + val parsed = runCatching { + LocalDateTime.parse(normalized.replace(' ', 'T')).format(clubDateTimeFormatter) + }.getOrNull() + if (parsed != null) return parsed + } + return DateFormat.dateTime(raw) +} + +/** Localized label for a club membership role. */ +@StringRes +fun roleLabelRes(role: String): Int = when (role) { + "owner" -> R.string.book_club_role_owner + "moderator" -> R.string.book_club_role_moderator + "guest" -> R.string.book_club_role_guest + else -> R.string.book_club_role_member +} + +/** Localized label for a club privacy setting. */ +@StringRes +fun privacyLabelRes(privacy: String): Int = when (privacy) { + "public" -> R.string.book_club_privacy_public + "private" -> R.string.book_club_privacy_private + "invite" -> R.string.book_club_privacy_invite + "hidden" -> R.string.book_club_privacy_hidden + else -> R.string.book_club_privacy_public +} + +/** 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 + runCatching { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } +} 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 new file mode 100644 index 0000000..c2634a6 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/bookclub/ClubDetailScreen.kt @@ -0,0 +1,507 @@ +package com.pinakes.app.ui.screens.bookclub + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.OpenInNew +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.pinakes.app.R +import com.pinakes.app.data.model.BookClubDetail +import com.pinakes.app.data.model.ClubBook +import com.pinakes.app.data.model.ClubMeeting +import com.pinakes.app.data.model.ClubPoll +import com.pinakes.app.ui.common.UiState +import com.pinakes.app.ui.common.resolvedMessage +import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.theme.Spacing + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ClubDetailScreen(onNavigateUp: () -> Unit) { + val vm: ClubDetailViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val context = LocalContext.current + val snackbarHost = remember { SnackbarHostState() } + + val snackbarMessage = state.snackbar ?: state.snackbarRes?.let { stringResource(it) } + LaunchedEffect(snackbarMessage) { + snackbarMessage?.let { snackbarHost.showSnackbar(it); vm.consumeSnackbar() } + } + + var progressBook by remember { mutableStateOf(null) } + + val title = (state.content as? UiState.Success)?.data?.club?.name + ?: stringResource(R.string.book_club_title) + + Scaffold( + topBar = { PinakesTopBar(title = title, onNavigateUp = onNavigateUp) }, + snackbarHost = { SnackbarHost(snackbarHost) }, + ) { padding -> + PullToRefreshBox( + isRefreshing = state.refreshing, + onRefresh = vm::refresh, + modifier = Modifier.fillMaxSize().padding(padding), + ) { + when (val content = state.content) { + is UiState.Loading -> LoadingState(label = stringResource(R.string.book_club_loading)) + is UiState.Error -> ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh) + is UiState.Success -> ClubDetailContent( + detail = content.data, + state = state, + onJoin = vm::join, + onVote = vm::vote, + onRsvp = vm::rsvp, + onUpdateProgress = { book -> progressBook = book }, + onOpenWeb = { url -> openWeb(context, url) }, + webBaseUrl = vm.webBaseUrl, + ) + } + } + } + + progressBook?.let { book -> + ProgressDialog( + book = book, + saving = state.progressBookId == book.id, + onSave = { percent, finished -> + vm.updateProgress(book.id, percent, finished) + progressBook = null + }, + onDismiss = { progressBook = null }, + ) + } +} + +@Composable +private fun ClubDetailContent( + detail: BookClubDetail, + state: ClubDetailUiState, + onJoin: () -> Unit, + onVote: (Int, List) -> Unit, + onRsvp: (Int, String) -> Unit, + onUpdateProgress: (ClubBook) -> Unit, + onOpenWeb: (String) -> Unit, + webBaseUrl: String, +) { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(Spacing.lg), + verticalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + item { ClubHeader(detail = detail, joining = state.joining, onJoin = onJoin) } + + if (detail.books.isNotEmpty()) { + item { SectionTitle(stringResource(R.string.book_club_reading_list)) } + items(detail.books, key = { "book-${it.id}" }) { book -> + ClubBookCard( + book = book, + canParticipate = detail.canParticipate, + onUpdateProgress = { onUpdateProgress(book) }, + ) + } + } + + if (detail.polls.isNotEmpty()) { + item { SectionTitle(stringResource(R.string.book_club_polls)) } + items(detail.polls, key = { "poll-${it.id}" }) { poll -> + PollCard( + poll = poll, + canParticipate = detail.canParticipate, + voting = state.votingPollId == poll.id, + onVote = { options -> onVote(poll.id, options) }, + onOpenWeb = { onOpenWeb("$webBaseUrl/book-club/${detail.club.slug}/polls/${poll.id}") }, + ) + } + } + + if (detail.meetings.isNotEmpty()) { + item { SectionTitle(stringResource(R.string.book_club_meetings)) } + items(detail.meetings, key = { "meeting-${it.id}" }) { meeting -> + MeetingCard( + meeting = meeting, + canParticipate = detail.canParticipate, + rsvping = state.rsvpMeetingId == meeting.id, + onRsvp = { response -> onRsvp(meeting.id, response) }, + onOpenVideo = { onOpenWeb(meeting.videoUrl) }, + ) + } + } + } +} + +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = Spacing.sm), + ) +} + +@Composable +private fun ClubHeader(detail: BookClubDetail, joining: Boolean, onJoin: () -> Unit) { + val club = detail.club + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(Spacing.lg)) { + Text(club.name, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface) + Spacer(Modifier.height(Spacing.xs)) + val members = if (club.maxMembers != null) { + stringResource(R.string.book_club_members_count_max, club.memberCount, club.maxMembers) + } else { + stringResource(R.string.book_club_members_count, club.memberCount) + } + Text( + "${stringResource(privacyLabelRes(club.privacy))} · $members", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (club.description.isNotBlank()) { + Spacer(Modifier.height(Spacing.sm)) + Text(club.description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (detail.isActiveMember && club.rules.isNotBlank()) { + Spacer(Modifier.height(Spacing.sm)) + Text(stringResource(R.string.book_club_rules), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface) + Text(club.rules, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + + Spacer(Modifier.height(Spacing.md)) + when { + detail.canJoin -> PrimaryButton( + label = stringResource(R.string.book_club_join), + onClick = onJoin, + loading = joining, + modifier = Modifier.fillMaxWidth(), + ) + detail.myMembership?.status == "pending" -> + StatusPill(stringResource(R.string.book_club_membership_pending)) + detail.isGuest -> + StatusPill(stringResource(R.string.book_club_guest_readonly)) + detail.isActiveMember -> + StatusPill(stringResource(R.string.book_club_membership_member, stringResource(roleLabelRes(detail.myMembership?.role ?: "member")))) + } + } + } +} + +@Composable +private fun StatusPill(text: String) { + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Text( + text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = Spacing.md, vertical = Spacing.xs), + ) + } +} + +@Composable +private fun ClubBookCard(book: ClubBook, canParticipate: Boolean, onUpdateProgress: () -> Unit) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(Spacing.lg)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(book.title, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis) + if (book.authors.isNotBlank()) { + Text(book.authors, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + Spacer(Modifier.width(Spacing.sm)) + StateChip(label = book.stateLabel, colorHex = book.stateColor) + } + if (book.motivation.isNotBlank()) { + Spacer(Modifier.height(Spacing.xs)) + Text("“${book.motivation}”", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 3, overflow = TextOverflow.Ellipsis) + } + book.myProgress?.let { progress -> + Spacer(Modifier.height(Spacing.sm)) + LinearProgressIndicator(progress = { progress.percent.coerceIn(0, 100) / 100f }, modifier = Modifier.fillMaxWidth()) + Text( + if (progress.finished) stringResource(R.string.book_club_progress_finished) + else stringResource(R.string.book_club_progress_percent, progress.percent), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (canParticipate && book.isCurrent) { + Spacer(Modifier.height(Spacing.sm)) + SecondaryButton( + label = stringResource(R.string.book_club_update_progress), + onClick = onUpdateProgress, + ) + } + } + } +} + +@Composable +private fun PollCard( + poll: ClubPoll, + canParticipate: Boolean, + voting: Boolean, + onVote: (List) -> Unit, + onOpenWeb: () -> Unit, +) { + // Selection is seeded from the user's existing ballot and resets when the poll reloads. + var selected by remember(poll.id, poll.myOptionIds) { mutableStateOf(poll.myOptionIds.toSet()) } + val interactive = canParticipate && poll.isOpen && poll.votableInApp + + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(Spacing.lg)) { + Text(poll.title, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface) + val meta = buildList { + add(stringResource(if (poll.isOpen) R.string.book_club_poll_open else R.string.book_club_poll_closed)) + poll.closesAt?.let { add(stringResource(R.string.book_club_poll_closes, clubDateTime(it))) } + add(stringResource(R.string.book_club_poll_voters, poll.voterCount)) + }.joinToString(" · ") + Text(meta, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (interactive && poll.maxChoices > 1) { + Text( + stringResource(R.string.book_club_poll_choose_up_to, poll.maxChoices), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(Spacing.sm)) + poll.options.forEach { option -> + val isSelected = option.id in selected + Row( + Modifier + .fillMaxWidth() + .then( + if (interactive) Modifier.clickable { + selected = toggleSelection(selected, option.id, poll.maxChoices) + } else Modifier + ) + .padding(vertical = Spacing.xs), + verticalAlignment = Alignment.CenterVertically, + ) { + if (interactive) { + if (poll.maxChoices == 1) { + RadioButton(selected = isSelected, onClick = { selected = setOf(option.id) }) + } else { + Checkbox(checked = isSelected, onCheckedChange = { selected = toggleSelection(selected, option.id, poll.maxChoices) }) + } + Spacer(Modifier.width(Spacing.sm)) + } + Text(option.title, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f), maxLines = 2, overflow = TextOverflow.Ellipsis) + if (option.id in poll.myOptionIds) { + Text(stringResource(R.string.book_club_poll_your_vote), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary) + } + } + } + + if (interactive) { + Spacer(Modifier.height(Spacing.sm)) + PrimaryButton( + label = stringResource(R.string.book_club_vote), + onClick = { onVote(selected.toList()) }, + enabled = selected.isNotEmpty() && !voting, + loading = voting, + modifier = Modifier.fillMaxWidth(), + ) + } else if (canParticipate && poll.isOpen && !poll.votableInApp) { + // Advanced ballots (stars/ranking/elimination) are web-only per the API contract. + Spacer(Modifier.height(Spacing.sm)) + SecondaryButton( + label = stringResource(R.string.book_club_vote_on_web), + onClick = onOpenWeb, + leadingIcon = Icons.Outlined.OpenInNew, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} + +@Composable +private fun MeetingCard( + meeting: ClubMeeting, + canParticipate: Boolean, + rsvping: Boolean, + onRsvp: (String) -> Unit, + onOpenVideo: () -> Unit, +) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(Spacing.lg)) { + Text(meeting.title, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface) + Text(clubDateTime(meeting.startsAt), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (meeting.location.isNotBlank()) { + Text(meeting.location, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (meeting.agenda.isNotBlank()) { + Spacer(Modifier.height(Spacing.xs)) + Text(meeting.agenda, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 3, overflow = TextOverflow.Ellipsis) + } + val seatsLabel = meeting.seats?.let { stringResource(R.string.book_club_meeting_seats, meeting.yesCount, it) } + ?: stringResource(R.string.book_club_meeting_yes, meeting.yesCount) + Spacer(Modifier.height(Spacing.xs)) + Text(seatsLabel, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + + if (meeting.videoUrl.isNotBlank()) { + Spacer(Modifier.height(Spacing.xs)) + AssistChip( + onClick = onOpenVideo, + label = { Text(stringResource(R.string.book_club_meeting_join_online)) }, + leadingIcon = { Icon(Icons.Outlined.OpenInNew, contentDescription = null, modifier = Modifier.width(18.dp)) }, + ) + } + + if (canParticipate && meeting.status == "scheduled") { + Spacer(Modifier.height(Spacing.sm)) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + RsvpChip(stringResource(R.string.book_club_rsvp_yes), meeting.myRsvp == "yes", rsvping) { onRsvp("yes") } + RsvpChip(stringResource(R.string.book_club_rsvp_maybe), meeting.myRsvp == "maybe", rsvping) { onRsvp("maybe") } + RsvpChip(stringResource(R.string.book_club_rsvp_no), meeting.myRsvp == "no", rsvping) { onRsvp("no") } + } + } + } + } +} + +@Composable +private fun RsvpChip(label: String, selected: Boolean, disabled: Boolean, onClick: () -> Unit) { + FilterChip( + selected = selected, + onClick = onClick, + enabled = !disabled, + label = { Text(label) }, + ) +} + +@Composable +private fun StateChip(label: String, colorHex: String) { + // Soft-tint chip: the state's colour as text on a low-alpha wash of the same colour. + val base = parseHexColor(colorHex, MaterialTheme.colorScheme.secondary) + Box( + Modifier + .background(base.copy(alpha = 0.18f), RoundedCornerShape(50)) + .padding(horizontal = Spacing.sm, vertical = 2.dp), + ) { + Text(label.ifBlank { "—" }, style = MaterialTheme.typography.labelSmall, color = base, fontWeight = FontWeight.Medium) + } +} + +@Composable +private fun ProgressDialog( + book: ClubBook, + saving: Boolean, + onSave: (Int, Boolean) -> Unit, + onDismiss: () -> Unit, +) { + var percent by remember { mutableFloatStateOf((book.myProgress?.percent ?: 0).toFloat()) } + var finished by remember { mutableStateOf(book.myProgress?.finished ?: false) } + + AlertDialog( + onDismissRequest = onDismiss, + shape = MaterialTheme.shapes.large, + title = { Text(stringResource(R.string.book_club_update_progress), style = MaterialTheme.typography.titleMedium) }, + text = { + Column { + Text(book.title, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis) + Spacer(Modifier.height(Spacing.md)) + Text(stringResource(R.string.book_club_progress_percent, percent.toInt()), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface) + Slider( + value = percent, + onValueChange = { percent = it; if (it >= 100f) finished = true }, + valueRange = 0f..100f, + steps = 19, + ) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { finished = !finished }) { + Checkbox(checked = finished, onCheckedChange = { finished = it; if (it) percent = 100f }) + Spacer(Modifier.width(Spacing.sm)) + Text(stringResource(R.string.book_club_progress_mark_finished), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface) + } + } + }, + confirmButton = { + PrimaryButton( + label = stringResource(R.string.action_save), + onClick = { onSave(percent.toInt(), finished) }, + loading = saving, + ) + }, + dismissButton = { + com.pinakes.app.ui.components.PinakesTextButton(label = stringResource(R.string.action_cancel), onClick = onDismiss) + }, + ) +} + +/** Toggle option [id] within [current], respecting [max] (single-choice replaces; multi caps). */ +private fun toggleSelection(current: Set, id: Int, max: Int): Set = when { + max <= 1 -> setOf(id) + id in current -> current - id + current.size < max -> current + id + else -> current // at cap: ignore extra picks +} 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 new file mode 100644 index 0000000..ad8b537 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/bookclub/ClubDetailViewModel.kt @@ -0,0 +1,136 @@ +package com.pinakes.app.ui.screens.bookclub + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.BookClubDetail +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.BookClubRepository +import com.pinakes.app.ui.common.UiState +import com.pinakes.app.ui.navigation.Routes +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class ClubDetailUiState( + val content: UiState = UiState.Loading, + val refreshing: Boolean = false, + val joining: Boolean = false, + val votingPollId: Int? = null, + val rsvpMeetingId: Int? = null, + val progressBookId: Int? = null, + val snackbar: String? = null, + val snackbarRes: Int? = null, +) + +@HiltViewModel +class ClubDetailViewModel @Inject constructor( + private val repo: BookClubRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + val slug: String = savedStateHandle.get(Routes.ARG_CLUB_SLUG).orEmpty() + + private val _state = MutableStateFlow(ClubDetailUiState()) + val state: StateFlow = _state.asStateFlow() + + /** Instance web origin, for deep-linking the flows the API marks web-only. */ + val webBaseUrl: String get() = repo.webBaseUrl() + + init { load(initial = true) } + + fun refresh() = load(initial = false) + + private fun load(initial: Boolean) { + if (initial) _state.update { it.copy(content = UiState.Loading) } + else _state.update { it.copy(refreshing = true) } + viewModelScope.launch { + when (val res = repo.clubDetail(slug)) { + is ApiResult.Success -> _state.update { + it.copy(content = UiState.Success(res.data), refreshing = false) + } + is ApiResult.Failure -> _state.update { + it.copy( + content = if (it.content is UiState.Success) it.content + else UiState.Error(res.message, res.code, R.string.book_club_error_load), + refreshing = false, + ) + } + } + } + } + + fun join() { + if (_state.value.joining) return + _state.update { it.copy(joining = true) } + viewModelScope.launch { + when (val res = repo.join(slug)) { + 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) } + load(initial = false) + } + is ApiResult.Failure -> _state.update { it.copy(joining = false).withError(res) } + } + } + } + + 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) } + 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) } + load(initial = false) + } + is ApiResult.Failure -> _state.update { it.copy(votingPollId = null).withError(res) } + } + } + } + + fun rsvp(meetingId: Int, response: String) { + if (_state.value.rsvpMeetingId != null) return + _state.update { it.copy(rsvpMeetingId = meetingId) } + 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) } + load(initial = false) + } + is ApiResult.Failure -> _state.update { it.copy(rsvpMeetingId = null).withError(res) } + } + } + } + + fun updateProgress(clubBookId: Int, percent: Int, finished: Boolean) { + if (_state.value.progressBookId != null) return + _state.update { it.copy(progressBookId = clubBookId) } + 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) } + load(initial = false) + } + is ApiResult.Failure -> _state.update { it.copy(progressBookId = null).withError(res) } + } + } + } + + fun consumeSnackbar() = _state.update { it.copy(snackbar = null, snackbarRes = null) } + + /** 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) +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt index 4489714..d59f3f5 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.material.icons.outlined.ChatBubbleOutline import androidx.compose.material.icons.outlined.DevicesOther import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.Groups import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Notifications @@ -75,6 +76,8 @@ fun ProfileScreen( onOpenNotifications: () -> Unit, onOpenContact: () -> Unit, onOpenMyReviews: () -> Unit, + onOpenBookClub: () -> Unit, + showBookClub: Boolean, ) { val app: AppViewModel = hiltViewModel() val vm: ProfileViewModel = hiltViewModel() @@ -103,9 +106,11 @@ fun ProfileScreen( onOpenNotifications = onOpenNotifications, onOpenContact = onOpenContact, onOpenMyReviews = onOpenMyReviews, + onOpenBookClub = onOpenBookClub, showNotifications = features.notifications, showContact = features.messages, showReviews = features.showReviews, + showBookClub = showBookClub, ) } } @@ -151,9 +156,11 @@ private fun ProfileContent( onOpenNotifications: () -> Unit, onOpenContact: () -> Unit, onOpenMyReviews: () -> Unit, + onOpenBookClub: () -> Unit, showNotifications: Boolean, showContact: Boolean, showReviews: Boolean, + showBookClub: Boolean, ) { Column( Modifier @@ -204,6 +211,9 @@ private fun ProfileContent( if (showReviews) { ActionRow(Icons.Outlined.RateReview, stringResource(R.string.profile_action_my_reviews), onClick = onOpenMyReviews) } + if (showBookClub) { + ActionRow(Icons.Outlined.Groups, stringResource(R.string.profile_action_book_club), onClick = onOpenBookClub) + } if (showNotifications) { ActionRow(Icons.Outlined.Notifications, stringResource(R.string.profile_action_notifications), onClick = onOpenNotifications) } diff --git a/i18n/de.json b/i18n/de.json index 7b0bf2b..bbe544c 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -340,5 +340,60 @@ "profile_action_my_reviews": "Meine Rezensionen", "my_reviews_empty_title": "Noch keine Rezensionen", "my_reviews_empty_subtitle": "Rezensionen, die du zu Büchern hinterlässt, erscheinen hier.", - "action_load_more": "Mehr laden" + "action_load_more": "Mehr laden", + "profile_action_book_club": "Lesekreis", + "book_club_title": "Lesekreis", + "book_club_loading": "Clubs werden geladen…", + "book_club_error_load": "Die Lesekreise konnten nicht geladen werden.", + "book_club_empty_title": "Noch keine Lesekreise", + "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", + "book_club_section_discover": "Clubs entdecken", + "book_club_reading_now": "Aktuell: %1$s", + "book_club_progress_percent": "%1$d%% gelesen", + "book_club_progress_finished": "Abgeschlossen", + "book_club_next_meeting": "Nächstes: %1$s · %2$s", + "book_club_open_polls": "%1$d offene Abstimmung(en)", + "book_club_members_count": "%1$d Mitglieder", + "book_club_members_count_max": "%1$d / %2$d Mitglieder", + "book_club_membership_pending": "Anfrage ausstehend", + "book_club_membership_member": "Mitglied · %1$s", + "book_club_guest_readonly": "Gast · nur Lesen", + "book_club_reading_list": "Leseliste", + "book_club_polls": "Abstimmungen", + "book_club_meetings": "Treffen", + "book_club_rules": "Clubregeln", + "book_club_join": "Diesem Club beitreten", + "book_club_join_active": "Du bist dem Club beigetreten", + "book_club_join_pending": "Anfrage gesendet — wartet auf Freigabe", + "book_club_update_progress": "Fortschritt aktualisieren", + "book_club_progress_mark_finished": "Ich habe dieses Buch beendet", + "book_club_progress_saved": "Fortschritt gespeichert", + "book_club_poll_open": "Offen", + "book_club_poll_closed": "Geschlossen", + "book_club_poll_closes": "endet am %1$s", + "book_club_poll_voters": "%1$d Abstimmende", + "book_club_poll_choose_up_to": "Wähle bis zu %1$d", + "book_club_poll_your_vote": "Deine Stimme", + "book_club_vote": "Abstimmen", + "book_club_vote_on_web": "Im Web abstimmen", + "book_club_vote_empty": "Wähle mindestens eine Option.", + "book_club_vote_saved": "Stimme gespeichert", + "book_club_meeting_seats": "%1$d von %2$d Plätzen belegt", + "book_club_meeting_yes": "%1$d nehmen teil", + "book_club_meeting_join_online": "Online teilnehmen", + "book_club_rsvp_yes": "Dabei", + "book_club_rsvp_maybe": "Vielleicht", + "book_club_rsvp_no": "Nicht dabei", + "book_club_rsvp_saved": "Antwort gespeichert", + "book_club_action_error": "Etwas ist schiefgelaufen. Bitte versuche es erneut.", + "book_club_role_owner": "Gründer", + "book_club_role_moderator": "Moderator", + "book_club_role_member": "Mitglied", + "book_club_role_guest": "Gast", + "book_club_privacy_public": "Öffentlich", + "book_club_privacy_private": "Privat", + "book_club_privacy_invite": "Nur mit Einladung", + "book_club_privacy_hidden": "Verborgen" } diff --git a/i18n/en.json b/i18n/en.json index 1e5d331..8e5ce19 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -340,5 +340,60 @@ "profile_action_my_reviews": "My reviews", "my_reviews_empty_title": "No reviews yet", "my_reviews_empty_subtitle": "Reviews you leave on books will appear here.", - "action_load_more": "Load more" + "action_load_more": "Load more", + "profile_action_book_club": "Book Club", + "book_club_title": "Book Club", + "book_club_loading": "Loading clubs…", + "book_club_error_load": "Couldn't load the book clubs.", + "book_club_empty_title": "No book clubs yet", + "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", + "book_club_section_discover": "Discover clubs", + "book_club_reading_now": "Reading: %1$s", + "book_club_progress_percent": "%1$d%% read", + "book_club_progress_finished": "Finished", + "book_club_next_meeting": "Next: %1$s · %2$s", + "book_club_open_polls": "%1$d open poll(s)", + "book_club_members_count": "%1$d members", + "book_club_members_count_max": "%1$d / %2$d members", + "book_club_membership_pending": "Request pending", + "book_club_membership_member": "Member · %1$s", + "book_club_guest_readonly": "Guest · read-only", + "book_club_reading_list": "Reading list", + "book_club_polls": "Polls", + "book_club_meetings": "Meetings", + "book_club_rules": "Club rules", + "book_club_join": "Join this club", + "book_club_join_active": "You've joined the club", + "book_club_join_pending": "Request sent — awaiting approval", + "book_club_update_progress": "Update progress", + "book_club_progress_mark_finished": "I've finished this book", + "book_club_progress_saved": "Progress saved", + "book_club_poll_open": "Open", + "book_club_poll_closed": "Closed", + "book_club_poll_closes": "closes %1$s", + "book_club_poll_voters": "%1$d voters", + "book_club_poll_choose_up_to": "Choose up to %1$d", + "book_club_poll_your_vote": "Your vote", + "book_club_vote": "Vote", + "book_club_vote_on_web": "Vote on the web", + "book_club_vote_empty": "Select at least one option.", + "book_club_vote_saved": "Vote recorded", + "book_club_meeting_seats": "%1$d of %2$d seats taken", + "book_club_meeting_yes": "%1$d attending", + "book_club_meeting_join_online": "Join online", + "book_club_rsvp_yes": "Going", + "book_club_rsvp_maybe": "Maybe", + "book_club_rsvp_no": "Can't go", + "book_club_rsvp_saved": "RSVP saved", + "book_club_action_error": "Something went wrong. Please try again.", + "book_club_role_owner": "Founder", + "book_club_role_moderator": "Moderator", + "book_club_role_member": "Member", + "book_club_role_guest": "Guest", + "book_club_privacy_public": "Public", + "book_club_privacy_private": "Private", + "book_club_privacy_invite": "Invite only", + "book_club_privacy_hidden": "Hidden" } diff --git a/i18n/fr.json b/i18n/fr.json index df83e2a..ed426b6 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -340,5 +340,60 @@ "profile_action_my_reviews": "Mes avis", "my_reviews_empty_title": "Aucun avis", "my_reviews_empty_subtitle": "Les avis que vous laissez sur les livres apparaîtront ici.", - "action_load_more": "Charger plus" + "action_load_more": "Charger plus", + "profile_action_book_club": "Club de lecture", + "book_club_title": "Club de lecture", + "book_club_loading": "Chargement des clubs…", + "book_club_error_load": "Impossible de charger les clubs de lecture.", + "book_club_empty_title": "Aucun club de lecture", + "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", + "book_club_section_discover": "Découvrir des clubs", + "book_club_reading_now": "En lecture : %1$s", + "book_club_progress_percent": "%1$d%% lu", + "book_club_progress_finished": "Terminé", + "book_club_next_meeting": "Prochain : %1$s · %2$s", + "book_club_open_polls": "%1$d vote(s) en cours", + "book_club_members_count": "%1$d membres", + "book_club_members_count_max": "%1$d / %2$d membres", + "book_club_membership_pending": "Demande en attente", + "book_club_membership_member": "Membre · %1$s", + "book_club_guest_readonly": "Invité · lecture seule", + "book_club_reading_list": "Liste de lecture", + "book_club_polls": "Votes", + "book_club_meetings": "Rencontres", + "book_club_rules": "Règlement du club", + "book_club_join": "Rejoindre ce club", + "book_club_join_active": "Vous avez rejoint le club", + "book_club_join_pending": "Demande envoyée — en attente d'approbation", + "book_club_update_progress": "Mettre à jour la progression", + "book_club_progress_mark_finished": "J'ai terminé ce livre", + "book_club_progress_saved": "Progression enregistrée", + "book_club_poll_open": "Ouvert", + "book_club_poll_closed": "Fermé", + "book_club_poll_closes": "clôture le %1$s", + "book_club_poll_voters": "%1$d votants", + "book_club_poll_choose_up_to": "Choisissez jusqu'à %1$d", + "book_club_poll_your_vote": "Votre vote", + "book_club_vote": "Voter", + "book_club_vote_on_web": "Voter sur le site", + "book_club_vote_empty": "Sélectionnez au moins une option.", + "book_club_vote_saved": "Vote enregistré", + "book_club_meeting_seats": "%1$d des %2$d places prises", + "book_club_meeting_yes": "%1$d participants", + "book_club_meeting_join_online": "Rejoindre en ligne", + "book_club_rsvp_yes": "Présent", + "book_club_rsvp_maybe": "Peut-être", + "book_club_rsvp_no": "Absent", + "book_club_rsvp_saved": "Réponse enregistrée", + "book_club_action_error": "Une erreur s'est produite. Veuillez réessayer.", + "book_club_role_owner": "Fondateur", + "book_club_role_moderator": "Modérateur", + "book_club_role_member": "Membre", + "book_club_role_guest": "Invité", + "book_club_privacy_public": "Public", + "book_club_privacy_private": "Privé", + "book_club_privacy_invite": "Sur invitation", + "book_club_privacy_hidden": "Masqué" } diff --git a/i18n/it.json b/i18n/it.json index b8fcc4b..977b8bc 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -340,5 +340,60 @@ "profile_action_my_reviews": "Le mie recensioni", "my_reviews_empty_title": "Nessuna recensione", "my_reviews_empty_subtitle": "Le recensioni che lasci sui libri appariranno qui.", - "action_load_more": "Carica altri" + "action_load_more": "Carica altri", + "profile_action_book_club": "Gruppo di lettura", + "book_club_title": "Gruppo di lettura", + "book_club_loading": "Caricamento dei club…", + "book_club_error_load": "Impossibile caricare i gruppi di lettura.", + "book_club_empty_title": "Ancora nessun gruppo di lettura", + "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", + "book_club_section_discover": "Scopri i club", + "book_club_reading_now": "In lettura: %1$s", + "book_club_progress_percent": "%1$d%% letto", + "book_club_progress_finished": "Completato", + "book_club_next_meeting": "Prossimo: %1$s · %2$s", + "book_club_open_polls": "%1$d votazioni aperte", + "book_club_members_count": "%1$d membri", + "book_club_members_count_max": "%1$d / %2$d membri", + "book_club_membership_pending": "Richiesta in attesa", + "book_club_membership_member": "Membro · %1$s", + "book_club_guest_readonly": "Ospite · sola lettura", + "book_club_reading_list": "Elenco di lettura", + "book_club_polls": "Votazioni", + "book_club_meetings": "Incontri", + "book_club_rules": "Regolamento del club", + "book_club_join": "Unisciti al club", + "book_club_join_active": "Ti sei unito al club", + "book_club_join_pending": "Richiesta inviata — in attesa di approvazione", + "book_club_update_progress": "Aggiorna il progresso", + "book_club_progress_mark_finished": "Ho finito questo libro", + "book_club_progress_saved": "Progresso salvato", + "book_club_poll_open": "Aperta", + "book_club_poll_closed": "Chiusa", + "book_club_poll_closes": "chiude il %1$s", + "book_club_poll_voters": "%1$d votanti", + "book_club_poll_choose_up_to": "Scegli fino a %1$d", + "book_club_poll_your_vote": "Il tuo voto", + "book_club_vote": "Vota", + "book_club_vote_on_web": "Vota sul sito", + "book_club_vote_empty": "Seleziona almeno un'opzione.", + "book_club_vote_saved": "Voto registrato", + "book_club_meeting_seats": "%1$d di %2$d posti occupati", + "book_club_meeting_yes": "%1$d partecipanti", + "book_club_meeting_join_online": "Partecipa online", + "book_club_rsvp_yes": "Ci sarò", + "book_club_rsvp_maybe": "Forse", + "book_club_rsvp_no": "Non posso", + "book_club_rsvp_saved": "Risposta salvata", + "book_club_action_error": "Qualcosa è andato storto. Riprova.", + "book_club_role_owner": "Fondatore", + "book_club_role_moderator": "Moderatore", + "book_club_role_member": "Membro", + "book_club_role_guest": "Ospite", + "book_club_privacy_public": "Pubblico", + "book_club_privacy_private": "Privato", + "book_club_privacy_invite": "Solo su invito", + "book_club_privacy_hidden": "Nascosto" } From f3d6e10285de010f48026c9b3eec7df05281977d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 10:45:25 +0000 Subject: [PATCH 2/4] chore: gitignore the Kotlin compiler session directory (.kotlin/) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 7b0f010..c701177 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ app/src/main/res/values*/strings.xml # Sentry auth token (mapping upload) — secret, never commit sentry.properties + +# Kotlin compiler session data (created by local builds) +.kotlin/ From 87700df7371d831694eab25db7d855af7d0c6612 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 11:17:14 +0000 Subject: [PATCH 3/4] fix: apply the full PHP-compatibility review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the app against the live Book Club + mobile-api PHP sources (10 confirmed findings, all fixed): Compatibility with the PHP API: - canJoin now mirrors the server's rejoin rules: members with status 'left'/'suspended' see the Join button again (server re-admits them; only 'banned' is blocked) instead of a dead-end header. - Polls whose closes_at has passed render as Closed: the mobile API never lazy-closes expired polls, so the old ballot could only fail with 409 poll_closed. - The 'Going' RSVP chip disables when a meeting is full (yes_count >= seats) unless the user is already going, mirroring the server's 409 no_seats rule; maybe/no stay enabled. - DateFormat now parses raw MySQL DATETIME (Y-m-d H:i:s): review cards, the device list and book club dates no longer show the raw DB string. - ErrorCodes.APP_DISABLED ('app_disabled') matched nothing the server emits; replaced with APP_ACCESS_DISABLED ('app_access_disabled'). - The Book Club section also hides when /health reports app_access_enabled=false (the plugin's public health answers 200 regardless of the gate). Correctness: - Home 'Available now' shelf: restored the server-side available=true query; the cached unfiltered first page is only the offline fallback, so availability beyond the newest 40 titles shows again. - forgetInstance() purges the Room catalog cache and the in-memory ETag cache — library A's titles/covers can no longer surface under library B. - A late availability probe can no longer resurrect the Book Club flag after 'change library': probe results are guarded by instance URL. - A Book Club dashboard fetch failure now surfaces a snackbar instead of silently dropping the 'Your reading' section. Efficiency & structure: - Core /health and the plugin probe run concurrently (was serial — it doubled the post-login spinner and every startup refresh). - Book Club availability folded into FeatureStore/InstanceFeatures (drops the fourth EncryptedSharedPreferences file and its Keystore cost). - bookClubCall reuses the core error pipeline (parseErrorBody, status fallbacks, Retry-After) — also fixes blank messages on bodiless errors; BookClubError replaced by the shared ApiError; NO_AUTH deduplicated. - Dead response DTOs removed (VoteResult/RsvpResult/ProgressResult/ BookClubHealth → Envelope); web deep-link URL built in the repository instead of string-concatenation in the composable. Verified: testDebugUnitTest, lintDebug, assembleDebug and assembleRelease (R8) all BUILD SUCCESSFUL. --- STATUS.md | 42 ++++++++++++-- .../pinakes/app/data/model/BookClubModels.kt | 57 ++++++------------- .../com/pinakes/app/data/network/ApiResult.kt | 5 +- .../pinakes/app/data/network/BookClubApi.kt | 21 +++---- .../app/data/network/BookClubResult.kt | 44 ++------------ .../app/data/repository/AuthRepository.kt | 38 +++++++++---- .../app/data/repository/BookClubRepository.kt | 53 +++++++++-------- .../app/data/repository/CatalogRepository.kt | 10 ++++ .../pinakes/app/data/store/BookClubStore.kt | 52 ----------------- .../pinakes/app/data/store/FeatureStore.kt | 21 ++++++- .../app/data/sync/CatalogSyncWorker.kt | 2 +- .../main/java/com/pinakes/app/di/AppModule.kt | 11 ++-- .../com/pinakes/app/ui/common/AppViewModel.kt | 6 +- .../com/pinakes/app/ui/common/DateFormat.kt | 47 ++++++++++----- .../pinakes/app/ui/navigation/MainScaffold.kt | 2 - .../ui/screens/bookclub/BookClubHomeScreen.kt | 14 ++++- .../screens/bookclub/BookClubHomeViewModel.kt | 10 ++++ .../app/ui/screens/bookclub/BookClubUi.kt | 25 -------- .../ui/screens/bookclub/ClubDetailScreen.kt | 36 +++++++----- .../screens/bookclub/ClubDetailViewModel.kt | 4 +- .../app/ui/screens/home/HomeViewModel.kt | 39 ++++++++++--- .../app/ui/screens/login/LoginViewModel.kt | 8 ++- .../app/ui/screens/profile/ProfileScreen.kt | 3 +- .../com/pinakes/app/CatalogSyncWorkerTest.kt | 2 +- i18n/de.json | 3 +- i18n/en.json | 3 +- i18n/fr.json | 3 +- i18n/it.json | 3 +- 28 files changed, 291 insertions(+), 273 deletions(-) delete mode 100644 app/src/main/java/com/pinakes/app/data/store/BookClubStore.kt diff --git a/STATUS.md b/STATUS.md index ba2a998..ca5dff3 100644 --- a/STATUS.md +++ b/STATUS.md @@ -90,10 +90,12 @@ active for the instance (a 404 hides it). The flag is cached in an encrypted sto refreshed alongside `/health`, so the entry never flickers and a first-run/offline probe keeps it hidden — same "confirm before showing" rule as public registration. -- **Data layer** — `BookClubApi` (Retrofit) + `BookClubRepository` + `BookClubStore`, all - reusing the same base URL and bearer token as the core client. The plugin uses a +- **Data layer** — `BookClubApi` (Retrofit) + `BookClubRepository`, reusing the same base URL + and bearer token as the core client; the availability flag lives in `FeatureStore` + (`InstanceFeatures.bookClubAvailable`) next to the other instance gates. The plugin uses a **different envelope** (`{success, data, error}` vs the core `{data, meta, error}`), handled - by a dedicated `bookClubCall` that maps into the shared `ApiResult`/`ErrorCodes`. + by a dedicated `bookClubCall` that reuses the core error pipeline (`parseErrorBody`, + status fallbacks, `Retry-After`) and the shared `ApiResult`/`ErrorCodes`. - **Screens** — a **Book Club home** (your reading dashboard, your clubs, discover directory, reached from Profile) and a **club detail** (reading list with state chips + progress, polls, meetings). Actions wired end-to-end: **join**, **vote** (simple / multi / weighted, @@ -101,8 +103,38 @@ keeps it hidden — same "confirm before showing" rule as public registration. **reading progress**. Guests are read-only. Advanced poll modes and proposing a title deep-link to the web page (per the API contract). - **i18n** — all new strings added to the 4 locale JSONs (it/en/fr/de), in parity. -- **Build** — `assembleDebug`, `lintDebug` and `assembleRelease` (R8/minify — exercises the - keep rules for the new `@Serializable` models) all **BUILD SUCCESSFUL**. +- **Build** — `testDebugUnitTest`, `assembleDebug`, `lintDebug` and `assembleRelease` + (R8/minify — exercises the keep rules for the new `@Serializable` models) all + **BUILD SUCCESSFUL**. + +### PHP-compatibility review (verified against the live plugin sources) + +A full adversarial review against the Book Club plugin + mobile-api PHP sources confirmed +the wire contract (paths, regexes, casts, nullability, envelopes, error codes) and fixed +every confirmed finding: + +- **Rejoin parity**: `canJoin` now mirrors the server — members with status `left`/`suspended` + can re-join (the web shows the join form for them; only `banned` is blocked). +- **Expired polls**: the mobile API never lazy-closes polls (only the web page/cron do), so a + past `closes_at` now renders as *Closed* instead of a ballot that can only 409 `poll_closed`. +- **Full meetings**: the *Going* chip disables when `yes_count >= seats` (unless already + going), mirroring the server's 409 `no_seats` rule; *maybe*/*no* stay enabled. +- **MySQL DATETIME timestamps**: the server emits raw `Y-m-d H:i:s` for reviews, devices and + book club dates — `DateFormat` now parses wall-clock values, fixing raw strings that + previously rendered verbatim on review cards and the device list. +- **Home "Available now" shelf**: restored the server-side `available=true` query (the cached + first page is only the offline fallback), so availability beyond the newest 40 titles shows + again. +- **Instance switch hygiene**: `forgetInstance()` now purges the Room catalog cache + ETag + cache (no cross-library leak), and a late availability probe can no longer resurrect the + Book Club flag after switching (probe results are guarded by instance URL). +- **App-access gate**: the Book Club section also hides when core `/health` reports + `app_access_enabled=false` (the plugin's public health answers 200 regardless). +- **Startup/login latency**: the core health call and the plugin probe now run concurrently. +- **Error codes**: `ErrorCodes` now matches the server's real `app_access_disabled` (the old + `app_disabled` constant matched nothing the server emits). +- **Partial failures**: a dashboard fetch failure on the Book Club home now surfaces a + snackbar instead of silently dropping the "Your reading" section. ## Partial / TODO diff --git a/app/src/main/java/com/pinakes/app/data/model/BookClubModels.kt b/app/src/main/java/com/pinakes/app/data/model/BookClubModels.kt index 53bff8e..5db1b9d 100644 --- a/app/src/main/java/com/pinakes/app/data/model/BookClubModels.kt +++ b/app/src/main/java/com/pinakes/app/data/model/BookClubModels.kt @@ -18,24 +18,8 @@ import kotlinx.serialization.Serializable data class BookClubEnvelope( val success: Boolean = false, val data: T? = null, - val error: BookClubError? = null, -) - -@Serializable -data class BookClubError( - val code: String = "unknown", - val message: String = "", -) - -// ---------- Discovery ---------- -// GET /bookclub/health (no token). A 2xx means the plugin + mobile module are active. -@Serializable -data class BookClubHealth( - val plugin: String = "", - val enabled: Boolean = false, - val version: String = "", - val requires: List = emptyList(), - val endpoints: List = emptyList(), + // Same {code, message} shape as the core envelope's error — reuse the shared model. + val error: ApiError? = null, ) // ---------- Clubs list ---------- @@ -69,9 +53,7 @@ data class DirectoryClub( val privacy: String = "", @SerialName("member_count") val memberCount: Int = 0, @SerialName("max_members") val maxMembers: Int? = null, -) { - val isFull: Boolean get() = maxMembers != null && memberCount >= maxMembers -} +) // ---------- Club detail ---------- // GET /bookclub/clubs/{slug} @@ -89,8 +71,18 @@ data class BookClubDetail( val isGuest: Boolean get() = myMembership?.role == "guest" /** Can take part (vote, RSVP, track progress): active member that is not a guest. */ val canParticipate: Boolean get() = isActiveMember && !isGuest + + /** + * Mirrors the server's join rules: the membership row survives leaving, so 'left' + * (and 'suspended') members can re-join exactly like on the web — the join endpoint + * only short-circuits active/pending and rejects banned. + */ val canJoin: Boolean - get() = myMembership == null && (club.privacy == "public" || club.privacy == "private") + get() { + val status = myMembership?.status + val joinable = status == null || status !in listOf("active", "pending", "banned") + return joinable && (club.privacy == "public" || club.privacy == "private") + } } @Serializable @@ -256,24 +248,7 @@ data class ClubProposalRequest( ) // ---------- Action responses ---------- +// vote/rsvp/progress just echo the request (no recomputed aggregates), so their bodies are +// ignored and the calls are declared as Envelope; only join's status is consumed. @Serializable data class JoinResult(val status: String = "") // active | pending - -@Serializable -data class VoteResult( - @SerialName("poll_id") val pollId: Int = 0, - val options: List = emptyList(), -) - -@Serializable -data class RsvpResult( - @SerialName("meeting_id") val meetingId: Int = 0, - val response: String = "", -) - -@Serializable -data class ProgressResult( - @SerialName("club_book_id") val clubBookId: Int = 0, - val percent: Int = 0, - val finished: Boolean = false, -) diff --git a/app/src/main/java/com/pinakes/app/data/network/ApiResult.kt b/app/src/main/java/com/pinakes/app/data/network/ApiResult.kt index 883a9ae..2044bf8 100644 --- a/app/src/main/java/com/pinakes/app/data/network/ApiResult.kt +++ b/app/src/main/java/com/pinakes/app/data/network/ApiResult.kt @@ -30,7 +30,8 @@ sealed interface ApiResult { /** Well-known error codes (from `error.code` or derived from the HTTP status). */ object ErrorCodes { const val INVALID_CREDENTIALS = "invalid_credentials" - const val APP_DISABLED = "app_disabled" + // Emitted by the server's AppAuthMiddleware (403) when mobile app access is disabled. + const val APP_ACCESS_DISABLED = "app_access_disabled" const val RATE_LIMITED = "rate_limited" const val UNAUTHORIZED = "unauthorized" // 401 without a body code → force re-login const val FORBIDDEN = "forbidden" @@ -105,7 +106,7 @@ suspend fun apiResponse(block: suspend () -> Response>): Pair` (the + * server body is a plain JSON object; `Unit` decoding ignores its keys). */ interface BookClubApi { /** Discovery — no token. 2xx means the section is available; 404 means the plugin is off. */ @GET("bookclub/health") - @Headers(NO_AUTH) - suspend fun health(): BookClubEnvelope + @Headers(PinakesApi.NO_AUTH) + suspend fun health(): BookClubEnvelope // ---- Reads ---- @GET("bookclub/clubs") @@ -60,24 +58,19 @@ interface BookClubApi { @Path("slug") slug: String, @Path("pollId") pollId: Int, @Body body: ClubVoteRequest, - ): BookClubEnvelope + ): BookClubEnvelope @POST("bookclub/clubs/{slug}/meetings/{meetingId}/rsvp") suspend fun rsvp( @Path("slug") slug: String, @Path("meetingId") meetingId: Int, @Body body: ClubRsvpRequest, - ): BookClubEnvelope + ): BookClubEnvelope @POST("bookclub/clubs/{slug}/books/{clubBookId}/progress") suspend fun progress( @Path("slug") slug: String, @Path("clubBookId") clubBookId: Int, @Body body: ClubProgressRequest, - ): BookClubEnvelope - - companion object { - /** Same value-less marker [AuthInterceptor] uses to skip bearer injection on public calls. */ - const val NO_AUTH = "X-Pinakes-No-Auth: true" - } + ): BookClubEnvelope } diff --git a/app/src/main/java/com/pinakes/app/data/network/BookClubResult.kt b/app/src/main/java/com/pinakes/app/data/network/BookClubResult.kt index a29639a..bd5149f 100644 --- a/app/src/main/java/com/pinakes/app/data/network/BookClubResult.kt +++ b/app/src/main/java/com/pinakes/app/data/network/BookClubResult.kt @@ -1,7 +1,6 @@ package com.pinakes.app.data.network import com.pinakes.app.data.model.BookClubEnvelope -import kotlinx.serialization.json.Json import retrofit2.HttpException import java.io.IOException @@ -12,15 +11,13 @@ import java.io.IOException * Success mapping: * - `error != null` → [ApiResult.Failure] carrying the plugin's error code + message. * - `data != null` → [ApiResult.Success]. - * - no data (no-content ok) → [ApiResult.Success] of `Unit` (e.g. the `propose` body we ignore). + * - no data (no-content ok) → [ApiResult.Success] of `Unit` (the `Envelope` endpoints). * - * Failure mapping mirrors [apiCall]: HTTP errors (4xx/5xx) arrive as [HttpException]; the plugin - * error body is parsed for its `code`/`message`, falling back to a status-derived code so the - * caller can still branch (and so [ApiResult.Failure.httpStatus] tells `404` — "section off" — - * apart from a network error). + * HTTP failures reuse the core pipeline ([HttpException.toFailure] → [parseErrorBody]): the + * plugin's error body decodes through the same `Envelope` shape (its extra `success` + * key is ignored, `error.{code,message}` is shared), and bodiless errors get the same + * localized status-derived fallback messages as the core API. */ -private val bookClubErrorJson = Json { ignoreUnknownKeys = true; isLenient = true } - suspend fun bookClubCall(block: suspend () -> BookClubEnvelope): ApiResult = try { val envelope = block() when { @@ -33,38 +30,9 @@ suspend fun bookClubCall(block: suspend () -> BookClubEnvelope): ApiResul } } } catch (e: HttpException) { - e.toBookClubFailure() + e.toFailure() } catch (e: IOException) { ApiResult.Failure(ErrorCodes.NETWORK, e.message ?: "Network error", 0) } catch (e: Throwable) { ApiResult.Failure(ErrorCodes.UNKNOWN, e.message ?: "Unexpected error", 0) } - -private fun HttpException.toBookClubFailure(): ApiResult.Failure { - val status = code() - val retryAfter = response()?.headers()?.get("Retry-After")?.toLongOrNull() - val body = try { response()?.errorBody()?.string() } catch (_: Throwable) { null } - val parsed = parseBookClubErrorBody(body, status) - return parsed.copy(retryAfterSeconds = parsed.retryAfterSeconds ?: retryAfter) -} - -/** Parse a Book Club error envelope; fall back to a status-derived code/message when absent. */ -internal fun parseBookClubErrorBody(body: String?, status: Int): ApiResult.Failure { - val error = body?.takeIf { it.isNotBlank() }?.let { - runCatching { bookClubErrorJson.decodeFromString>(it).error }.getOrNull() - } - val code = error?.code?.takeIf { it.isNotBlank() } ?: bookClubCodeForStatus(status) - val message = error?.message?.takeIf { it.isNotBlank() } ?: "" - return ApiResult.Failure(code = code, message = message, httpStatus = status) -} - -private fun bookClubCodeForStatus(status: Int): String = when (status) { - 400, 422 -> ErrorCodes.VALIDATION - 401 -> ErrorCodes.UNAUTHORIZED - 403 -> ErrorCodes.FORBIDDEN - 404 -> ErrorCodes.NOT_FOUND - 409 -> ErrorCodes.CONFLICT - 429 -> ErrorCodes.RATE_LIMITED - in 500..599 -> ErrorCodes.SERVER_ERROR - else -> ErrorCodes.UNKNOWN -} diff --git a/app/src/main/java/com/pinakes/app/data/repository/AuthRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/AuthRepository.kt index ab3e6b5..3e4aaa6 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/AuthRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/AuthRepository.kt @@ -10,6 +10,8 @@ import com.pinakes.app.data.network.NetworkModule import com.pinakes.app.data.network.apiCall import com.pinakes.app.data.store.FeatureStore import com.pinakes.app.data.store.SessionStore +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope /** * Onboarding + authentication: instance discovery (`/health`), login (persists token + URL), @@ -20,6 +22,7 @@ class AuthRepository( private val session: SessionStore, private val features: FeatureStore, private val bookClub: BookClubRepository, + private val catalog: CatalogRepository, ) { /** @@ -64,14 +67,27 @@ class AuthRepository( * updated (reactively gating the UI); on failure the last-known flags are kept untouched. */ suspend fun refreshHealth() { - if (!session.hasInstance()) return - when (val res = apiCall { network.api().health() }) { - is ApiResult.Success -> features.update(res.data) - is ApiResult.Failure -> { /* keep last-known flags; never lock the user out */ } + val instance = session.instanceUrl ?: return + coroutineScope { + // The core /health and the Book Club plugin probe are independent requests to + // the same instance — run them concurrently so the refresh costs max(RTT), not + // the sum (this path gates the post-login spinner). + val probe = async { bookClub.probeAvailability() } + var appAccessEnabled: Boolean? = null + when (val res = apiCall { network.api().health() }) { + is ApiResult.Success -> { + features.update(res.data) + appAccessEnabled = res.data.appAccessEnabled + } + is ApiResult.Failure -> { /* keep last-known flags; never lock the user out */ } + } + // The plugin's health endpoint is public and answers 2xx even when the instance + // has mobile app access switched off — gate the section on the core flag so it + // hides instead of rendering entries whose calls can only 403. + val probed = probe.await() + val available = if (appAccessEnabled == false) false else probed + bookClub.applyAvailability(available, probedInstanceUrl = instance) } - // Probe the optional Book Club plugin surface too, so the section shows/hides in step - // with the instance's plugin state. Best-effort: a failure keeps the last-known flag. - bookClub.refreshAvailability() } suspend fun login(email: String, password: String): ApiResult { @@ -133,10 +149,12 @@ class AuthRepository( } /** Forget the instance entirely (back to onboarding). */ - fun forgetInstance() { + suspend fun forgetInstance() { session.clearAll() - features.clear() - bookClub.clearAvailability() + features.clear() // resets the Book Club availability flag too + // Purge the offline catalog cache (Room + in-memory ETags): it belongs to the old + // instance and must never surface under the next library's name. + catalog.clearCache() network.invalidate() } diff --git a/app/src/main/java/com/pinakes/app/data/repository/BookClubRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/BookClubRepository.kt index bf93e49..8165b45 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/BookClubRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/BookClubRepository.kt @@ -8,42 +8,49 @@ import com.pinakes.app.data.model.ClubProposalRequest import com.pinakes.app.data.model.ClubRsvpRequest import com.pinakes.app.data.model.ClubVoteRequest import com.pinakes.app.data.model.JoinResult -import com.pinakes.app.data.model.ProgressResult -import com.pinakes.app.data.model.RsvpResult -import com.pinakes.app.data.model.VoteResult import com.pinakes.app.data.network.ApiResult import com.pinakes.app.data.network.ErrorCodes import com.pinakes.app.data.network.NetworkModule import com.pinakes.app.data.network.bookClubCall -import com.pinakes.app.data.store.BookClubStore +import com.pinakes.app.data.store.FeatureStore import com.pinakes.app.data.store.SessionStore /** * Book Club plugin surface (`/api/v1/bookclub/…`): availability discovery, reads (clubs, * detail, personal dashboard) and the write actions (join, propose, vote, RSVP, progress). * - * Availability is probed once per `/health` refresh and cached in [BookClubStore] so the app - * only shows the section when the plugin's `mobile` module is active for this instance. + * Availability is probed alongside every `/health` refresh and stored as an + * [com.pinakes.app.data.store.InstanceFeatures] flag, so the UI only shows the section when + * the plugin's `mobile` module is active for this instance. */ class BookClubRepository( private val network: NetworkModule, - private val store: BookClubStore, + private val features: FeatureStore, private val session: SessionStore, ) { /** - * Re-probe `GET /bookclub/health`. A 2xx flips the section on; an explicit 404 flips it off; - * a network/other failure keeps the last-known flag (never hide a working section on a blip). - * No token is required, so this is safe to call at app start alongside the core health refresh. + * Probe `GET /bookclub/health` (public, no token). + * Returns true on 2xx (plugin on), false on an explicit 404 (plugin off), and null on + * any other failure — the caller keeps the last-known flag rather than hiding a working + * section on a network blip. */ - suspend fun refreshAvailability() { - if (!session.hasInstance()) return + suspend fun probeAvailability(): Boolean? = when (val res = bookClubCall { network.bookClubApi().health() }) { - is ApiResult.Success -> store.setAvailable(true) - is ApiResult.Failure -> if (res.httpStatus == 404 || res.code == ErrorCodes.NOT_FOUND) { - store.setAvailable(false) - } + is ApiResult.Success -> true + is ApiResult.Failure -> + if (res.httpStatus == 404 || res.code == ErrorCodes.NOT_FOUND) false else null } + + /** + * Apply a probe result, guarded against instance switches: a late response from the + * previous instance (the user tapped "change library" mid-flight) must not resurrect + * or clobber the flag of the instance now configured. Null keeps the last-known value. + */ + fun applyAvailability(available: Boolean?, probedInstanceUrl: String?) { + if (available == null) return + if (probedInstanceUrl == null || session.instanceUrl != probedInstanceUrl) return + features.setBookClubAvailable(available) } suspend fun clubs(): ApiResult = @@ -61,18 +68,16 @@ class BookClubRepository( suspend fun propose(slug: String, libroId: Int, motivation: String?): ApiResult = bookClubCall { network.bookClubApi().propose(slug, ClubProposalRequest(libroId, motivation)) } - suspend fun vote(slug: String, pollId: Int, optionIds: List): ApiResult = + suspend fun vote(slug: String, pollId: Int, optionIds: List): ApiResult = bookClubCall { network.bookClubApi().vote(slug, pollId, ClubVoteRequest(optionIds)) } - suspend fun rsvp(slug: String, meetingId: Int, response: String): ApiResult = + suspend fun rsvp(slug: String, meetingId: Int, response: String): ApiResult = bookClubCall { network.bookClubApi().rsvp(slug, meetingId, ClubRsvpRequest(response)) } - suspend fun progress(slug: String, clubBookId: Int, percent: Int, finished: Boolean?): ApiResult = + suspend fun progress(slug: String, clubBookId: Int, percent: Int, finished: Boolean?): ApiResult = bookClubCall { network.bookClubApi().progress(slug, clubBookId, ClubProgressRequest(percent, finished)) } - /** Forget the availability flag (instance forgotten). */ - fun clearAvailability() = store.clear() - - /** Origin (scheme://host[:port]) of the instance, for deep-linking web-only flows. */ - fun webBaseUrl(): String = session.instanceOrigin.orEmpty() + /** Web URL of a poll page — the deep-link target for ballots the app can't render. */ + fun pollWebUrl(slug: String, pollId: Int): String = + "${session.instanceOrigin.orEmpty()}/book-club/$slug/polls/$pollId" } diff --git a/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt index 7aab1d7..fee579b 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt @@ -57,6 +57,16 @@ class CatalogRepository( /** True once a catalog snapshot has been cached at least once. */ suspend fun hasCachedCatalog(): Boolean = catalogDao.count() > 0 + /** + * Drop every per-instance catalog artifact: the Room snapshot and the in-memory ETag + * cache. Called when the instance is forgotten so library A's titles, covers and 304 + * payloads can never surface under library B. + */ + suspend fun clearCache() { + detailCache.clear() + catalogDao.clear() + } + /** * Refresh the cached catalog from the network (first page, unfiltered) and replace * the Room snapshot atomically. On network failure the existing cache is kept, so a diff --git a/app/src/main/java/com/pinakes/app/data/store/BookClubStore.kt b/app/src/main/java/com/pinakes/app/data/store/BookClubStore.kt deleted file mode 100644 index 10ccf4d..0000000 --- a/app/src/main/java/com/pinakes/app/data/store/BookClubStore.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.pinakes.app.data.store - -import android.content.Context -import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -/** - * Whether the current instance exposes the Book Club plugin's mobile surface. - * - * Sourced from `GET /api/v1/bookclub/health` (2xx → available, 404 → off) and persisted so the - * Profile entry point doesn't flicker between launches. Like `registration_enabled` in - * [FeatureStore], the default is DISABLED until discovery confirms it — a first-run or offline - * probe keeps the section hidden rather than showing a dead entry. - */ -class BookClubStore(context: Context) { - - private val prefs: SharedPreferences = run { - val masterKey = MasterKey.Builder(context.applicationContext) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - EncryptedSharedPreferences.create( - context.applicationContext, - FILE_NAME, - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) - } - - private val _available = MutableStateFlow(prefs.getBoolean(KEY_AVAILABLE, false)) - val available: StateFlow = _available.asStateFlow() - - fun setAvailable(value: Boolean) { - prefs.edit().putBoolean(KEY_AVAILABLE, value).apply() - _available.value = value - } - - /** Reset when the instance is forgotten. */ - fun clear() { - prefs.edit().clear().apply() - _available.value = false - } - - companion object { - private const val FILE_NAME = "pinakes_bookclub_secure" - private const val KEY_AVAILABLE = "available" - } -} diff --git a/app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt b/app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt index b8d8a9d..62b58e2 100644 --- a/app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt +++ b/app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt @@ -32,6 +32,13 @@ data class InstanceFeatures( val push: Boolean = true, val reviews: Boolean = true, val registrationEnabled: Boolean = false, + /** + * Whether the instance exposes the optional Book Club plugin's mobile surface. + * Unlike the flags above it is NOT sourced from `/health` but from a separate + * `GET /api/v1/bookclub/health` probe (2xx → on, 404 → off), and like + * [registrationEnabled] it stays hidden until discovery confirms it. + */ + val bookClubAvailable: Boolean = false, ) { /** Library tab (loans + reservations) is shown only when at least one of them is enabled. */ val showLibrary: Boolean get() = loans || reservations @@ -68,7 +75,10 @@ class FeatureStore(context: Context) { private val _features = MutableStateFlow(read()) val features: StateFlow = _features.asStateFlow() - /** Persist + publish the flags from a fresh `/health` payload. */ + /** + * Persist + publish the flags from a fresh `/health` payload. The Book Club flag has + * its own probe lifecycle ([setBookClubAvailable]) and is preserved, not clobbered. + */ fun update(health: HealthPayload) { val f = health.features val value = InstanceFeatures( @@ -82,6 +92,7 @@ class FeatureStore(context: Context) { push = f.push, reviews = f.reviews, registrationEnabled = health.registrationEnabled, + bookClubAvailable = prefs.getBoolean(KEY_BOOK_CLUB, false), ) prefs.edit() .putBoolean(KEY_KNOWN, true) @@ -99,6 +110,12 @@ class FeatureStore(context: Context) { _features.value = value } + /** Persist + publish the Book Club plugin availability (from its own health probe). */ + fun setBookClubAvailable(available: Boolean) { + prefs.edit().putBoolean(KEY_BOOK_CLUB, available).apply() + _features.value = _features.value.copy(bookClubAvailable = available) + } + /** Reset to all-enabled (e.g. when forgetting the instance). */ fun clear() { prefs.edit().clear().apply() @@ -121,6 +138,7 @@ class FeatureStore(context: Context) { push = prefs.getBoolean(KEY_PUSH, true), reviews = prefs.getBoolean(KEY_REVIEWS, true), registrationEnabled = prefs.getBoolean(KEY_REGISTRATION_ENABLED, false), + bookClubAvailable = prefs.getBoolean(KEY_BOOK_CLUB, false), ) } @@ -137,5 +155,6 @@ class FeatureStore(context: Context) { private const val KEY_PUSH = "f_push" private const val KEY_REVIEWS = "f_reviews" private const val KEY_REGISTRATION_ENABLED = "registration_enabled" + private const val KEY_BOOK_CLUB = "f_book_club" } } diff --git a/app/src/main/java/com/pinakes/app/data/sync/CatalogSyncWorker.kt b/app/src/main/java/com/pinakes/app/data/sync/CatalogSyncWorker.kt index 7551d30..698023f 100644 --- a/app/src/main/java/com/pinakes/app/data/sync/CatalogSyncWorker.kt +++ b/app/src/main/java/com/pinakes/app/data/sync/CatalogSyncWorker.kt @@ -69,7 +69,7 @@ class CatalogSyncWorker( fun isPermanentFailure(res: ApiResult.Failure): Boolean = res.httpStatus == 401 || res.httpStatus == 403 || res.code == ErrorCodes.UNAUTHORIZED || res.code == ErrorCodes.FORBIDDEN || - res.code == ErrorCodes.APP_DISABLED || res.code == ErrorCodes.VALIDATION || + res.code == ErrorCodes.APP_ACCESS_DISABLED || res.code == ErrorCodes.VALIDATION || res.code == ErrorCodes.NOT_FOUND /** diff --git a/app/src/main/java/com/pinakes/app/di/AppModule.kt b/app/src/main/java/com/pinakes/app/di/AppModule.kt index 7072aa7..7bdae89 100644 --- a/app/src/main/java/com/pinakes/app/di/AppModule.kt +++ b/app/src/main/java/com/pinakes/app/di/AppModule.kt @@ -13,7 +13,6 @@ import com.pinakes.app.data.repository.NotificationsRepository import com.pinakes.app.data.repository.ProfileRepository import com.pinakes.app.data.repository.ReviewsRepository import com.pinakes.app.data.repository.WishlistRepository -import com.pinakes.app.data.store.BookClubStore import com.pinakes.app.data.store.FeatureStore import com.pinakes.app.data.store.SessionStore import com.pinakes.app.data.store.ThemeStore @@ -41,9 +40,6 @@ object AppModule { @Provides @Singleton fun features(@ApplicationContext context: Context): FeatureStore = FeatureStore(context) - @Provides @Singleton - fun bookClubStore(@ApplicationContext context: Context): BookClubStore = BookClubStore(context) - @Provides @Singleton fun network(session: SessionStore): NetworkModule = NetworkModule(session) @@ -58,8 +54,8 @@ object AppModule { CatalogRepository(network, dao) @Provides @Singleton - fun bookClubRepository(network: NetworkModule, store: BookClubStore, session: SessionStore): BookClubRepository = - BookClubRepository(network, store, session) + fun bookClubRepository(network: NetworkModule, features: FeatureStore, session: SessionStore): BookClubRepository = + BookClubRepository(network, features, session) @Provides @Singleton fun authRepository( @@ -67,7 +63,8 @@ object AppModule { session: SessionStore, features: FeatureStore, bookClub: BookClubRepository, - ): AuthRepository = AuthRepository(network, session, features, bookClub) + catalog: CatalogRepository, + ): AuthRepository = AuthRepository(network, session, features, bookClub, catalog) @Provides @Singleton fun libraryRepository(network: NetworkModule): LibraryRepository = LibraryRepository(network) diff --git a/app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt b/app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt index 70fb9b3..f25e029 100644 --- a/app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt @@ -1,7 +1,6 @@ package com.pinakes.app.ui.common import androidx.lifecycle.ViewModel -import com.pinakes.app.data.store.BookClubStore import com.pinakes.app.data.store.FeatureStore import com.pinakes.app.data.store.SessionStore import com.pinakes.app.data.store.ThemeStore @@ -22,16 +21,13 @@ import javax.inject.Inject class AppViewModel @Inject constructor( session: SessionStore, featureStore: FeatureStore, - bookClubStore: BookClubStore, private val themeStore: ThemeStore, ) : ViewModel() { val authState = session.authState + // Includes bookClubAvailable, the Book Club plugin gate (from its own health probe). val features = featureStore.features val themeMode = themeStore.mode - /** Whether the instance exposes the optional Book Club plugin (gates the Profile entry). */ - val bookClubAvailable = bookClubStore.available - fun setThemeMode(mode: ThemeMode) = themeStore.setMode(mode) } 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 c14aa61..a5a24c0 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 @@ -2,15 +2,18 @@ package com.pinakes.app.ui.common import java.time.Instant import java.time.LocalDate +import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.Locale /** - * ISO-8601 (UTC) → human, locale-aware date formatting. All API timestamps are UTC; we render - * them in the device's local zone. Robust to date-only strings and parse failures (returns the - * raw input rather than throwing). + * Server timestamp → human, locale-aware date formatting. The API emits two shapes: + * ISO-8601 UTC instants (rendered in the device's local zone) and raw MySQL wall-clock + * values — `yyyy-MM-dd HH:mm[:ss]` DATETIMEs (reviews, devices, book club) and + * `yyyy-MM-dd` DATEs — rendered as-is, matching the website. Robust to parse failures + * (returns the raw input rather than throwing). */ object DateFormat { @@ -21,28 +24,46 @@ object DateFormat { DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT) .withLocale(Locale.getDefault()) - /** "12 Jun 2026" — date only. Accepts full ISO timestamps or yyyy-MM-dd. */ + private val wallClockSeconds = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + private val wallClockMinutes = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") + + /** "12 Jun 2026" — date only. Accepts ISO timestamps, MySQL DATETIMEs or yyyy-MM-dd. */ fun date(iso: String?): String { if (iso.isNullOrBlank()) return "—" return runCatching { - val instant = parseInstant(iso) - if (instant != null) { - instant.atZone(ZoneId.systemDefault()).toLocalDate().format(dateFormatter) - } else { - LocalDate.parse(iso).format(dateFormatter) - } + parseServerDateTime(iso)?.toLocalDate()?.format(dateFormatter) + ?: LocalDate.parse(iso).format(dateFormatter) }.getOrDefault(iso) } - /** "12 Jun 2026, 14:30" — date and time in the local zone. */ + /** "12 Jun 2026, 14:30" — date and time. Date-only inputs defer to [date]. */ fun dateTime(iso: String?): String { if (iso.isNullOrBlank()) return "—" return runCatching { - val instant = parseInstant(iso) ?: return date(iso) - instant.atZone(ZoneId.systemDefault()).format(dateTimeFormatter) + parseServerDateTime(iso)?.format(dateTimeFormatter) ?: date(iso) }.getOrDefault(iso) } + /** + * Parse any server timestamp with a time component: an ISO-8601 instant (converted to + * the device zone) or a MySQL wall-clock DATETIME (kept as-is). Null when the input is + * blank, date-only or unparseable. + */ + fun parseServerDateTime(raw: String?): LocalDateTime? { + if (raw.isNullOrBlank()) return null + parseInstant(raw)?.let { return it.atZone(ZoneId.systemDefault()).toLocalDateTime() } + return runCatching { LocalDateTime.parse(raw, wallClockSeconds) }.getOrNull() + ?: runCatching { LocalDateTime.parse(raw, wallClockMinutes) }.getOrNull() + ?: runCatching { LocalDateTime.parse(raw) }.getOrNull() // ISO local ("...T...", no zone) + } + + /** + * 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. + */ + fun isPast(raw: String?): Boolean = + parseServerDateTime(raw)?.isBefore(LocalDateTime.now()) == true + private fun parseInstant(iso: String): Instant? = runCatching { Instant.parse(iso) }.getOrNull() /** Today as yyyy-MM-dd (local), used as a default reservation start date. */ diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/MainScaffold.kt b/app/src/main/java/com/pinakes/app/ui/navigation/MainScaffold.kt index dc9bfff..bf87a1d 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/MainScaffold.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/MainScaffold.kt @@ -43,7 +43,6 @@ fun MainScaffold( ) { val app: AppViewModel = hiltViewModel() val features by app.features.collectAsStateWithLifecycle() - val bookClubAvailable by app.bookClubAvailable.collectAsStateWithLifecycle() var tab by rememberSaveable { mutableStateOf(PinakesTab.Home) } @@ -98,7 +97,6 @@ fun MainScaffold( onOpenContact = onOpenContact, onOpenMyReviews = onOpenMyReviews, onOpenBookClub = onOpenBookClub, - showBookClub = bookClubAvailable, ) } } 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 c5a3144..6d1fe98 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 @@ -25,11 +25,15 @@ import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -41,6 +45,7 @@ import com.pinakes.app.R import com.pinakes.app.data.model.DashboardCard import com.pinakes.app.data.model.DirectoryClub import com.pinakes.app.data.model.MyClub +import com.pinakes.app.ui.common.DateFormat import com.pinakes.app.ui.common.UiState import com.pinakes.app.ui.common.resolvedMessage import com.pinakes.app.ui.components.EmptyState @@ -57,9 +62,16 @@ fun BookClubHomeScreen( ) { val vm: BookClubHomeViewModel = hiltViewModel() val state by vm.state.collectAsStateWithLifecycle() + val snackbarHost = remember { SnackbarHostState() } + + val snackbarMessage = state.snackbarRes?.let { stringResource(it) } + LaunchedEffect(snackbarMessage) { + snackbarMessage?.let { snackbarHost.showSnackbar(it); vm.consumeSnackbar() } + } Scaffold( topBar = { PinakesTopBar(title = stringResource(R.string.book_club_title), onNavigateUp = onNavigateUp) }, + snackbarHost = { SnackbarHost(snackbarHost) }, ) { padding -> PullToRefreshBox( isRefreshing = state.refreshing, @@ -180,7 +192,7 @@ private fun DashboardCardView(card: DashboardCard, onClick: () -> Unit) { card.nextMeeting?.let { meeting -> Spacer(Modifier.height(Spacing.sm)) Text( - stringResource(R.string.book_club_next_meeting, meeting.title, clubDateTime(meeting.startsAt)), + stringResource(R.string.book_club_next_meeting, meeting.title, DateFormat.dateTime(meeting.startsAt)), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) 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 3e032ec..4dd400e 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 @@ -29,6 +29,8 @@ data class BookClubHome( data class BookClubHomeUiState( val content: UiState = UiState.Loading, val refreshing: Boolean = false, + /** Partial-failure notice (e.g. the dashboard fetch failed while clubs loaded). */ + val snackbarRes: Int? = null, ) @HiltViewModel @@ -56,10 +58,16 @@ class BookClubHomeViewModel @Inject constructor( when (clubsRes) { is ApiResult.Success -> { val dashboard = (dashboardRes as? ApiResult.Success)?.data?.clubs ?: emptyList() + // Partial failure: clubs loaded but the personal dashboard didn't. + // Never swallow it — the member's "Your reading" section would vanish + // (or the friendly empty state would show) with no signal at all. + val dashboardFailed = + dashboardRes is ApiResult.Failure && clubsRes.data.myClubs.isNotEmpty() _state.update { it.copy( content = UiState.Success(BookClubHome(dashboard = dashboard, clubs = clubsRes.data)), refreshing = false, + snackbarRes = if (dashboardFailed) R.string.book_club_error_dashboard else it.snackbarRes, ) } } @@ -73,4 +81,6 @@ class BookClubHomeViewModel @Inject constructor( } } } + + fun consumeSnackbar() = _state.update { it.copy(snackbarRes = null) } } 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 7fe85bd..b2e04d3 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 @@ -6,11 +6,6 @@ import android.net.Uri import androidx.annotation.StringRes import androidx.compose.ui.graphics.Color import com.pinakes.app.R -import com.pinakes.app.ui.common.DateFormat -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.util.Locale /** * Parse a `#RRGGBB` (or `#AARRGGBB`) club/state colour into a Compose [Color]. @@ -21,26 +16,6 @@ fun parseHexColor(hex: String?, fallback: Color): Color { return runCatching { Color(android.graphics.Color.parseColor(hex.trim())) }.getOrDefault(fallback) } -private val clubDateTimeFormatter: DateTimeFormatter = - DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT).withLocale(Locale.getDefault()) - -/** - * Book Club timestamps come straight from the DB as `yyyy-MM-dd HH:mm:ss` (local wall-clock, no - * zone), unlike the core API's ISO-8601 UTC. Render those directly; delegate anything else to the - * shared [DateFormat] so ISO strings and date-only values still format correctly. - */ -fun clubDateTime(raw: String?): String { - if (raw.isNullOrBlank()) return "—" - if (raw.contains(' ') && !raw.contains('T')) { - val normalized = if (raw.length == 16) "$raw:00" else raw // "yyyy-MM-dd HH:mm" → add seconds - val parsed = runCatching { - LocalDateTime.parse(normalized.replace(' ', 'T')).format(clubDateTimeFormatter) - }.getOrNull() - if (parsed != null) return parsed - } - return DateFormat.dateTime(raw) -} - /** Localized label for a club membership role. */ @StringRes fun roleLabelRes(role: String): Int = when (role) { 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 c2634a6..19debbd 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 @@ -55,10 +55,12 @@ import com.pinakes.app.data.model.BookClubDetail import com.pinakes.app.data.model.ClubBook import com.pinakes.app.data.model.ClubMeeting import com.pinakes.app.data.model.ClubPoll +import com.pinakes.app.ui.common.DateFormat import com.pinakes.app.ui.common.UiState import com.pinakes.app.ui.common.resolvedMessage import com.pinakes.app.ui.components.ErrorState import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.PinakesTextButton import com.pinakes.app.ui.components.PinakesTopBar import com.pinakes.app.ui.components.PrimaryButton import com.pinakes.app.ui.components.SecondaryButton @@ -101,8 +103,8 @@ fun ClubDetailScreen(onNavigateUp: () -> Unit) { onVote = vm::vote, onRsvp = vm::rsvp, onUpdateProgress = { book -> progressBook = book }, - onOpenWeb = { url -> openWeb(context, url) }, - webBaseUrl = vm.webBaseUrl, + onOpenPollWeb = { poll -> openWeb(context, vm.pollWebUrl(poll.id)) }, + onOpenVideo = { url -> openWeb(context, url) }, ) } } @@ -129,8 +131,8 @@ private fun ClubDetailContent( onVote: (Int, List) -> Unit, onRsvp: (Int, String) -> Unit, onUpdateProgress: (ClubBook) -> Unit, - onOpenWeb: (String) -> Unit, - webBaseUrl: String, + onOpenPollWeb: (ClubPoll) -> Unit, + onOpenVideo: (String) -> Unit, ) { LazyColumn( Modifier.fillMaxSize(), @@ -158,7 +160,7 @@ private fun ClubDetailContent( canParticipate = detail.canParticipate, voting = state.votingPollId == poll.id, onVote = { options -> onVote(poll.id, options) }, - onOpenWeb = { onOpenWeb("$webBaseUrl/book-club/${detail.club.slug}/polls/${poll.id}") }, + onOpenWeb = { onOpenPollWeb(poll) }, ) } } @@ -171,7 +173,7 @@ private fun ClubDetailContent( canParticipate = detail.canParticipate, rsvping = state.rsvpMeetingId == meeting.id, onRsvp = { response -> onRsvp(meeting.id, response) }, - onOpenVideo = { onOpenWeb(meeting.videoUrl) }, + onOpenVideo = { onOpenVideo(meeting.videoUrl) }, ) } } @@ -306,7 +308,11 @@ private fun PollCard( ) { // Selection is seeded from the user's existing ballot and resets when the poll reloads. var selected by remember(poll.id, poll.myOptionIds) { mutableStateOf(poll.myOptionIds.toSet()) } - val interactive = canParticipate && poll.isOpen && poll.votableInApp + // The server only flips status to 'closed' from the cron or the web page (never on the + // mobile read path), yet vote() rejects past-deadline ballots with 409 poll_closed — + // treat an expired closes_at as closed so we never render a ballot that can only fail. + val open = poll.isOpen && !DateFormat.isPast(poll.closesAt) + val interactive = canParticipate && open && poll.votableInApp Surface( shape = MaterialTheme.shapes.medium, @@ -316,8 +322,8 @@ private fun PollCard( Column(Modifier.padding(Spacing.lg)) { Text(poll.title, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface) val meta = buildList { - add(stringResource(if (poll.isOpen) R.string.book_club_poll_open else R.string.book_club_poll_closed)) - poll.closesAt?.let { add(stringResource(R.string.book_club_poll_closes, clubDateTime(it))) } + add(stringResource(if (open) R.string.book_club_poll_open else R.string.book_club_poll_closed)) + poll.closesAt?.let { add(stringResource(R.string.book_club_poll_closes, DateFormat.dateTime(it))) } add(stringResource(R.string.book_club_poll_voters, poll.voterCount)) }.joinToString(" · ") Text(meta, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) @@ -367,7 +373,7 @@ private fun PollCard( loading = voting, modifier = Modifier.fillMaxWidth(), ) - } else if (canParticipate && poll.isOpen && !poll.votableInApp) { + } else if (canParticipate && open && !poll.votableInApp) { // Advanced ballots (stars/ranking/elimination) are web-only per the API contract. Spacer(Modifier.height(Spacing.sm)) SecondaryButton( @@ -396,7 +402,7 @@ private fun MeetingCard( ) { Column(Modifier.padding(Spacing.lg)) { Text(meeting.title, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface) - Text(clubDateTime(meeting.startsAt), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(DateFormat.dateTime(meeting.startsAt), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) if (meeting.location.isNotBlank()) { Text(meeting.location, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } @@ -421,7 +427,11 @@ private fun MeetingCard( if (canParticipate && meeting.status == "scheduled") { Spacer(Modifier.height(Spacing.sm)) Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { - RsvpChip(stringResource(R.string.book_club_rsvp_yes), meeting.myRsvp == "yes", rsvping) { onRsvp("yes") } + // A full meeting always 409s (no_seats) on a NEW 'yes' — mirror the + // server rule client-side; users already going stay free to re-confirm, + // and 'maybe'/'no' are never seat-gated. + val yesBlocked = meeting.isFull && meeting.myRsvp != "yes" + RsvpChip(stringResource(R.string.book_club_rsvp_yes), meeting.myRsvp == "yes", rsvping || yesBlocked) { onRsvp("yes") } RsvpChip(stringResource(R.string.book_club_rsvp_maybe), meeting.myRsvp == "maybe", rsvping) { onRsvp("maybe") } RsvpChip(stringResource(R.string.book_club_rsvp_no), meeting.myRsvp == "no", rsvping) { onRsvp("no") } } @@ -493,7 +503,7 @@ private fun ProgressDialog( ) }, dismissButton = { - com.pinakes.app.ui.components.PinakesTextButton(label = stringResource(R.string.action_cancel), onClick = onDismiss) + PinakesTextButton(label = stringResource(R.string.action_cancel), onClick = onDismiss) }, ) } 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 ad8b537..a9b4845 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 @@ -39,8 +39,8 @@ class ClubDetailViewModel @Inject constructor( private val _state = MutableStateFlow(ClubDetailUiState()) val state: StateFlow = _state.asStateFlow() - /** Instance web origin, for deep-linking the flows the API marks web-only. */ - val webBaseUrl: String get() = repo.webBaseUrl() + /** Web page of a poll — deep-link target for the ballots the app can't render. */ + fun pollWebUrl(pollId: Int): String = repo.pollWebUrl(slug, pollId) init { load(initial = true) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt index 0592792..f3c2d35 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt @@ -5,9 +5,11 @@ import androidx.lifecycle.viewModelScope 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.SessionStore import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -36,19 +38,28 @@ class HomeViewModel @Inject constructor( ) val state: StateFlow = _state.asStateFlow() + /** + * True once a server-side `available=true` page has been rendered this session. From + * that point the shelf is authoritative and cache emissions must not overwrite it — + * the cache only holds the first unfiltered page, a subset of the real answer. + */ + private var hasFreshShelf = false + init { observeCache() refresh() } /** - * Offline-first: render the "Available now" shelf from the locally-cached catalog + * Offline fallback: render the "Available now" shelf from the locally-cached catalog * snapshot immediately (works with no network), filtering to currently-loanable - * copies. The shelf updates automatically when [refresh] replaces the cache. + * 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. */ private fun observeCache() { viewModelScope.launch { catalog.observeCachedCatalog().collectLatest { books -> + if (hasFreshShelf) return@collectLatest val available = books.filter { it.available } _state.update { it.copy( @@ -67,14 +78,23 @@ class HomeViewModel @Inject constructor( } /** - * Pull a fresh catalog snapshot from the network into the cache. Called on init and - * on every app foreground. A failure only surfaces an error when there is nothing - * cached to show — otherwise the cached catalog stays on screen. + * Refresh the shelf and the offline cache concurrently. The shelf is the server-side + * `available=true` query (the catalog can hold hundreds of titles whose newest page is + * fully on loan — only the server knows what's loanable now); the unfiltered first + * page keeps feeding the Room cache for offline starts. A shelf failure only surfaces + * an error when there is nothing cached to fall back on. */ fun refresh() { viewModelScope.launch { - when (val res = catalog.refreshCatalog()) { - is ApiResult.Success -> _state.update { it.copy(loading = false, error = null) } + val shelf = async { catalog.search(SearchFilters(availableOnly = true), limit = SHELF_LIMIT) } + launch { catalog.refreshCatalog() } + when (val res = shelf.await()) { + is ApiResult.Success -> { + hasFreshShelf = true + _state.update { + it.copy(available = res.data.items, loading = false, error = null) + } + } is ApiResult.Failure -> { val hasCache = catalog.hasCachedCatalog() _state.update { @@ -89,4 +109,9 @@ class HomeViewModel @Inject constructor( } fun retry() = refresh() + + private companion object { + /** Server page cap is 50; 40 matches the cached-catalog page size. */ + const val SHELF_LIMIT = 40 + } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/login/LoginViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/login/LoginViewModel.kt index 88f4d24..2be5e22 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/login/LoginViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/login/LoginViewModel.kt @@ -70,13 +70,15 @@ class LoginViewModel @Inject constructor( /** Forget the instance — back to onboarding (e.g. "wrong library"). */ fun changeLibrary(onDone: () -> Unit) { - auth.forgetInstance() - onDone() + viewModelScope.launch { + auth.forgetInstance() + onDone() + } } private fun applyError(state: LoginUiState, failure: ApiResult.Failure): LoginUiState = when (failure.code) { ErrorCodes.INVALID_CREDENTIALS -> state.copy(error = null, errorRes = R.string.login_error_invalid_credentials, errorArg = null) - ErrorCodes.APP_DISABLED -> state.copy(error = null, errorRes = R.string.login_error_app_disabled, errorArg = null) + ErrorCodes.APP_ACCESS_DISABLED -> state.copy(error = null, errorRes = R.string.login_error_app_disabled, errorArg = null) ErrorCodes.RATE_LIMITED -> { val s = failure.retryAfterSeconds if (s != null) state.copy(error = null, errorRes = R.string.login_error_rate_limited_seconds, errorArg = s) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt index d59f3f5..acd95bb 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt @@ -77,7 +77,6 @@ fun ProfileScreen( onOpenContact: () -> Unit, onOpenMyReviews: () -> Unit, onOpenBookClub: () -> Unit, - showBookClub: Boolean, ) { val app: AppViewModel = hiltViewModel() val vm: ProfileViewModel = hiltViewModel() @@ -110,7 +109,7 @@ fun ProfileScreen( showNotifications = features.notifications, showContact = features.messages, showReviews = features.showReviews, - showBookClub = showBookClub, + showBookClub = features.bookClubAvailable, ) } } diff --git a/app/src/test/java/com/pinakes/app/CatalogSyncWorkerTest.kt b/app/src/test/java/com/pinakes/app/CatalogSyncWorkerTest.kt index e432744..ebc3a60 100644 --- a/app/src/test/java/com/pinakes/app/CatalogSyncWorkerTest.kt +++ b/app/src/test/java/com/pinakes/app/CatalogSyncWorkerTest.kt @@ -25,7 +25,7 @@ class CatalogSyncWorkerTest { // Auth/authorization: a background worker can't re-login. assertTrue(CatalogSyncWorker.isPermanentFailure(failure(ErrorCodes.UNAUTHORIZED, 401))) assertTrue(CatalogSyncWorker.isPermanentFailure(failure(ErrorCodes.FORBIDDEN, 403))) - assertTrue(CatalogSyncWorker.isPermanentFailure(failure(ErrorCodes.APP_DISABLED, 403))) + assertTrue(CatalogSyncWorker.isPermanentFailure(failure(ErrorCodes.APP_ACCESS_DISABLED, 403))) // A bare 401/403 status with no matching body code still counts. assertTrue(CatalogSyncWorker.isPermanentFailure(failure(ErrorCodes.UNKNOWN, 401))) assertTrue(CatalogSyncWorker.isPermanentFailure(failure(ErrorCodes.UNKNOWN, 403))) diff --git a/i18n/de.json b/i18n/de.json index bbe544c..3270bda 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -395,5 +395,6 @@ "book_club_privacy_public": "Öffentlich", "book_club_privacy_private": "Privat", "book_club_privacy_invite": "Nur mit Einladung", - "book_club_privacy_hidden": "Verborgen" + "book_club_privacy_hidden": "Verborgen", + "book_club_error_dashboard": "Dein Lesebereich konnte nicht geladen werden. Zum Aktualisieren ziehen." } diff --git a/i18n/en.json b/i18n/en.json index 8e5ce19..0fa2241 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -395,5 +395,6 @@ "book_club_privacy_public": "Public", "book_club_privacy_private": "Private", "book_club_privacy_invite": "Invite only", - "book_club_privacy_hidden": "Hidden" + "book_club_privacy_hidden": "Hidden", + "book_club_error_dashboard": "Couldn't load your reading section. Pull to refresh." } diff --git a/i18n/fr.json b/i18n/fr.json index ed426b6..d79140e 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -395,5 +395,6 @@ "book_club_privacy_public": "Public", "book_club_privacy_private": "Privé", "book_club_privacy_invite": "Sur invitation", - "book_club_privacy_hidden": "Masqué" + "book_club_privacy_hidden": "Masqué", + "book_club_error_dashboard": "Impossible de charger votre section de lecture. Tirez pour actualiser." } diff --git a/i18n/it.json b/i18n/it.json index 977b8bc..b5f5c5e 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -395,5 +395,6 @@ "book_club_privacy_public": "Pubblico", "book_club_privacy_private": "Privato", "book_club_privacy_invite": "Solo su invito", - "book_club_privacy_hidden": "Nascosto" + "book_club_privacy_hidden": "Nascosto", + "book_club_error_dashboard": "Impossibile caricare la sezione delle tue letture. Trascina per aggiornare." } From 303a22561e18f5fdfb9fcd6f0012ccfe2a9e3341 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Mon, 6 Jul 2026 16:29:41 +0200 Subject: [PATCH 4/4] fix(book-club): degrade gracefully when the plugin is deactivated server-side Review findings on the integration branch: - A 404 on clubs/detail now re-probes /bookclub/health (confirmGone): when the plugin is really gone it flips the feature flag immediately - hiding the Profile entry without waiting for the next app-foreground health refresh - and the screen shows a friendly terminal empty state instead of a retryable error banner. A 404 for a single missing club keeps the normal error path. - pollWebUrl returns an empty string when the instance origin is unknown, so the deep-link guard skips instead of firing an intent on a relative URL. - 2 new i18n keys (gone title/subtitle) added to all 4 locales (400 keys each, parity verified). 67 unit tests + assembleRelease green. --- .../app/data/repository/BookClubRepository.kt | 15 +++++++++++- .../ui/screens/bookclub/BookClubHomeScreen.kt | 8 ++++++- .../screens/bookclub/BookClubHomeViewModel.kt | 24 ++++++++++++++----- .../ui/screens/bookclub/ClubDetailScreen.kt | 7 +++++- .../screens/bookclub/ClubDetailViewModel.kt | 23 +++++++++++++----- i18n/de.json | 2 ++ i18n/en.json | 2 ++ i18n/fr.json | 2 ++ i18n/it.json | 2 ++ 9 files changed, 70 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/pinakes/app/data/repository/BookClubRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/BookClubRepository.kt index 8165b45..601aee8 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/BookClubRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/BookClubRepository.kt @@ -77,7 +77,20 @@ class BookClubRepository( suspend fun progress(slug: String, clubBookId: Int, percent: Int, finished: Boolean?): ApiResult = bookClubCall { network.bookClubApi().progress(slug, clubBookId, ClubProgressRequest(percent, finished)) } + /** + * A bookclub endpoint answered 404: re-probe the plugin health and, when the + * plugin is confirmed gone, flip the feature flag so every entry point hides + * immediately (instead of waiting for the next app-foreground health refresh). + * Returns true when the plugin is really unavailable (vs a single missing club). + */ + suspend fun confirmGone(): Boolean { + val instance = session.instanceUrl + val available = probeAvailability() + applyAvailability(available, instance) + return available == false + } + /** Web URL of a poll page — the deep-link target for ballots the app can't render. */ fun pollWebUrl(slug: String, pollId: Int): String = - "${session.instanceOrigin.orEmpty()}/book-club/$slug/polls/$pollId" + session.instanceOrigin?.let { "$it/book-club/$slug/polls/$pollId" } ?: "" } 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 6d1fe98..c17ad7c 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 @@ -80,7 +80,13 @@ fun BookClubHomeScreen( ) { when (val content = state.content) { is UiState.Loading -> LoadingState(label = stringResource(R.string.book_club_loading)) - is UiState.Error -> ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh) + is UiState.Error -> + // Plugin deactivated server-side: a friendly terminal state, not + // a retryable error (the feature flag is already flipped off). + if (state.pluginGone) EmptyState( + title = stringResource(R.string.book_club_gone_title), + subtitle = stringResource(R.string.book_club_gone_subtitle), + ) else ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh) is UiState.Success -> { val home = content.data if (home.isEmpty) { 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 4dd400e..d58f903 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 @@ -6,6 +6,7 @@ import com.pinakes.app.R import com.pinakes.app.data.model.BookClubClubs import com.pinakes.app.data.model.DashboardCard import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes import com.pinakes.app.data.repository.BookClubRepository import com.pinakes.app.ui.common.UiState import dagger.hilt.android.lifecycle.HiltViewModel @@ -31,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, + /** The plugin was deactivated server-side (confirmed via health re-probe). */ + val pluginGone: Boolean = false, ) @HiltViewModel @@ -71,12 +74,21 @@ class BookClubHomeViewModel @Inject constructor( ) } } - is ApiResult.Failure -> _state.update { - it.copy( - content = if (it.content is UiState.Success) it.content - else UiState.Error(clubsRes.message, clubsRes.code, R.string.book_club_error_load), - refreshing = false, - ) + is ApiResult.Failure -> { + // 404 usually means the plugin was deactivated: confirm via the + // health probe (which also flips the feature flag so the Profile + // entry hides) and degrade to a friendly "gone" state instead of + // a retryable error banner. + val gone = (clubsRes.httpStatus == 404 || clubsRes.code == ErrorCodes.NOT_FOUND) && + repo.confirmGone() + _state.update { + it.copy( + content = if (!gone && it.content is UiState.Success) it.content + else UiState.Error(clubsRes.message, clubsRes.code, R.string.book_club_error_load), + refreshing = false, + pluginGone = gone, + ) + } } } } 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 19debbd..a7c544d 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 @@ -58,6 +58,7 @@ import com.pinakes.app.data.model.ClubPoll import com.pinakes.app.ui.common.DateFormat import com.pinakes.app.ui.common.UiState import com.pinakes.app.ui.common.resolvedMessage +import com.pinakes.app.ui.components.EmptyState import com.pinakes.app.ui.components.ErrorState import com.pinakes.app.ui.components.LoadingState import com.pinakes.app.ui.components.PinakesTextButton @@ -95,7 +96,11 @@ fun ClubDetailScreen(onNavigateUp: () -> Unit) { ) { when (val content = state.content) { is UiState.Loading -> LoadingState(label = stringResource(R.string.book_club_loading)) - is UiState.Error -> ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh) + is UiState.Error -> + if (state.pluginGone) EmptyState( + title = stringResource(R.string.book_club_gone_title), + subtitle = stringResource(R.string.book_club_gone_subtitle), + ) else ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh) is UiState.Success -> ClubDetailContent( detail = content.data, state = state, 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 a9b4845..a3e3036 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 @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.pinakes.app.R import com.pinakes.app.data.model.BookClubDetail import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes import com.pinakes.app.data.repository.BookClubRepository import com.pinakes.app.ui.common.UiState import com.pinakes.app.ui.navigation.Routes @@ -26,6 +27,8 @@ data class ClubDetailUiState( val progressBookId: Int? = null, val snackbar: String? = null, val snackbarRes: Int? = null, + /** The plugin was deactivated server-side (confirmed via health re-probe). */ + val pluginGone: Boolean = false, ) @HiltViewModel @@ -54,12 +57,20 @@ class ClubDetailViewModel @Inject constructor( is ApiResult.Success -> _state.update { it.copy(content = UiState.Success(res.data), refreshing = false) } - is ApiResult.Failure -> _state.update { - it.copy( - content = if (it.content is UiState.Success) it.content - else UiState.Error(res.message, res.code, R.string.book_club_error_load), - refreshing = false, - ) + is ApiResult.Failure -> { + // A 404 here can be either "this club is gone" or "the whole + // plugin was deactivated". The health re-probe disambiguates and + // flips the feature flag when it is the plugin. + val gone = (res.httpStatus == 404 || res.code == ErrorCodes.NOT_FOUND) && + repo.confirmGone() + _state.update { + it.copy( + content = if (!gone && it.content is UiState.Success) it.content + else UiState.Error(res.message, res.code, R.string.book_club_error_load), + refreshing = false, + pluginGone = gone, + ) + } } } } diff --git a/i18n/de.json b/i18n/de.json index 3270bda..3bc71ad 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -345,6 +345,8 @@ "book_club_title": "Lesekreis", "book_club_loading": "Clubs werden geladen…", "book_club_error_load": "Die Lesekreise konnten nicht geladen werden.", + "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_empty_subtitle": "Sobald deine Bibliothek einen Lesekreis eröffnet, findest du ihn hier.", "book_club_section_reading": "Deine Lektüre", diff --git a/i18n/en.json b/i18n/en.json index 0fa2241..81abf84 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -345,6 +345,8 @@ "book_club_title": "Book Club", "book_club_loading": "Loading clubs…", "book_club_error_load": "Couldn't load the book clubs.", + "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_empty_subtitle": "When your library opens a reading group, you'll find it here.", "book_club_section_reading": "Your reading", diff --git a/i18n/fr.json b/i18n/fr.json index d79140e..f5d7a3f 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -345,6 +345,8 @@ "book_club_title": "Club de lecture", "book_club_loading": "Chargement des clubs…", "book_club_error_load": "Impossible de charger les clubs de lecture.", + "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_empty_subtitle": "Lorsque votre bibliothèque ouvrira un club de lecture, il apparaîtra ici.", "book_club_section_reading": "Vos lectures", diff --git a/i18n/it.json b/i18n/it.json index b5f5c5e..5e8293d 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -345,6 +345,8 @@ "book_club_title": "Gruppo di lettura", "book_club_loading": "Caricamento dei club…", "book_club_error_load": "Impossibile caricare i gruppi di lettura.", + "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_empty_subtitle": "Quando la biblioteca aprirà un gruppo di lettura, lo troverai qui.", "book_club_section_reading": "Le tue letture",