Skip to content
Merged
12 changes: 12 additions & 0 deletions STATUS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Pinakes Android — Build Status

## Unreleased: alignment with Pinakes 0.9.0 (Mobile API 1.9.x)

Pairs with Pinakes 0.9.0 (Mobile API 1.9.0 for `closed_dates`, `loanable`, `reservations_enabled`, `/me/movements` and the new 409 codes; Mobile API 1.9.1 for the `/health` toggles `fines`, `closures_calendar` and `circulation_rules`). Works with older servers: every field is optional, the three flags default to off, and their absence keeps the 1.10.0 behaviour.

- **Feature toggles**: `/health.features.fines`, `closures_calendar` and `circulation_rules` are read and stored with the other flags; catalogue mode turns all three off whatever the server sends.
- **Closures calendar**: `closed_dates` paints closed days grey (legend "Library closed", only when the library has closures), they cannot be picked as a start, a tap says the library is closed, the pre-selected day skips them, and a proposed return date always lands on an open day (the default return moves forward as the server does; a shortened one sent as `end_date` moves back, never past the booked day). Same calendar for the reader and for the staff loan desk.
- **Consultation only and closed waitlists**: with `circulation_rules` on, the book page reads `loanable` and `reservations_enabled` from the availability payload; the loan button stays visible but disabled, with the reason under it ("Consultation only", or no reservations with this card). A 409 `not_loanable` / `reservations_disabled` turns the button the same way at once.
- **Refusal codes**: `not_loanable`, `reservations_disabled`, `reservation_limit_reached`, `media_limit_reached`, `max_loans_reached` and `not_eligible` with `meta.reason` `debt_block` / `card_expired` / `user_suspended` have their own wording for readers and staff; unknown codes keep the server message. Staff borrower pages and the new-loan form name the eligibility reason in the app's words.
- **Balance and movements** (Profile, only with `features.fines`): balance, the "loans blocked" banner with the library's threshold, fines still accruing on overdue loans, and the ledger. A 404 `feature_disabled` closes the page without an error.
- Additive, shown when the server sends them: `patron_type` on the profile, `consultation_only` on a staff copy.
- No change to the local cache database (Room version 3).

## 1.10.0 (versionCode 24): loan requests bounded by a scheduled loan

Pairs with Pinakes 0.8.7 (Mobile API 1.8.2). Works with older servers: every new field is optional and its absence keeps the 1.9.0 behaviour.
Expand Down
2 changes: 1 addition & 1 deletion _contract/health-sample.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"data":{"name":"Pinakes","logo":null,"version":"0.7.20.2","api_version":"v1","features":{"catalog":true,"loans":true,"reservations":true,"wishlist":true,"messages":true,"notifications":true,"push":true},"catalogue_mode":false,"app_access_enabled":true,"registration_enabled":true,"private_mode":false,"vapid_public_key":"BAI9Ljyok0x691trnj9VsLYnPPGG651bXV9UDXM-TwKViUOWm9l3HPfRfXodc8-YaR7rMCc6fjrZKJv9ZBZBunE"},"meta":{"https":false,"warning":"insecure_transport"},"error":null}
{"data":{"name":"Pinakes","logo":null,"version":"0.9.0","api_version":"v1","features":{"catalog":true,"loans":true,"reservations":true,"wishlist":true,"messages":true,"notifications":true,"push":true,"reviews":true,"archives":true,"desiderata":true,"staff_catalog":true,"catalog_sync":true,"calendar_feed":true,"fines":false,"closures_calendar":true,"circulation_rules":true,"staff_circulation":true,"staff_copies":true,"staff_series":true,"staff_merges":true,"staff_labels":true,"staff_imports":true,"staff_covers":true,"staff_desiderata":true,"staff_archives":true,"staff_periodicals":true,"staff_bookclub":true,"staff_frbr":true,"staff_digital_library":true,"staff_librarything":true},"catalogue_mode":false,"loan_approval_required":true,"app_access_enabled":true,"registration_enabled":true,"registration":{"require_cognome":true,"require_telefono":true,"require_indirizzo":true,"show_sesso":true,"show_cod_fiscale":true,"custom_fields":[]},"private_mode":false,"vapid_public_key":"BE1SJEHx27wB0gDE4cx6BRrW6HRbSpDuJoGXpMaRMRsZ-7sKEQpOOk1XEy0TBIdiP-ZPF54Hmw5wNsoJI22xWdA"},"meta":{"https":false,"warning":"insecure_transport"},"error":null}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class DigitalLinkRefreshFlowTest : AppFlowTest() {
lateinit var vm: BookDetailViewModel
instrumentation.runOnMainSync {
vm = BookDetailViewModel(SavedStateHandle(mapOf(Routes.ARG_BOOK_ID to 1)),
deps.catalogRepository(), deps.libraryRepository(), deps.wishlistRepository(), deps.connectivity())
deps.catalogRepository(), deps.libraryRepository(), deps.wishlistRepository(), deps.connectivity(), deps.featureStore())
store.put("digital", vm)
}
compose.waitUntil(10_000) { vm.state.value.content is UiState.Success }
Expand Down
23 changes: 23 additions & 0 deletions app/src/main/java/com/pinakes/app/data/model/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ data class HealthFeatures(
@SerialName("staff_frbr") val staffFrbr: Boolean = false,
@SerialName("staff_digital_library") val staffDigitalLibrary: Boolean = false,
@SerialName("staff_librarything") val staffLibraryThing: Boolean = false,
// Pinakes 0.9.0 feature toggles (Mobile API 1.9.1), each already false in catalogue mode.
// Older servers omit them: no fines ledger, no closures calendar, no patron-type rules.
val fines: Boolean = false,
@SerialName("closures_calendar") val closuresCalendar: Boolean = false,
@SerialName("circulation_rules") val circulationRules: Boolean = false,
)

