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/ 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..ca5dff3 100644 --- a/STATUS.md +++ b/STATUS.md @@ -81,6 +81,61 @@ 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`, 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 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, + 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** — `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 - **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..5db1b9d --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/model/BookClubModels.kt @@ -0,0 +1,254 @@ +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, + // Same {code, message} shape as the core envelope's error — reuse the shared model. + val error: ApiError? = null, +) + +// ---------- 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, +) + +// ---------- 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 + + /** + * 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() { + val status = myMembership?.status + val joinable = status == null || status !in listOf("active", "pending", "banned") + return joinable && (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 ---------- +// 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 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(PinakesApi.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 +} 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..bd5149f --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/network/BookClubResult.kt @@ -0,0 +1,38 @@ +package com.pinakes.app.data.network + +import com.pinakes.app.data.model.BookClubEnvelope +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` (the `Envelope` endpoints). + * + * 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. + */ +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.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) +} 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..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), @@ -19,6 +21,8 @@ class AuthRepository( private val network: NetworkModule, private val session: SessionStore, private val features: FeatureStore, + private val bookClub: BookClubRepository, + private val catalog: CatalogRepository, ) { /** @@ -63,10 +67,26 @@ 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) } } @@ -129,9 +149,12 @@ class AuthRepository( } /** Forget the instance entirely (back to onboarding). */ - fun forgetInstance() { + suspend fun forgetInstance() { session.clearAll() - features.clear() + 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 new file mode 100644 index 0000000..601aee8 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/repository/BookClubRepository.kt @@ -0,0 +1,96 @@ +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.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.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 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 features: FeatureStore, + private val session: SessionStore, +) { + + /** + * 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 probeAvailability(): Boolean? = + when (val res = bookClubCall { network.bookClubApi().health() }) { + 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 = + 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)) } + + /** + * 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?.let { "$it/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/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 110eee2..7bdae89 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 @@ -53,8 +54,17 @@ object AppModule { CatalogRepository(network, dao) @Provides @Singleton - fun authRepository(network: NetworkModule, session: SessionStore, features: FeatureStore): AuthRepository = - AuthRepository(network, session, features) + fun bookClubRepository(network: NetworkModule, features: FeatureStore, session: SessionStore): BookClubRepository = + BookClubRepository(network, features, session) + + @Provides @Singleton + fun authRepository( + network: NetworkModule, + session: SessionStore, + features: FeatureStore, + bookClub: BookClubRepository, + 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 545942c..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 @@ -25,6 +25,7 @@ class AppViewModel @Inject constructor( ) : 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 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 0f2545e..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 @@ -39,6 +39,7 @@ fun MainScaffold( onOpenNotifications: () -> Unit, onOpenContact: () -> Unit, onOpenMyReviews: () -> Unit, + onOpenBookClub: () -> Unit, ) { val app: AppViewModel = hiltViewModel() val features by app.features.collectAsStateWithLifecycle() @@ -95,6 +96,7 @@ fun MainScaffold( onOpenNotifications = onOpenNotifications, onOpenContact = onOpenContact, onOpenMyReviews = onOpenMyReviews, + onOpenBookClub = onOpenBookClub, ) } } 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..c17ad7c --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/bookclub/BookClubHomeScreen.kt @@ -0,0 +1,292 @@ +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.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 +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.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.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() + 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, + 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 -> + // 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) { + 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, DateFormat.dateTime(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..d58f903 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/bookclub/BookClubHomeViewModel.kt @@ -0,0 +1,98 @@ +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.network.ErrorCodes +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, + /** 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 +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() + // 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, + ) + } + } + 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, + ) + } + } + } + } + } + + 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 new file mode 100644 index 0000000..b2e04d3 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/bookclub/BookClubUi.kt @@ -0,0 +1,46 @@ +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 + +/** + * 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) +} + +/** 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..a7c544d --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/bookclub/ClubDetailScreen.kt @@ -0,0 +1,522 @@ +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.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 +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 -> + 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, + onJoin = vm::join, + onVote = vm::vote, + onRsvp = vm::rsvp, + onUpdateProgress = { book -> progressBook = book }, + onOpenPollWeb = { poll -> openWeb(context, vm.pollWebUrl(poll.id)) }, + onOpenVideo = { url -> openWeb(context, url) }, + ) + } + } + } + + 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, + onOpenPollWeb: (ClubPoll) -> Unit, + onOpenVideo: (String) -> Unit, +) { + 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 = { onOpenPollWeb(poll) }, + ) + } + } + + 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 = { onOpenVideo(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()) } + // 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, + 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 (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) + 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 && open && !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(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) + } + 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)) { + // 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") } + } + } + } + } +} + +@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 = { + 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..a3e3036 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/bookclub/ClubDetailViewModel.kt @@ -0,0 +1,147 @@ +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.network.ErrorCodes +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, + /** The plugin was deactivated server-side (confirmed via health re-probe). */ + val pluginGone: Boolean = false, +) + +@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() + + /** 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) } + + 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 -> { + // 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, + ) + } + } + } + } + } + + 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/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 4489714..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 @@ -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,7 @@ fun ProfileScreen( onOpenNotifications: () -> Unit, onOpenContact: () -> Unit, onOpenMyReviews: () -> Unit, + onOpenBookClub: () -> Unit, ) { val app: AppViewModel = hiltViewModel() val vm: ProfileViewModel = hiltViewModel() @@ -103,9 +105,11 @@ fun ProfileScreen( onOpenNotifications = onOpenNotifications, onOpenContact = onOpenContact, onOpenMyReviews = onOpenMyReviews, + onOpenBookClub = onOpenBookClub, showNotifications = features.notifications, showContact = features.messages, showReviews = features.showReviews, + showBookClub = features.bookClubAvailable, ) } } @@ -151,9 +155,11 @@ private fun ProfileContent( onOpenNotifications: () -> Unit, onOpenContact: () -> Unit, onOpenMyReviews: () -> Unit, + onOpenBookClub: () -> Unit, showNotifications: Boolean, showContact: Boolean, showReviews: Boolean, + showBookClub: Boolean, ) { Column( Modifier @@ -204,6 +210,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/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 7b0bf2b..3bc71ad 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -340,5 +340,63 @@ "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_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", + "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", + "book_club_error_dashboard": "Dein Lesebereich konnte nicht geladen werden. Zum Aktualisieren ziehen." } diff --git a/i18n/en.json b/i18n/en.json index 1e5d331..81abf84 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -340,5 +340,63 @@ "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_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", + "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", + "book_club_error_dashboard": "Couldn't load your reading section. Pull to refresh." } diff --git a/i18n/fr.json b/i18n/fr.json index df83e2a..f5d7a3f 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -340,5 +340,63 @@ "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_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", + "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é", + "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 b8fcc4b..5e8293d 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -340,5 +340,63 @@ "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_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", + "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", + "book_club_error_dashboard": "Impossibile caricare la sezione delle tue letture. Trascina per aggiornare." }