Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
```
Expand Down
55 changes: 55 additions & 0 deletions STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
254 changes: 254 additions & 0 deletions app/src/main/java/com/pinakes/app/data/model/BookClubModels.kt
Original file line number Diff line number Diff line change
@@ -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<T>(
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<MyClub> = emptyList(),
val directory: List<DirectoryClub> = 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<WorkflowState> = emptyList(),
val books: List<ClubBook> = emptyList(),
val polls: List<ClubPoll> = emptyList(),
val meetings: List<ClubMeeting> = 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<Int> = emptyList(),
val options: List<PollOption> = 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<DashboardCard> = emptyList(),
)

@Serializable
data class DashboardCard(
val club: DashboardClub = DashboardClub(),
@SerialName("current_books") val currentBooks: List<DashboardBook> = emptyList(),
@SerialName("next_meeting") val nextMeeting: DashboardMeeting? = null,
@SerialName("open_polls") val openPolls: List<DashboardPoll> = 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<Int>)

@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<Unit>; only join's status is consumed.
@Serializable
data class JoinResult(val status: String = "") // active | pending
5 changes: 3 additions & 2 deletions app/src/main/java/com/pinakes/app/data/network/ApiResult.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ sealed interface ApiResult<out T> {
/** 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"
Expand Down Expand Up @@ -105,7 +106,7 @@ suspend fun <T> apiResponse(block: suspend () -> Response<Envelope<T>>): Pair<Ap
private fun ApiError.toFailure(httpStatus: Int): ApiResult.Failure =
ApiResult.Failure(code = code, message = message, httpStatus = httpStatus)

private fun HttpException.toFailure(): ApiResult.Failure {
internal fun HttpException.toFailure(): ApiResult.Failure {
val status = code()
val retryAfter = response()?.headers()?.get("Retry-After")?.toLongOrNull()
val body = try { response()?.errorBody()?.string() } catch (_: Throwable) { null }
Expand Down
Loading
Loading