// ---------- Auth ----------
Expand Down Expand Up @@ -231,6 +236,8 @@ data class UserProfile(
@SerialName("custom_fields") val customFields: List<CustomFieldValue> = emptyList(),
/** Fields the library hides (`sesso`, `cod_fiscale`); their keys are omitted from `/me`. */
@SerialName("hidden_fields") val hiddenFields: List<String> = emptyList(),
/** The reader's patron type (Pinakes 0.9.0 circulation rules); null when the server omits it. */
@SerialName("patron_type") val patronType: PatronTypeRef? = null,
) {
val fullName: String get() = listOf(nome, cognome).filter { it.isNotBlank() }.joinToString(" ")
}
Expand Down Expand Up @@ -510,6 +517,13 @@ data class ReservationItem(
@SerialName("expires_at") val expiresAt: String? = null,
)

/** A patron type (`tipologia lettore`) as `/me` names it. */
@Serializable
data class PatronTypeRef(
val code: String? = null,
val name: String? = null,
)

// ---------- Availability calendar ----------
// Mirrors GET /catalog/books/{id}/availability — ~180 days of per-day availability
// used to drive the colored loan-request calendar (matches the website).
Expand All @@ -524,6 +538,15 @@ data class AvailabilityCalendar(
@SerialName("loan_duration_days") val loanDurationDays: Int? = null,
// Longest loan POST /reservations accepts through end_date (Mobile API 1.8.2); null on older servers.
@SerialName("max_loan_duration_days") val maxLoanDurationDays: Int? = null,
// Closures calendar (Mobile API 1.9.0): days the library is closed. Not a start/pickup
// day; a return date on one moves to the next open day. Empty on older servers.
@SerialName("closed_dates") val closedDates: List<String> = emptyList(),
// Circulation rules of the reader's patron type (Mobile API 1.9.0). False `loanable` =
// consultation only (POST /reservations answers 409 not_loanable); false
// `reservations_enabled` = no waitlist (409 reservations_disabled). Older servers omit
// both, which reads as the behaviour they always had.
val loanable: Boolean = true,
@SerialName("reservations_enabled") val reservationsEnabled: Boolean = true,
)

@Serializable
Expand Down
66 changes: 66 additions & 0 deletions app/src/main/java/com/pinakes/app/data/model/MovementsModels.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.pinakes.app.data.model

import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames

/**
* `GET /me/movements` (Mobile API 1.9.0, Pinakes 0.9.0 fines ledger): the reader's balance
* (positive = owed to the library), the fines still accruing on overdue loans and the ledger
* entries, newest first. Every field is optional so a partial payload never crashes the page;
* a server without the ledger answers 404 `feature_disabled` and the page is not offered.
*/
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class MovementsPayload(
val currency: String? = null,
/** Null when the server omits it: [balanceTotalCents] then reads [balance]. */
@SerialName("balance_cents") val balanceCents: Int? = null,
/** Decimal string in [currency] ("4.50"), the fallback when [balanceCents] is missing. */
val balance: String? = null,
/** Null when the server omits it: [accruedTotalCents] then adds up [accrued]. */
@SerialName("accrued_cents") val accruedCents: Int? = null,
val accrued: List<AccruedFine> = emptyList(),
@SerialName("debt_block_threshold_cents") val debtBlockThresholdCents: Int? = null,
@SerialName("debt_blocked") val debtBlocked: Boolean = false,
/** The server names the list `items`; the 0.9.0 plan called it `movements`: both are read. */
@JsonNames("movements") val items: List<Movement> = emptyList(),
) {
/** The fines still accruing: the server's total, else the sum of its rows (an explicit 0 stays 0). */
val accruedTotalCents: Int get() = accruedCents ?: accrued.sumOf { it.amountCents }

/** The balance in cents: `balance_cents`, else the decimal `balance` ("4.50", "-2,00"), else 0. */
val balanceTotalCents: Int get() = balanceCents ?: decimalToCents(balance) ?: 0

private companion object {
fun decimalToCents(raw: String?): Int? = raw?.trim()?.replace(',', '.')?.takeIf { it.isNotEmpty() }
?.let { runCatching { java.math.BigDecimal(it).movePointRight(2).setScale(0, java.math.RoundingMode.HALF_UP).intValueExact() }.getOrNull() }
}
}

/** One ledger entry: a charge (positive amount), a payment, waiver or reversal (negative). */
@Serializable
data class Movement(
val id: Int = 0,
/** multa_ritardo | addebito_perdita | addebito_danno | addebito_manuale | pagamento | condono | storno */
val type: String = "",
@SerialName("type_label") val typeLabel: String? = null,
@SerialName("amount_cents") val amountCents: Int = 0,
val amount: String? = null,
val description: String? = null,
@SerialName("payment_method") val paymentMethod: String? = null,
@SerialName("loan_id") val loanId: Int? = null,
@SerialName("book_title") val bookTitle: String? = null,
@SerialName("created_at") val createdAt: String? = null,
)

/** A late fine still growing on an overdue loan: charged only when the book comes back. */
@Serializable
data class AccruedFine(
@SerialName("loan_id") val loanId: Int = 0,
val title: String? = null,
@SerialName("due_at") val dueAt: String? = null,
@SerialName("days_late") val daysLate: Int = 0,
@SerialName("amount_cents") val amountCents: Int = 0,
)
2 changes: 2 additions & 0 deletions app/src/main/java/com/pinakes/app/data/model/StaffModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ data class StaffCopy(
val available: Boolean? = null,
/** Copy-code lookup only: the open loan on this copy. */
val loan: StaffLoan? = null,
/** Kept for consultation in the library (Pinakes 0.9.0); absent on older servers. */
@SerialName("consultation_only") val consultationOnly: Boolean = false,
)

@Serializable
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ interface PinakesApi {
@GET("me/loans")
suspend fun loans(): Envelope<LoansData>

/** Balance and ledger of the fines module (Mobile API 1.9.0); 404 `feature_disabled` when off. */
@GET("me/movements")
suspend fun movements(): Envelope<com.pinakes.app.data.model.MovementsPayload>

@GET("me/reservations")
suspend fun reservations(): Envelope<List<ReservationItem>>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ class LibraryRepository(
return res
}

/** The reader's balance and movements (fines ledger). Not cached: money must be current. */
suspend fun movements(): ApiResult<com.pinakes.app.data.model.MovementsPayload> {
val api = network.api()
return apiCall { api.movements() }
}

/** The last loans the server returned, or null when nothing was ever cached. */
suspend fun loansCached(): LoansData? {
val rows = cache?.loans(CachedLoan.LOAN_BUCKETS) ?: return null
Expand Down
27 changes: 25 additions & 2 deletions app/src/main/java/com/pinakes/app/data/staff/StaffErrors.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ object StaffErrors {
"not_eligible" -> R.string.staff_err_not_eligible
"not_available" -> R.string.staff_err_not_available
"max_loans_reached" -> R.string.staff_err_max_loans
// Circulation rules of the borrower's patron type (Pinakes 0.9.0).
"media_limit_reached" -> R.string.staff_err_media_limit
"not_loanable" -> R.string.staff_err_not_loanable
"reservations_disabled" -> R.string.staff_err_reservations_disabled
"reservation_limit_reached" -> R.string.staff_err_reservation_limit
"loan_closed" -> R.string.staff_err_loan_closed
"concurrent_retry" -> R.string.staff_err_concurrent_retry
"not_returnable" -> R.string.staff_err_not_returnable
Expand All @@ -43,7 +48,25 @@ object StaffErrors {
else -> null
}

/** The resource for [failure], when its code is one of ours. */
/**
* The resource for [failure], when its code is one of ours. A 409 `not_eligible` carries the
* core reason in `meta.reason` (`debt_block`, `card_expired`, `user_suspended`): the precise
* wording when we know it, the generic one otherwise.
*/
@StringRes
fun messageRes(failure: ApiResult.Failure): Int? = messageRes(failure.code)
fun messageRes(failure: ApiResult.Failure): Int? =
if (failure.code == "not_eligible") eligibilityRes(failure.meta?.reason) ?: messageRes(failure.code)
else messageRes(failure.code)

/**
* Wording for an `eligibility_reason` (user lists, loan create) or a `not_eligible` reason;
* null for a reason this app version does not know (the caller shows the raw value).
*/
@StringRes
fun eligibilityRes(reason: String?): Int? = when (reason) {
"debt_block" -> R.string.staff_err_debt_block
"card_expired" -> R.string.staff_err_card_expired
"user_suspended" -> R.string.staff_err_user_suspended
else -> null
}
}
Loading
Loading