diff --git a/STATUS.md b/STATUS.md index 529dd78..5fce723 100644 --- a/STATUS.md +++ b/STATUS.md @@ -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. diff --git a/_contract/health-sample.json b/_contract/health-sample.json index a1cde2f..962e2ed 100644 --- a/_contract/health-sample.json +++ b/_contract/health-sample.json @@ -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} \ No newline at end of file diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/DigitalLinkRefreshFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/DigitalLinkRefreshFlowTest.kt index 4c59b84..668fe29 100644 --- a/app/src/androidTest/java/com/pinakes/app/staffe2e/DigitalLinkRefreshFlowTest.kt +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/DigitalLinkRefreshFlowTest.kt @@ -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 } diff --git a/app/src/main/java/com/pinakes/app/data/model/Models.kt b/app/src/main/java/com/pinakes/app/data/model/Models.kt index eddfc99..014acad 100644 --- a/app/src/main/java/com/pinakes/app/data/model/Models.kt +++ b/app/src/main/java/com/pinakes/app/data/model/Models.kt @@ -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 ---------- @@ -231,6 +236,8 @@ data class UserProfile( @SerialName("custom_fields") val customFields: List = emptyList(), /** Fields the library hides (`sesso`, `cod_fiscale`); their keys are omitted from `/me`. */ @SerialName("hidden_fields") val hiddenFields: List = 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(" ") } @@ -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). @@ -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 = 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 diff --git a/app/src/main/java/com/pinakes/app/data/model/MovementsModels.kt b/app/src/main/java/com/pinakes/app/data/model/MovementsModels.kt new file mode 100644 index 0000000..c57e59b --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/model/MovementsModels.kt @@ -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 = 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 = 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, +) diff --git a/app/src/main/java/com/pinakes/app/data/model/StaffModels.kt b/app/src/main/java/com/pinakes/app/data/model/StaffModels.kt index 9af4550..a453c23 100644 --- a/app/src/main/java/com/pinakes/app/data/model/StaffModels.kt +++ b/app/src/main/java/com/pinakes/app/data/model/StaffModels.kt @@ -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 diff --git a/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt b/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt index 31ef01c..9ef8cce 100644 --- a/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt +++ b/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt @@ -159,6 +159,10 @@ interface PinakesApi { @GET("me/loans") suspend fun loans(): Envelope + /** Balance and ledger of the fines module (Mobile API 1.9.0); 404 `feature_disabled` when off. */ + @GET("me/movements") + suspend fun movements(): Envelope + @GET("me/reservations") suspend fun reservations(): Envelope> diff --git a/app/src/main/java/com/pinakes/app/data/repository/LibraryRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/LibraryRepository.kt index 27efa62..a6762e3 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/LibraryRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/LibraryRepository.kt @@ -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 { + 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 diff --git a/app/src/main/java/com/pinakes/app/data/staff/StaffErrors.kt b/app/src/main/java/com/pinakes/app/data/staff/StaffErrors.kt index 5f42665..ac3436b 100644 --- a/app/src/main/java/com/pinakes/app/data/staff/StaffErrors.kt +++ b/app/src/main/java/com/pinakes/app/data/staff/StaffErrors.kt @@ -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 @@ -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 + } } 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 97bbc8d..ddef1c1 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 @@ -70,6 +70,19 @@ data class InstanceFeatures( val staffFrbrAvailable: Boolean = false, val staffDigitalLibraryAvailable: Boolean = false, val staffLibraryThingAvailable: Boolean = false, + /** + * Pinakes 0.9.0 toggles (`features.fines`, `closures_calendar`, `circulation_rules`). Hidden + * until a server advertises them: an older server has no ledger, no closures calendar and + * no patron-type rules, and the app then behaves exactly as before. + */ + val finesAvailable: Boolean = false, + /** + * Informational: the calendar honours `closed_dates` whatever this says, because the server + * already sends an empty list with the toggle off, and a Mobile API 1.9.0 server sends closures + * before the flag existed (ignoring them would offer days the server then moves). + */ + val closuresCalendarAvailable: Boolean = false, + val circulationRulesAvailable: Boolean = false, ) { /** Library tab (loans + reservations) is shown only when at least one of them is enabled. */ val showLibrary: Boolean get() = loans || reservations @@ -82,9 +95,60 @@ data class InstanceFeatures( /** Book reviews (read + write) surfaced on book detail and the "My reviews" page. */ val showReviews: Boolean get() = reviews + /** The balance and movements page (fines ledger on, loans offered). */ + val showMovements: Boolean get() = finesAvailable && loans && !catalogueMode + companion object { /** Safe default before/without `/health`: app features enabled, registration hidden. */ val AllEnabled = InstanceFeatures() + + /** + * The flags a `/health` payload grants. Pure, so the mapping is unit-tested without the + * encrypted preferences; the plugin probes ([bookClub], [periodicals]) keep their own value. + */ + fun from(health: HealthPayload, bookClub: Boolean = false, periodicals: Boolean = false): InstanceFeatures { + val f = health.features + val app = health.appAccessEnabled + val circulation = !health.catalogueMode + return InstanceFeatures( + catalogueMode = health.catalogueMode, + catalog = f.catalog, + loans = f.loans, + reservations = f.reservations, + wishlist = f.wishlist, + messages = f.messages, + notifications = f.notifications, + push = f.push, + reviews = f.reviews, + registrationEnabled = health.registrationEnabled, + bookClubAvailable = bookClub, + periodicalsAvailable = periodicals, + archivesAvailable = f.archives && app, + desiderataAvailable = f.desiderata && app, + staffCatalogAvailable = f.staffCatalog && app, + catalogSyncAvailable = f.catalogSync && app, + calendarFeedAvailable = f.calendarFeed && app && circulation, + staffCirculationAvailable = f.staffCirculation && app && circulation, + staffCopiesAvailable = f.staffCopies && app, + staffSeriesAvailable = f.staffSeries && app, + staffMergesAvailable = f.staffMerges && app, + staffLabelsAvailable = f.staffLabels && app, + staffImportsAvailable = f.staffImports && app, + staffCoversAvailable = f.staffCovers && app, + staffDesiderataAvailable = f.staffDesiderata && app, + staffArchivesAvailable = f.staffArchives && app, + staffPeriodicalsAvailable = f.staffPeriodicals && app, + staffBookClubAvailable = f.staffBookClub && app, + staffFrbrAvailable = f.staffFrbr && app, + staffDigitalLibraryAvailable = f.staffDigitalLibrary && app, + staffLibraryThingAvailable = f.staffLibraryThing && app, + // The server already turns these off in catalogue mode; the app does too, so a + // stale or inconsistent payload can never show circulation in a read-only catalogue. + finesAvailable = f.fines && app && circulation, + closuresCalendarAvailable = f.closuresCalendar && app && circulation, + circulationRulesAvailable = f.circulationRules && app && circulation, + ) + } } } @@ -112,39 +176,10 @@ class FeatureStore(context: Context) { * [setPeriodicalsAvailable]) and are preserved, not clobbered. */ fun update(health: HealthPayload) { - val f = health.features - val value = InstanceFeatures( - catalogueMode = health.catalogueMode, - catalog = f.catalog, - loans = f.loans, - reservations = f.reservations, - wishlist = f.wishlist, - messages = f.messages, - notifications = f.notifications, - push = f.push, - reviews = f.reviews, - registrationEnabled = health.registrationEnabled, - bookClubAvailable = prefs.getBoolean(KEY_BOOK_CLUB, false), - periodicalsAvailable = prefs.getBoolean(KEY_PERIODICALS, false), - archivesAvailable = f.archives && health.appAccessEnabled, - desiderataAvailable = f.desiderata && health.appAccessEnabled, - staffCatalogAvailable = f.staffCatalog && health.appAccessEnabled, - catalogSyncAvailable = f.catalogSync && health.appAccessEnabled, - calendarFeedAvailable = f.calendarFeed && health.appAccessEnabled && !health.catalogueMode, - staffCirculationAvailable = f.staffCirculation && health.appAccessEnabled && !health.catalogueMode, - staffCopiesAvailable = f.staffCopies && health.appAccessEnabled, - staffSeriesAvailable = f.staffSeries && health.appAccessEnabled, - staffMergesAvailable = f.staffMerges && health.appAccessEnabled, - staffLabelsAvailable = f.staffLabels && health.appAccessEnabled, - staffImportsAvailable = f.staffImports && health.appAccessEnabled, - staffCoversAvailable = f.staffCovers && health.appAccessEnabled, - staffDesiderataAvailable = f.staffDesiderata && health.appAccessEnabled, - staffArchivesAvailable = f.staffArchives && health.appAccessEnabled, - staffPeriodicalsAvailable = f.staffPeriodicals && health.appAccessEnabled, - staffBookClubAvailable = f.staffBookClub && health.appAccessEnabled, - staffFrbrAvailable = f.staffFrbr && health.appAccessEnabled, - staffDigitalLibraryAvailable = f.staffDigitalLibrary && health.appAccessEnabled, - staffLibraryThingAvailable = f.staffLibraryThing && health.appAccessEnabled, + val value = InstanceFeatures.from( + health, + bookClub = prefs.getBoolean(KEY_BOOK_CLUB, false), + periodicals = prefs.getBoolean(KEY_PERIODICALS, false), ) prefs.edit() .putBoolean(KEY_KNOWN, true) @@ -177,6 +212,9 @@ class FeatureStore(context: Context) { .putBoolean(KEY_STAFF_FRBR, value.staffFrbrAvailable) .putBoolean(KEY_STAFF_DIGITAL, value.staffDigitalLibraryAvailable) .putBoolean(KEY_STAFF_LIBRARYTHING, value.staffLibraryThingAvailable) + .putBoolean(KEY_FINES, value.finesAvailable) + .putBoolean(KEY_CLOSURES_CALENDAR, value.closuresCalendarAvailable) + .putBoolean(KEY_CIRCULATION_RULES, value.circulationRulesAvailable) .apply() _features.value = value } @@ -236,6 +274,9 @@ class FeatureStore(context: Context) { staffFrbrAvailable = prefs.getBoolean(KEY_STAFF_FRBR, false), staffDigitalLibraryAvailable = prefs.getBoolean(KEY_STAFF_DIGITAL, false), staffLibraryThingAvailable = prefs.getBoolean(KEY_STAFF_LIBRARYTHING, false), + finesAvailable = prefs.getBoolean(KEY_FINES, false), + closuresCalendarAvailable = prefs.getBoolean(KEY_CLOSURES_CALENDAR, false), + circulationRulesAvailable = prefs.getBoolean(KEY_CIRCULATION_RULES, false), ) } @@ -271,5 +312,8 @@ class FeatureStore(context: Context) { private const val KEY_STAFF_FRBR = "f_staff_frbr" private const val KEY_STAFF_DIGITAL = "f_staff_digital_library" private const val KEY_STAFF_LIBRARYTHING = "f_staff_librarything" + private const val KEY_FINES = "f_fines" + private const val KEY_CLOSURES_CALENDAR = "f_closures_calendar" + private const val KEY_CIRCULATION_RULES = "f_circulation_rules" } } 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 3e1f878..605ee93 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 @@ -44,6 +44,7 @@ fun MainScaffold( onOpenNotifications: () -> Unit, onOpenContact: () -> Unit, onOpenMyReviews: () -> Unit, + onOpenMovements: () -> Unit = {}, onOpenBookClub: () -> Unit, onOpenPeriodicals: () -> Unit, onOpenArticles: () -> Unit, @@ -122,6 +123,7 @@ fun MainScaffold( onOpenNotifications = onOpenNotifications, onOpenContact = onOpenContact, onOpenMyReviews = onOpenMyReviews, + onOpenMovements = onOpenMovements, onOpenBookClub = onOpenBookClub, onOpenPeriodicals = onOpenPeriodicals, onOpenArticles = onOpenArticles, 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 d37fca1..d961bf5 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 @@ -42,6 +42,7 @@ import com.pinakes.app.ui.screens.periodicals.StandaloneArticlesScreen import com.pinakes.app.ui.screens.periodicals.StandaloneArticleScreen import com.pinakes.app.ui.screens.periodicals.PeriodicalsScreen import com.pinakes.app.ui.screens.reviews.MyReviewsScreen +import com.pinakes.app.ui.screens.movements.MovementsScreen import com.pinakes.app.ui.screens.staff.StaffAuthorsScreen import com.pinakes.app.ui.screens.staff.StaffBookEditScreen import com.pinakes.app.ui.screens.staff.StaffBooksScreen @@ -153,6 +154,7 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { onOpenNotifications = { navController.navigate(Routes.NOTIFICATIONS) }, onOpenContact = { navController.navigate(Routes.CONTACT) }, onOpenMyReviews = { navController.navigate(Routes.MY_REVIEWS) }, + onOpenMovements = { navController.navigate(Routes.MOVEMENTS) }, onOpenBookClub = { navController.navigate(Routes.BOOK_CLUB) }, onOpenPeriodicals = { navController.navigate(Routes.PERIODICALS) }, onOpenArticles = { navController.navigate(Routes.standaloneArticles()) }, @@ -489,6 +491,14 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { ) } + composable( + Routes.MOVEMENTS, + enterTransition = slideIn, + popExitTransition = slideOut, + ) { + MovementsScreen(onNavigateUp = { navController.popBackStack() }) + } + composable( Routes.BOOK_CLUB, enterTransition = slideIn, 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 34f627d..dbb0d9e 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 @@ -20,6 +20,8 @@ object Routes { const val NOTIFICATIONS = "notifications" const val CONTACT = "contact" const val MY_REVIEWS = "my-reviews" + /** Balance and movements of the fines ledger (Pinakes 0.9.0, `features.fines`). */ + const val MOVEMENTS = "movements" const val BOOK_DETAIL = "book/{bookId}" fun bookDetail(bookId: Int): String = "book/$bookId" diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt index d2d9986..76c2c0d 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt @@ -202,6 +202,7 @@ fun BookDetailScreen( showWishlist = features.showWishlist && !state.offlineNow, showReviews = features.showReviews && !state.offlineNow, onReserve = vm::openLoanSheet, + rules = state.rules, onToggleWishlist = vm::toggleWishlist, onFindWorks = onFindWorks, onRefreshFile = vm::refreshDigitalFile, @@ -276,8 +277,9 @@ internal fun LoanRequestSheetContent( today: LocalDate? = null, ) { // Pre-select earliest_available when offered, else null until the user picks. + // A closed day is never offered: the first requestable day from it (closures calendar). var selectedIso by remember(calendar) { - mutableStateOf(calendar?.earliestAvailable?.takeIf { it.isNotBlank() }) + mutableStateOf(calendar?.earliestAvailable?.takeIf { it.isNotBlank() }?.let { calendar.firstRequestableDay(it) }) } // A fully-booked day the reader tapped: explained under the calendar until the next pick. var blockedIso by remember(calendar) { mutableStateOf(null) } @@ -384,7 +386,10 @@ internal fun LoanRequestSheetContent( ) } } - val blockedText = blockedIso?.let { stringResource(R.string.book_loan_blocked_day, formatDisplayDate(it)) } + val blockedText = blockedIso?.let { + if (calendar?.isClosed(it) == true) stringResource(R.string.book_loan_closed_day, formatDisplayDate(it)) + else stringResource(R.string.book_loan_blocked_day, formatDisplayDate(it)) + } val explanation = listOfNotNull(blockedText, limitText).joinToString(" ") if (explanation.isNotEmpty()) { Text( @@ -428,6 +433,8 @@ internal fun DetailContent( onReserve: () -> Unit, onToggleWishlist: () -> Unit, onShowMessage: (String) -> Unit, + /** The reader's circulation rules for this title (Pinakes 0.9.0); null = not known, usual button. */ + rules: ViewerRules? = null, onFindWorks: (String, Int?) -> Unit = { _, _ -> }, /** Revalidates a signed file on the server, preserving this page while the request runs. */ onRefreshFile: suspend (DigitalAttachment) -> ApiResult = { @@ -585,12 +592,34 @@ internal fun DetailContent( if (showLoanButton || showWishlist) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { if (showLoanButton) { + // Consultation only, or no waitlist for this reader's patron type: the button + // stays, disabled, with the reason under it (it does not just disappear). + val action = CirculationRules.loanAction(book.available, rules) PrimaryButton( - label = if (book.available) stringResource(R.string.book_request_loan) else stringResource(R.string.book_reserve), + label = when (action) { + LoanAction.Request -> stringResource(R.string.book_request_loan) + LoanAction.Reserve -> stringResource(R.string.book_reserve) + LoanAction.ConsultationOnly -> stringResource(R.string.book_consultation_only) + LoanAction.ReservationsDisabled -> stringResource(R.string.book_reserve) + }, onClick = onReserve, + enabled = CirculationRules.canRequest(action), loading = reserveBusy, modifier = Modifier.fillMaxWidth().testTag("detail-loan"), ) + val reason = when (action) { + LoanAction.ConsultationOnly -> stringResource(R.string.book_consultation_only_hint) + LoanAction.ReservationsDisabled -> stringResource(R.string.book_reservations_disabled_hint) + else -> null + } + if (reason != null) { + Text( + reason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag("detail-loan-reason"), + ) + } } if (showWishlist) { SecondaryButton( diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt index c3e43d0..52e022c 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.drop import com.pinakes.app.data.repository.CatalogRepository import com.pinakes.app.data.repository.LibraryRepository import com.pinakes.app.data.repository.WishlistRepository +import com.pinakes.app.data.store.FeatureStore import com.pinakes.app.R import com.pinakes.app.ui.common.UiState import com.pinakes.app.ui.navigation.Routes @@ -49,6 +50,11 @@ data class BookDetailUiState( val syncedAt: String? = null, /** Device connectivity: a page loaded online hides its loan actions when it drops. */ val connected: Boolean = true, + /** + * What this reader's patron type allows on the title (Pinakes 0.9.0), from the availability + * payload; null until known (older server, rules off, fetch failed): the usual button then. + */ + val rules: ViewerRules? = null, ) { /** Cached page or no connection: no loan/reservation affordance, no current availability. */ val offlineNow: Boolean get() = offline || !connected @@ -66,6 +72,7 @@ class BookDetailViewModel @Inject constructor( private val library: LibraryRepository, private val wishlist: WishlistRepository, private val connectivity: ConnectivityObserver, + private val featureStore: FeatureStore, ) : ViewModel() { // The book id arrives as a navigation argument; Hilt populates SavedStateHandle from it. @@ -95,7 +102,7 @@ class BookDetailViewModel @Inject constructor( wishlisted = res.data.personalHistory?.hasWishlisted ?: it.wishlisted, offline = false, ) - } + }.also { loadRules() } is ApiResult.Failure -> { val cached = if (res.code == ErrorCodes.NETWORK) catalog.bookOffline(bookId) else null val syncedAt = if (cached != null) catalog.lastSyncedAt() else null @@ -108,6 +115,20 @@ class BookDetailViewModel @Inject constructor( } } + /** + * With the server's circulation rules on, learn up front whether the title is consultation + * only or the waitlist is closed for this reader, so the button says so before a tap. Without + * the flag (an older server) nothing is fetched and the page behaves as it always did. + */ + private fun loadRules() { + if (!featureStore.features.value.circulationRulesAvailable) return + viewModelScope.launch { + (catalog.bookAvailability(bookId) as? ApiResult.Success)?.data?.let { cal -> + _state.update { it.copy(rules = ViewerRules.of(cal)) } + } + } + } + /** Refresh in place: a failed renewal must not replace the page or reuse a cached URL. */ suspend fun refreshDigitalFile(file: DigitalAttachment): ApiResult { return when (val result = catalog.book(bookId, forceRefresh = true)) { @@ -164,7 +185,8 @@ class BookDetailViewModel @Inject constructor( viewModelScope.launch { when (val res = catalog.bookAvailability(bookId)) { is ApiResult.Success -> _state.update { - it.copy(availabilityLoading = false, availability = res.data, availabilityFallback = false) + it.copy(availabilityLoading = false, availability = res.data, availabilityFallback = false, + rules = ViewerRules.of(res.data)) } is ApiResult.Failure -> _state.update { it.copy(availabilityLoading = false, availability = null, availabilityFallback = true) @@ -212,13 +234,17 @@ class BookDetailViewModel @Inject constructor( // A `not_available` refusal carries the server's own explanation (the // longest loan still possible, and from which day the book is booked): // that message, not the generic conflict text, is what the reader sees. + // Patron-type refusals (Pinakes 0.9.0) get our wording and turn the button + // into its disabled state; unknown codes keep the server's message. + val ownText = CirculationRules.requestErrorRes(res) + val rules = CirculationRules.rulesAfterRefusal(_state.value.rules, res.code) when { - res.code == ErrorCodes.CONFLICT -> - _state.update { it.copy(reserveBusy = false, showLoanSheet = false, snackbar = null, snackbarRes = R.string.snackbar_request_conflict) } + ownText != null -> + _state.update { it.copy(reserveBusy = false, showLoanSheet = false, snackbar = null, snackbarRes = ownText, rules = rules) } res.serverMessage() != null -> - _state.update { it.copy(reserveBusy = false, showLoanSheet = false, snackbar = res.serverMessage(), snackbarRes = null) } + _state.update { it.copy(reserveBusy = false, showLoanSheet = false, snackbar = res.serverMessage(), snackbarRes = null, rules = rules) } else -> - _state.update { it.copy(reserveBusy = false, showLoanSheet = false, snackbar = null, snackbarRes = R.string.snackbar_request_error) } + _state.update { it.copy(reserveBusy = false, showLoanSheet = false, snackbar = null, snackbarRes = R.string.snackbar_request_error, rules = rules) } } } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/CirculationRules.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/CirculationRules.kt new file mode 100644 index 0000000..a2d3e73 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/CirculationRules.kt @@ -0,0 +1,84 @@ +package com.pinakes.app.ui.screens.detail + +import androidx.annotation.StringRes +import com.pinakes.app.R +import com.pinakes.app.data.model.AvailabilityCalendar +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes + +/** + * What the reader's patron type allows on a title (Pinakes 0.9.0 circulation rules), read off + * `GET /catalog/books/{id}/availability`. Both default to the pre-0.9.0 behaviour, so an older + * server, or a page whose availability was not fetched, keeps the usual button. + */ +data class ViewerRules( + /** False: the title is consultation only for this reader. */ + val loanable: Boolean = true, + /** False: the reader's patron type cannot join a waitlist. */ + val reservationsEnabled: Boolean = true, +) { + companion object { + fun of(calendar: AvailabilityCalendar): ViewerRules = + ViewerRules(loanable = calendar.loanable, reservationsEnabled = calendar.reservationsEnabled) + } +} + +/** The loan button on the book page. */ +enum class LoanAction { + /** A copy is free now: "Request loan". */ + Request, + + /** No copy is free: "Reserve" (joins the waitlist). */ + Reserve, + + /** Consultation only for this reader: no request, a disabled button that says so. */ + ConsultationOnly, + + /** No copy is free and the reader's patron type has no reservations: nothing to ask for. */ + ReservationsDisabled, +} + +object CirculationRules { + + /** The button for a title that is [available] now, under [rules] (null = not known yet). */ + fun loanAction(available: Boolean, rules: ViewerRules?): LoanAction = when { + rules?.loanable == false -> LoanAction.ConsultationOnly + available -> LoanAction.Request + rules?.reservationsEnabled == false -> LoanAction.ReservationsDisabled + else -> LoanAction.Reserve + } + + /** True when the button opens the request sheet; false when it is shown disabled with a reason. */ + fun canRequest(action: LoanAction): Boolean = action == LoanAction.Request || action == LoanAction.Reserve + + /** What a refusal teaches about the reader's rules: the button then shows the reason at once. */ + fun rulesAfterRefusal(current: ViewerRules?, code: String): ViewerRules? = when (code) { + "not_loanable" -> (current ?: ViewerRules()).copy(loanable = false) + "reservations_disabled" -> (current ?: ViewerRules()).copy(reservationsEnabled = false) + else -> current + } + + /** + * Our wording for a refused POST /reservations, or null when the server's own message is the + * better text (an unknown code, or `not_available` with the longest loan still possible). + * `not_eligible` names its cause only when the server passes `meta.reason`; without it the + * server message (which says whether the card, the account or the balance is the reason) wins. + */ + @StringRes + fun requestErrorRes(failure: ApiResult.Failure): Int? = when (failure.code) { + "not_loanable" -> R.string.request_err_not_loanable + "reservations_disabled" -> R.string.request_err_reservations_disabled + "reservation_limit_reached" -> R.string.request_err_reservation_limit + "media_limit_reached" -> R.string.request_err_media_limit + "max_loans_reached" -> R.string.request_err_max_loans + "not_eligible" -> when (failure.meta?.reason) { + "debt_block" -> R.string.request_err_debt_block + "card_expired" -> R.string.request_err_card_expired + "user_suspended" -> R.string.request_err_suspended + else -> null + } + // The generic 409 without a server code (a proxy page): the dates clash. + ErrorCodes.CONFLICT -> R.string.snackbar_request_conflict + else -> null + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanCalendar.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanCalendar.kt index ef79bfb..ecd814e 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanCalendar.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanCalendar.kt @@ -67,7 +67,7 @@ import java.util.Locale private data class DayTint(val container: Color, val onContainer: Color, val rule: Color = Color.Transparent) /** Per-day availability state used to colour a calendar cell. */ -private enum class DayKind { Free, Partial, Full, PastOrUnknown } +internal enum class DayKind { Free, Partial, Full, Closed, PastOrUnknown } @Composable private fun tintFor(kind: DayKind, dark: Boolean): DayTint = when (kind) { @@ -78,6 +78,9 @@ private fun tintFor(kind: DayKind, dark: Boolean): DayTint = when (kind) { else ruled(DueSoonContainerLight, DueSoonOnContainerLight) DayKind.Full -> ruled(MaterialTheme.colorScheme.errorContainer, MaterialTheme.colorScheme.onErrorContainer) + // The library is closed: a neutral grey, never the red of a booked day (nothing is on loan). + DayKind.Closed -> + ruled(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant) DayKind.PastOrUnknown -> DayTint(Color.Transparent, MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)) } @@ -87,7 +90,7 @@ private fun ruled(container: Color, onContainer: Color) = DayTint(container, onContainer, mix(onContainer, container, .22f)) /** Index per-day data by ISO date for O(1) lookup, and the set of unavailable dates. */ -private class CalendarModel(private val source: AvailabilityCalendar) { +internal class CalendarModel(private val source: AvailabilityCalendar) { private val byDate: Map = source.days.associate { it.date to it.state } // date -> state private val unavailable: Set = source.unavailableDates.toSet() @@ -99,6 +102,8 @@ private class CalendarModel(private val source: AvailabilityCalendar) { fun kind(date: LocalDate, today: LocalDate): DayKind { val iso = date.toString() if (date.isBefore(today)) return DayKind.PastOrUnknown + // Closures calendar: no start or pickup on a day the library is closed. + if (source.isClosed(iso)) return DayKind.Closed // Same predicate as loanWindowFrom(): a red day is a day that caps the loan. if (source.isFullyBooked(iso)) return DayKind.Full return when (byDate[iso]) { @@ -134,7 +139,10 @@ fun LoanCalendar( anyFutureDay: Boolean = false, /** The day the grid treats as today; null reads the device clock once. Tests pin it. */ today: LocalDate? = null, - /** A fully-booked future day cannot be picked; tapping it calls this so the sheet can say why. */ + /** + * A fully-booked or closed future day cannot be picked; tapping it calls this so the sheet + * can say why (it tells the two apart with [AvailabilityCalendar.isClosed]). + */ onBlockedTap: ((isoDate: String) -> Unit)? = null, ) { val dark = LocalPinakesColors.current.darkMode @@ -171,7 +179,7 @@ fun LoanCalendar( ) if (!anyFutureDay) { Spacer(Modifier.height(Spacing.md)) - Legend(dark = dark) + Legend(dark = dark, showClosed = calendar.closedDates.isNotEmpty()) } } } @@ -316,8 +324,9 @@ private fun DayCell( .then( when { selectable -> Modifier.clickable { onSelect(iso) } - // Not pickable, but a tap must still explain why (every copy is booked). - onBlockedTap != null && kind == DayKind.Full -> Modifier.clickable { onBlockedTap(iso) } + // Not pickable, but a tap must still explain why (every copy is booked, or the + // library is closed that day). + onBlockedTap != null && (kind == DayKind.Full || kind == DayKind.Closed) -> Modifier.clickable { onBlockedTap(iso) } else -> Modifier } ) @@ -334,7 +343,7 @@ private fun DayCell( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun Legend(dark: Boolean) { +private fun Legend(dark: Boolean, showClosed: Boolean = false) { // Wraps on narrow screens and with long translations instead of running off the edge. FlowRow( Modifier.fillMaxWidth(), @@ -345,6 +354,10 @@ private fun Legend(dark: Boolean) { LegendItem(MaterialTheme.colorScheme.surface, stringResource(R.string.book_loan_legend_available), MaterialTheme.colorScheme.outline) tintFor(DayKind.Partial, dark).let { LegendItem(it.container, stringResource(R.string.book_loan_legend_limited), it.rule) } tintFor(DayKind.Full, dark).let { LegendItem(it.container, stringResource(R.string.book_loan_legend_unavailable), it.rule) } + // Only when the library publishes closures: an empty key would explain nothing. + if (showClosed) { + tintFor(DayKind.Closed, dark).let { LegendItem(it.container, stringResource(R.string.book_loan_legend_closed), it.rule) } + } } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanWindow.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanWindow.kt index 06dc6ed..4e2c53f 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanWindow.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanWindow.kt @@ -41,6 +41,43 @@ internal fun AvailabilityCalendar.isFullyBooked(isoDate: String): Boolean { return day.state == "full" || (day.state == "free" && day.available <= 0) } +/** A day the library is closed (closures calendar, Mobile API 1.9.0): never a start or a return. */ +internal fun AvailabilityCalendar.isClosed(isoDate: String): Boolean = closedDates.contains(isoDate) + +/** How far [openDayFrom] looks for an open day: closures longer than this are not real. */ +private const val MAX_CLOSED_RUN = 60 + +/** + * The nearest open day from [date] (itself when open), walking [forward] or back, or null when + * none exists within [MAX_CLOSED_RUN] days or before [notBefore] (going back). + */ +internal fun AvailabilityCalendar.openDayFrom(date: LocalDate, forward: Boolean, notBefore: LocalDate? = null): LocalDate? { + var d = date + repeat(MAX_CLOSED_RUN) { + if (notBefore != null && d.isBefore(notBefore)) return null + if (!isClosed(d.toString())) return d + d = if (forward) d.plusDays(1) else d.minusDays(1) + } + return null +} + +/** + * The day the request sheet pre-selects: [earliestIso] (the server's `earliest_available`), or + * the first day after it that is neither closed nor fully booked. Null when nothing qualifies + * in the fetched window or [earliestIso] is not a date. + */ +internal fun AvailabilityCalendar.firstRequestableDay(earliestIso: String?): String? { + val start = runCatching { LocalDate.parse(earliestIso) }.getOrNull() ?: return null + if (closedDates.isEmpty()) return earliestIso + var d = start + repeat(MAX_CLOSED_RUN) { + val iso = d.toString() + if (!isClosed(iso) && !isFullyBooked(iso)) return iso + d = d.plusDays(1) + } + return null +} + /** * The window from [startIso] (yyyy-MM-dd), or null when the duration is unknown (an older * server omits `loan_duration_days`) or the start is not a date: then the server's default @@ -56,6 +93,8 @@ internal fun loanWindowFrom(startIso: String, calendar: AvailabilityCalendar?, l if (loanDurationDays == null || loanDurationDays < 1) return null val start = runCatching { LocalDate.parse(startIso) }.getOrNull() ?: return null if (calendar?.maxLoanDurationDays == null) { + // closed_dates comes with Mobile API 1.9.0, which always sends max_loan_duration_days: + // a server without the maximum has no closures to apply here. return LoanWindow( endDate = start.plusDays(loanDurationDays.toLong()).toString(), firstBlocked = null, @@ -64,23 +103,50 @@ internal fun loanWindowFrom(startIso: String, calendar: AvailabilityCalendar?, l requiresEndDate = false, ) } - val durationDays = calendar?.maxLoanDurationDays?.takeIf { it > 0 } + // Non-null from here: maxLoanDurationDays was read off it. + val cal = calendar ?: return null + val durationDays = cal.maxLoanDurationDays?.takeIf { it > 0 } ?.let { minOf(loanDurationDays, it) } ?: loanDurationDays var firstBlocked: String? = null for (offset in 1..durationDays) { val day = start.plusDays(offset.toLong()).toString() - if (calendar?.isFullyBooked(day) == true) { + if (cal.isFullyBooked(day)) { firstBlocked = day break } } val defaultEnd = start.plusDays(durationDays.toLong()) - val end = firstBlocked?.let { LocalDate.parse(it).minusDays(1) } ?: defaultEnd + var rawEnd = firstBlocked?.let { LocalDate.parse(it).minusDays(1) } ?: defaultEnd + // A default return on a closed day moves to the next open day, as the server's due date + // does. When that walk meets a fully-booked day, the loan would run into the commitment: + // the booked day caps it, as if it lay inside the duration (the server's renewal cap does + // the same with closures). + if (firstBlocked == null && cal.isClosed(rawEnd.toString())) { + var d = rawEnd + while (cal.isClosed(d.toString()) && ChronoUnit.DAYS.between(rawEnd, d) < 60) { + if (cal.isFullyBooked(d.toString())) break + d = d.plusDays(1) + } + if (cal.isFullyBooked(d.toString())) { + firstBlocked = d.toString() + rawEnd = d.minusDays(1) + } else { + rawEnd = d + } + } + val shortened = rawEnd.isBefore(start.plusDays(loanDurationDays.toLong())) + // A shortened return (sent as end_date) cannot move past the day that shortened it, so on a + // closed day it moves back to the last open day after the start; when there is none, no + // loan fits from this start. + val end = when { + !cal.isClosed(rawEnd.toString()) -> rawEnd + else -> cal.openDayFrom(rawEnd, forward = false, notBefore = start.plusDays(1)) ?: start + } return LoanWindow( endDate = end.toString(), firstBlocked = firstBlocked, maxDays = ChronoUnit.DAYS.between(start, end).toInt(), capped = firstBlocked != null, - requiresEndDate = end.isBefore(start.plusDays(loanDurationDays.toLong())), + requiresEndDate = shortened || end.isBefore(rawEnd), ) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/movements/MovementsScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/movements/MovementsScreen.kt new file mode 100644 index 0000000..274809c --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/movements/MovementsScreen.kt @@ -0,0 +1,224 @@ +package com.pinakes.app.ui.screens.movements + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AccountBalanceWallet +import androidx.compose.material3.ExperimentalMaterial3Api +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.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.pinakes.app.R +import com.pinakes.app.data.model.AccruedFine +import com.pinakes.app.data.model.Movement +import com.pinakes.app.data.model.MovementsPayload +import com.pinakes.app.ui.common.AppLocale +import com.pinakes.app.ui.common.AppViewModel +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.components.SectionHeader +import com.pinakes.app.ui.theme.Spacing + +/** + * The reader's balance and movements (fines ledger, Pinakes 0.9.0). Reached from the profile + * only while `/health` advertises `features.fines`; if the library turns fines off meanwhile + * the server answers 404 `feature_disabled` and the page simply closes. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MovementsScreen( + onNavigateUp: () -> Unit, +) { + val vm: MovementsViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val features by app.features.collectAsStateWithLifecycle() + LaunchedEffect(state.disabled, features.showMovements) { + if (state.disabled || !features.showMovements) onNavigateUp() + } + // A failed refresh keeps the last balance on screen, but says it may be out of date. + val snackbar = remember { SnackbarHostState() } + val refreshFailedText = stringResource(R.string.movements_refresh_failed) + LaunchedEffect(state.refreshFailed) { + if (state.refreshFailed) { + snackbar.showSnackbar(refreshFailedText) + vm.consumeRefreshFailed() + } + } + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.title_movements), onNavigateUp = onNavigateUp) }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { 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.movements_loading)) + is UiState.Error -> ErrorState(message = content.resolvedMessage(), onRetry = vm::load) + is UiState.Success -> MovementsContent(content.data) + } + } + } +} + +@Composable +private fun MovementsContent(data: MovementsPayload) { + val locale = AppLocale.current() + val money = { cents: Int -> MoneyFormat.format(cents, data.currency, locale) } + LazyColumn( + Modifier.fillMaxSize().testTag("movements-list"), + contentPadding = PaddingValues(vertical = Spacing.lg), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + item(key = "balance") { BalanceCard(data, money) } + // The total shows even when the server sends it without the per-loan rows. + if (data.accruedTotalCents != 0 || data.accrued.isNotEmpty()) { + item(key = "accrued-title") { + SectionHeader(title = stringResource(R.string.movements_accrued_title, money(data.accruedTotalCents))) + } + // Keys fall back to the position: a row without its id must not collide with another. + itemsIndexed(data.accrued, key = { i, a -> MoneyFormat.rowKey("accrued", i, a.loanId) }) { _, a -> AccruedRow(a, money) } + } + item(key = "items-title") { SectionHeader(title = stringResource(R.string.movements_history_title)) } + if (data.items.isEmpty()) { + item(key = "empty") { + EmptyState( + title = stringResource(R.string.movements_empty_title), + subtitle = stringResource(R.string.movements_empty_subtitle), + icon = Icons.Outlined.AccountBalanceWallet, + modifier = Modifier.testTag("movements-empty"), + ) + } + } else { + itemsIndexed(data.items, key = { i, m -> MoneyFormat.rowKey("movement", i, m.id) }) { _, m -> MovementRow(m, money) } + } + } +} + +@Composable +private fun BalanceCard(data: MovementsPayload, money: (Int) -> String) { + val kind = MoneyFormat.balanceKind(data.balanceTotalCents) + Column(Modifier.padding(horizontal = Spacing.lg), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().testTag("movements-balance"), + ) { + Column(Modifier.padding(Spacing.lg), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + Text(stringResource(R.string.movements_balance), style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + money(kotlin.math.abs(data.balanceTotalCents)), + style = MaterialTheme.typography.headlineMedium, + color = if (kind == BalanceKind.Owed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, + ) + Text( + stringResource( + when (kind) { + BalanceKind.Owed -> R.string.movements_balance_owed + BalanceKind.Credit -> R.string.movements_balance_credit + BalanceKind.Settled -> R.string.movements_balance_settled + }, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (data.debtBlocked) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.fillMaxWidth().testTag("movements-debt-blocked"), + ) { + Column(Modifier.padding(Spacing.lg), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + Text(stringResource(R.string.movements_debt_blocked_title), style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onErrorContainer) + Text( + data.debtBlockThresholdCents?.let { stringResource(R.string.movements_debt_blocked_body, money(it)) } + ?: stringResource(R.string.movements_debt_blocked_body_plain), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } + } + } +} + +@Composable +private fun AccruedRow(fine: AccruedFine, money: (Int) -> String) { + LedgerRow( + title = fine.title?.takeIf { it.isNotBlank() } ?: stringResource(R.string.movements_untitled), + subtitle = stringResource(R.string.movements_accrued_row, DateFormat.date(fine.dueAt), fine.daysLate), + amount = money(fine.amountCents), + owed = true, + ) +} + +@Composable +private fun MovementRow(m: Movement, money: (Int) -> String) { + val title = m.typeLabel?.takeIf { it.isNotBlank() } ?: m.type + val detail = listOfNotNull( + m.bookTitle?.takeIf { it.isNotBlank() }, + m.description?.takeIf { it.isNotBlank() && it != title }, + m.paymentMethod?.takeIf { it.isNotBlank() }, + m.createdAt?.let { DateFormat.date(it) }, + ).joinToString(" · ") + LedgerRow(title = title, subtitle = detail, amount = money(m.amountCents), owed = m.amountCents > 0) +} + +@Composable +private fun LedgerRow(title: String, subtitle: String, amount: String, owed: Boolean) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.lg), + ) { + Row(Modifier.padding(Spacing.md), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge, maxLines = 2, overflow = TextOverflow.Ellipsis) + if (subtitle.isNotBlank()) { + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, overflow = TextOverflow.Ellipsis) + } + } + Text( + amount, + style = MaterialTheme.typography.titleSmall, + color = if (owed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = Spacing.md), + ) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/movements/MovementsViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/movements/MovementsViewModel.kt new file mode 100644 index 0000000..fc53f2c --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/movements/MovementsViewModel.kt @@ -0,0 +1,121 @@ +package com.pinakes.app.ui.screens.movements + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.MovementsPayload +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.LibraryRepository +import com.pinakes.app.ui.common.UiState +import dagger.hilt.android.lifecycle.HiltViewModel +import java.math.BigDecimal +import java.text.NumberFormat +import java.util.Currency +import java.util.Locale +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 MovementsUiState( + val content: UiState = UiState.Loading, + val refreshing: Boolean = false, + /** + * The library turned the fines module off (404 `feature_disabled`, or a server without the + * route): the page leaves at once instead of showing an error, as the profile entry is gone too. + */ + val disabled: Boolean = false, + /** A pull-to-refresh failed: the page keeps the last balance and says it may be out of date. */ + val refreshFailed: Boolean = false, +) { + companion object { + /** + * The state after a fetch. Pure: a 404 (`feature_disabled` or `not_found` from a server + * without the route) means "no ledger here", never an error; a failed refresh keeps the + * page already on screen. + */ + fun after(previous: MovementsUiState, result: ApiResult): MovementsUiState = when (result) { + is ApiResult.Success -> previous.copy(content = UiState.Success(result.data), refreshing = false, disabled = false, refreshFailed = false) + is ApiResult.Failure -> when { + result.code == "feature_disabled" || result.httpStatus == 404 -> previous.copy(refreshing = false, disabled = true) + previous.content is UiState.Success -> previous.copy(refreshing = false, refreshFailed = true) + else -> previous.copy(content = UiState.Error(result.message, result.code, R.string.movements_error_load), refreshing = false) + } + } + } +} + +/** What the reader owes, in one place: the sign decides the wording of the balance card. */ +enum class BalanceKind { Owed, Settled, Credit } + +object MoneyFormat { + + /** + * A LazyColumn key for a ledger row: its id when the server sent one, else its position. + * Ids default to 0 when omitted, and Compose throws on a duplicate key. + */ + fun rowKey(prefix: String, index: Int, id: Int): String = if (id > 0) "$prefix-$id" else "$prefix-at-$index" + + fun balanceKind(cents: Int): BalanceKind = when { + cents > 0 -> BalanceKind.Owed + cents < 0 -> BalanceKind.Credit + else -> BalanceKind.Settled + } + + /** + * [cents] in [currency] (an ISO 4217 code such as "EUR") for [locale]. A currency the + * platform does not know (a symbol such as "€" typed in the settings) is appended to the + * plain decimal instead, so the amount is never lost to a formatting failure. + */ + fun format(cents: Int, currency: String?, locale: Locale): String { + val amount = BigDecimal.valueOf(cents.toLong(), 2) + val code = currency?.trim().orEmpty() + val iso = runCatching { Currency.getInstance(code.uppercase(Locale.ROOT)) }.getOrNull() + if (iso != null) { + val nf = NumberFormat.getCurrencyInstance(locale) + nf.currency = iso + nf.minimumFractionDigits = 2 + nf.maximumFractionDigits = 2 + return nf.format(amount) + } + val nf = NumberFormat.getNumberInstance(locale).apply { + minimumFractionDigits = 2 + maximumFractionDigits = 2 + } + val plain = nf.format(amount) + return if (code.isEmpty()) plain else "$plain $code" + } +} + +/** Balance and movements of the fines ledger (Pinakes 0.9.0). */ +@HiltViewModel +class MovementsViewModel @Inject constructor( + private val library: LibraryRepository, +) : ViewModel() { + + private val _state = MutableStateFlow(MovementsUiState()) + val state: StateFlow = _state.asStateFlow() + + init { load() } + + fun load() { + _state.update { it.copy(content = UiState.Loading) } + fetch() + } + + fun refresh() { + _state.update { it.copy(refreshing = true) } + fetch() + } + + fun consumeRefreshFailed() = _state.update { it.copy(refreshFailed = false) } + + private fun fetch() { + viewModelScope.launch { + val res = library.movements() + _state.update { MovementsUiState.after(it, res) } + } + } +} 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 d028923..ae8faab 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 @@ -20,6 +20,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Logout import androidx.compose.material.icons.outlined.BrightnessMedium +import androidx.compose.material.icons.outlined.AccountBalanceWallet import androidx.compose.material.icons.outlined.CalendarMonth import androidx.compose.material.icons.outlined.ChatBubbleOutline import androidx.compose.material.icons.outlined.DevicesOther @@ -93,6 +94,7 @@ fun ProfileScreen( onOpenNotifications: () -> Unit, onOpenContact: () -> Unit, onOpenMyReviews: () -> Unit, + onOpenMovements: () -> Unit, onOpenBookClub: () -> Unit, onOpenPeriodicals: () -> Unit, onOpenArticles: () -> Unit, @@ -137,6 +139,7 @@ fun ProfileScreen( onOpenNotifications = onOpenNotifications, onOpenContact = onOpenContact, onOpenMyReviews = onOpenMyReviews, + onOpenMovements = onOpenMovements, onOpenBookClub = onOpenBookClub, onOpenPeriodicals = onOpenPeriodicals, onOpenArticles = onOpenArticles, @@ -152,6 +155,7 @@ fun ProfileScreen( showNotifications = features.notifications, showContact = features.messages, showReviews = features.showReviews, + showMovements = features.showMovements, showBookClub = features.bookClubAvailable, showPeriodicals = features.periodicalsAvailable, ) @@ -230,6 +234,7 @@ private fun ProfileContent( onOpenNotifications: () -> Unit, onOpenContact: () -> Unit, onOpenMyReviews: () -> Unit, + onOpenMovements: () -> Unit, onOpenBookClub: () -> Unit, onOpenPeriodicals: () -> Unit, onOpenArticles: () -> Unit, @@ -242,6 +247,7 @@ private fun ProfileContent( showNotifications: Boolean, showContact: Boolean, showReviews: Boolean, + showMovements: Boolean, showBookClub: Boolean, showPeriodicals: Boolean, showDesiderata: Boolean, @@ -291,7 +297,9 @@ private fun ProfileContent( // Read-only membership info (only shown when the server provides it). val cardNumber = profile.codiceTessera?.takeIf { it.isNotBlank() } val cardExpiry = profile.cardExpiresAt?.takeIf { it.isNotBlank() } - if (cardNumber != null || cardExpiry != null) { + // Patron type (Pinakes 0.9.0 circulation rules), when /me names it. + val patronType = profile.patronType?.let { it.name?.takeIf(String::isNotBlank) ?: it.code?.takeIf(String::isNotBlank) } + if (cardNumber != null || cardExpiry != null || patronType != null) { Spacer(Modifier.height(Spacing.xl)) Surface( shape = MaterialTheme.shapes.medium, @@ -301,6 +309,7 @@ private fun ProfileContent( Column(Modifier.padding(Spacing.lg), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { cardNumber?.let { ReadOnlyRow(stringResource(R.string.profile_card_number), it) } cardExpiry?.let { ReadOnlyRow(stringResource(R.string.profile_card_expires), DateFormat.date(it)) } + patronType?.let { ReadOnlyRow(stringResource(R.string.profile_patron_type), it) } } } } @@ -313,6 +322,9 @@ private fun ProfileContent( if (showReviews) { ActionRow(Icons.Outlined.RateReview, stringResource(R.string.profile_action_my_reviews), onClick = onOpenMyReviews) } + if (showMovements) { + ActionRow(Icons.Outlined.AccountBalanceWallet, stringResource(R.string.profile_action_movements), onClick = onOpenMovements) + } if (showCalendar) { ActionRow(Icons.Outlined.CalendarMonth, stringResource(R.string.profile_action_calendar), onClick = onOpenCalendar) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffCommon.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffCommon.kt index 855644d..47e9ed3 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffCommon.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffCommon.kt @@ -224,6 +224,8 @@ internal fun DatePickerField( onChange: (String?) -> Unit, allowClear: Boolean = false, enabled: Boolean = true, + /** Days the dialog refuses (yyyy-MM-dd), e.g. the library's closures; empty = none. */ + disabledDates: Set = emptySet(), ) { var open by remember { mutableStateOf(false) } PickerRow( @@ -235,7 +237,16 @@ internal fun DatePickerField( ) if (open) { val initial = value?.let { runCatching { LocalDate.parse(it) }.getOrNull() } - val pickerState = rememberDatePickerState(initialSelectedDateMillis = initial?.atStartOfDay(ZoneOffset.UTC)?.toInstant()?.toEpochMilli()) + val selectable = remember(disabledDates) { + object : androidx.compose.material3.SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean = + Instant.ofEpochMilli(utcTimeMillis).atZone(ZoneOffset.UTC).toLocalDate().toString() !in disabledDates + } + } + val pickerState = rememberDatePickerState( + initialSelectedDateMillis = initial?.atStartOfDay(ZoneOffset.UTC)?.toInstant()?.toEpochMilli(), + selectableDates = selectable, + ) DatePickerDialog( onDismissRequest = { open = false }, confirmButton = { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt index dccc753..c058bb6 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt @@ -68,6 +68,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch enum class DeskPicker { USER, BOOK } @@ -83,6 +84,10 @@ data class LoanCreateUiState( val notes: String = "", val calendar: AvailabilityCalendar? = null, val calendarFailed: Boolean = false, + /** The availability (and its closures) is being fetched: submit waits for it. */ + val calendarLoading: Boolean = false, + /** Number of the latest availability request: only its answer is applied. */ + val calendarRequest: Int = 0, val picker: DeskPicker? = null, val saving: Boolean = false, val error: StaffMessage? = null, @@ -95,7 +100,28 @@ data class LoanCreateUiState( /** "Hand over now" only makes sense for a loan starting today. */ val canHandOver: Boolean get() = !reservation && (start == null || start == today) - val canSubmit: Boolean get() = user != null && book != null && !saving + val canSubmit: Boolean get() = user != null && book != null && !saving && !calendarLoading + + /** Closed days of the library (closures calendar) from the availability payload. */ + val closedDates: Set get() = calendar?.closedDates?.toSet().orEmpty() + + /** + * The start or end that falls on a day the library is closed, else null. A loan without a + * start begins today, so today is checked then too. + */ + val closedDateChosen: String? get() = listOfNotNull(start ?: today.takeIf { !reservation }, end).firstOrNull { it in closedDates } + + /** A new availability request starts: it supersedes any one still in flight. */ + fun startCalendarRequest(): LoanCreateUiState = copy(calendarLoading = true, calendarRequest = calendarRequest + 1) + + /** + * The answer to availability request [request]. An answer to an older request (the staff + * changed book or user meanwhile) is dropped: it neither replaces the calendar nor ends the + * loading of the latest one. + */ + fun withCalendarResult(request: Int, calendar: AvailabilityCalendar?, forbidden: Boolean = false): LoanCreateUiState = + if (request != calendarRequest) this + else copy(calendar = calendar, calendarFailed = calendar == null, calendarLoading = false, forbidden = this.forbidden || forbidden) } /** @@ -174,9 +200,11 @@ class StaffLoanCreateViewModel @Inject constructor( /** Availability for the chosen book, from this user's point of view (their own holds count as free). */ private suspend fun loadCalendar() { val book = _state.value.book ?: return + val request = _state.updateAndGet { it.startCalendarRequest() }.calendarRequest when (val res = circulation.availability(book.id, _state.value.user?.id)) { - is ApiResult.Success -> _state.update { it.copy(calendar = res.data, calendarFailed = false) } - is ApiResult.Failure -> _state.update { it.copy(calendar = null, calendarFailed = true, forbidden = res.isForbidden()) } + is ApiResult.Success -> _state.update { it.withCalendarResult(request, res.data) } + // A failed fetch keeps the old fallback: the form can be sent, the server decides. + is ApiResult.Failure -> _state.update { it.withCalendarResult(request, null, forbidden = res.isForbidden()) } } } @@ -190,11 +218,16 @@ class StaffLoanCreateViewModel @Inject constructor( val s = _state.value val user = s.user ?: return val book = s.book ?: return - if (s.saving) return + if (!s.canSubmit) return if (s.start != null && s.end != null && s.end < s.start) { _state.update { it.copy(error = StaffMessage(R.string.staff_loan_dates_order)) } return } + // Neither a start nor a return on a day the library is closed (closures calendar). + s.closedDateChosen?.let { day -> + _state.update { it.copy(error = StaffMessage(R.string.staff_loan_closed_day, args = listOf(DateFormat.date(day)))) } + return + } _state.update { it.copy(saving = true, error = null) } viewModelScope.launch { if (s.reservation) { @@ -276,7 +309,10 @@ fun StaffLoanCreateScreen(onNavigateUp: () -> Unit, onCreated: (loanId: Int) -> stringResource(R.string.staff_pick_user_placeholder), onClick = { vm.openPicker(DeskPicker.USER) }, ) state.user?.note?.let { - Text(stringResource(R.string.staff_user_not_eligible, it), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + // The note is the server's eligibility_reason: a known one in our words. + val text = com.pinakes.app.data.staff.StaffErrors.eligibilityRes(it)?.let { res -> stringResource(res) } + ?: stringResource(R.string.staff_user_not_eligible, it) + Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) } FormSectionTitle(stringResource(R.string.staff_pick_book)) PickedField(stringResource(R.string.staff_pick_book), state.book?.title, stringResource(R.string.staff_pick_book_placeholder), @@ -289,8 +325,8 @@ fun StaffLoanCreateScreen(onNavigateUp: () -> Unit, onCreated: (loanId: Int) -> ) } FormSectionTitle(stringResource(R.string.staff_loan_dates)) - DatePickerField(stringResource(if (state.reservation) R.string.staff_reservation_from else R.string.staff_loan_loaned), state.start, vm::setStart, allowClear = true) - DatePickerField(stringResource(if (state.reservation) R.string.staff_reservation_to else R.string.staff_loan_due), state.end, vm::setEnd, allowClear = true) + DatePickerField(stringResource(if (state.reservation) R.string.staff_reservation_from else R.string.staff_loan_loaned), state.start, vm::setStart, allowClear = true, disabledDates = state.closedDates) + DatePickerField(stringResource(if (state.reservation) R.string.staff_reservation_to else R.string.staff_loan_due), state.end, vm::setEnd, allowClear = true, disabledDates = state.closedDates) Text( stringResource(if (state.reservation) R.string.staff_reservation_dates_hint else R.string.staff_loan_dates_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffUserScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffUserScreen.kt index 2cb1b44..4aab445 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffUserScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffUserScreen.kt @@ -133,7 +133,9 @@ fun StaffUserScreen(onNavigateUp: () -> Unit, onOpenLoan: (Int) -> Unit, onNewLo InfoLine(stringResource(R.string.staff_reservations), user.activeReservations.toString()) if (!user.eligible) { Text( - stringResource(R.string.staff_user_not_eligible, user.eligibilityReason ?: user.status.orEmpty()), + // A known reason (debt block, expired card, suspension) in our words. + com.pinakes.app.data.staff.StaffErrors.eligibilityRes(user.eligibilityReason)?.let { stringResource(it) } + ?: stringResource(R.string.staff_user_not_eligible, user.eligibilityReason ?: user.status.orEmpty()), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffCopiesSection.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffCopiesSection.kt index 9339117..09d1220 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffCopiesSection.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffCopiesSection.kt @@ -218,6 +218,17 @@ internal fun copyStateLabel(copy: StaffCopy, lookups: StaffLookups): String = ?: lookups.copyStateOptions.firstOrNull { it.value == copy.status }?.label ?: copy.status.orEmpty() +/** + * The second line of a copy row: its state, "consultation only" when the copy is kept in the + * library (Pinakes 0.9.0 `consultation_only`), then its notes. Blank parts are dropped. + */ +internal fun copySummary(copy: StaffCopy, stateLabel: String, consultationLabel: String): String = + listOfNotNull( + stateLabel.takeIf { it.isNotBlank() }, + consultationLabel.takeIf { copy.consultationOnly && it.isNotBlank() }, + copy.notes?.takeIf { it.isNotBlank() }, + ).joinToString(" · ") + /** * The per-copy list of the book editor. Shown only when the server offers per-copy management * (`staff_copies`); label buttons only with `staff_labels`. Messages go to [onMessage] (the @@ -270,7 +281,7 @@ fun StaffCopiesSection( Column(Modifier.weight(1f)) { Text(copy.inventoryNumber ?: stringResource(R.string.staff_copy_no_code), style = MaterialTheme.typography.bodyLarge) Text( - listOfNotNull(copyStateLabel(copy, lookups).takeIf { it.isNotBlank() }, copy.notes?.takeIf { it.isNotBlank() }).joinToString(" · "), + copySummary(copy, copyStateLabel(copy, lookups), stringResource(R.string.staff_copy_consultation_only)), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } diff --git a/app/src/test/java/com/pinakes/app/server090/CirculationRulesTest.kt b/app/src/test/java/com/pinakes/app/server090/CirculationRulesTest.kt new file mode 100644 index 0000000..b16d372 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/CirculationRulesTest.kt @@ -0,0 +1,85 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.R +import com.pinakes.app.data.model.AvailabilityCalendar +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.parseErrorBody +import com.pinakes.app.ui.screens.detail.CirculationRules +import com.pinakes.app.ui.screens.detail.LoanAction +import com.pinakes.app.ui.screens.detail.ViewerRules +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Patron-type rules on the book page and the 0.9.0 refusal codes of POST /reservations. */ +class CirculationRulesTest { + + private fun refusal(code: String, message: String = "Server text", reason: String? = null, status: Int = 409): ApiResult.Failure { + val meta = reason?.let { """{"reason":"$it"}""" } ?: "{}" + return parseErrorBody("""{"data":null,"meta":$meta,"error":{"code":"$code","message":"$message"}}""", status) + } + + @Test fun unknownRulesKeepTheUsualButton() { + assertEquals(LoanAction.Request, CirculationRules.loanAction(available = true, rules = null)) + assertEquals(LoanAction.Reserve, CirculationRules.loanAction(available = false, rules = null)) + } + + @Test fun consultationOnlyWinsWhateverTheCopies() { + val rules = ViewerRules(loanable = false) + assertEquals(LoanAction.ConsultationOnly, CirculationRules.loanAction(true, rules)) + assertEquals(LoanAction.ConsultationOnly, CirculationRules.loanAction(false, rules)) + assertFalse(CirculationRules.canRequest(LoanAction.ConsultationOnly)) + } + + @Test fun noReservationsOnlyMattersWhenNoCopyIsFree() { + val rules = ViewerRules(reservationsEnabled = false) + assertEquals(LoanAction.Request, CirculationRules.loanAction(true, rules)) + assertEquals(LoanAction.ReservationsDisabled, CirculationRules.loanAction(false, rules)) + assertFalse(CirculationRules.canRequest(LoanAction.ReservationsDisabled)) + assertTrue(CirculationRules.canRequest(LoanAction.Reserve)) + } + + @Test fun rulesAreReadOffTheAvailabilityPayload() { + assertEquals(ViewerRules(false, true), ViewerRules.of(AvailabilityCalendar(loanable = false))) + assertEquals(ViewerRules(true, true), ViewerRules.of(AvailabilityCalendar())) + } + + @Test fun aRefusalTeachesTheRuleSoTheButtonChangesAtOnce() { + assertEquals(ViewerRules(loanable = false), CirculationRules.rulesAfterRefusal(null, "not_loanable")) + assertEquals(ViewerRules(reservationsEnabled = false), CirculationRules.rulesAfterRefusal(ViewerRules(), "reservations_disabled")) + assertNull(CirculationRules.rulesAfterRefusal(null, "not_available")) + } + + @Test fun theNewConflictCodesHaveTheirOwnWording() { + assertEquals(R.string.request_err_not_loanable, CirculationRules.requestErrorRes(refusal("not_loanable"))) + assertEquals(R.string.request_err_reservations_disabled, CirculationRules.requestErrorRes(refusal("reservations_disabled"))) + assertEquals(R.string.request_err_reservation_limit, CirculationRules.requestErrorRes(refusal("reservation_limit_reached"))) + assertEquals(R.string.request_err_media_limit, CirculationRules.requestErrorRes(refusal("media_limit_reached"))) + assertEquals(R.string.request_err_max_loans, CirculationRules.requestErrorRes(refusal("max_loans_reached"))) + } + + @Test fun notEligibleNamesTheReasonWhenTheServerGivesIt() { + assertEquals(R.string.request_err_debt_block, CirculationRules.requestErrorRes(refusal("not_eligible", reason = "debt_block"))) + assertEquals(R.string.request_err_card_expired, CirculationRules.requestErrorRes(refusal("not_eligible", reason = "card_expired"))) + assertEquals(R.string.request_err_suspended, CirculationRules.requestErrorRes(refusal("not_eligible", reason = "user_suspended"))) + } + + @Test fun notEligibleWithoutAReasonKeepsTheServerSentence() { + // The patron route answers 403 not_eligible with the cause in the message only. + assertNull(CirculationRules.requestErrorRes(refusal("not_eligible", "Prestiti bloccati per debito.", status = 403))) + assertNull(CirculationRules.requestErrorRes(refusal("not_eligible", reason = "a_reason_this_app_does_not_know"))) + } + + @Test fun unknownCodesAndNotAvailableFallBackToTheServerMessage() { + assertNull(CirculationRules.requestErrorRes(refusal("some_future_code"))) + assertNull(CirculationRules.requestErrorRes(refusal("not_available", "Durata massima del prestito: 4 giorni"))) + assertNull(CirculationRules.requestErrorRes(refusal("duplicate"))) + } + + @Test fun aBare409FromAProxyStillReadsAsADateClash() { + val failure = parseErrorBody("conflict", 409) + assertEquals(R.string.snackbar_request_conflict, CirculationRules.requestErrorRes(failure)) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/ClosedDatesCalendarTest.kt b/app/src/test/java/com/pinakes/app/server090/ClosedDatesCalendarTest.kt new file mode 100644 index 0000000..0136128 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/ClosedDatesCalendarTest.kt @@ -0,0 +1,139 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.data.model.AvailabilityCalendar +import com.pinakes.app.data.model.AvailabilityDay +import com.pinakes.app.ui.screens.detail.CalendarModel +import com.pinakes.app.ui.screens.detail.DayKind +import com.pinakes.app.ui.screens.detail.firstRequestableDay +import com.pinakes.app.ui.screens.detail.isClosed +import com.pinakes.app.ui.screens.detail.loanWindowFrom +import com.pinakes.app.ui.screens.detail.openDayFrom +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate + +/** + * Closures calendar (Mobile API 1.9.0 `closed_dates`): a closed day is never a start, gets + * its own colour, and a proposed return date always lands on an open day. + */ +class ClosedDatesCalendarTest { + private val today = LocalDate.parse("2030-03-01") + + private fun freeDays(from: String, count: Int) = (0 until count).map { + AvailabilityDay(date = LocalDate.parse(from).plusDays(it.toLong()).toString(), available = 1, state = "free") + } + + private fun calendar(closed: List, maxDuration: Int? = 90, full: List = emptyList()) = AvailabilityCalendar( + totalCopies = 1, + maxLoanDurationDays = maxDuration, + closedDates = closed, + days = freeDays("2030-03-01", 90).map { d -> if (d.date in full) d.copy(available = 0, state = "full") else d }, + ) + + @Test fun aClosedFutureDayHasItsOwnKindAndCannotBePicked() { + val model = CalendarModel(calendar(listOf("2030-03-05"))) + val day = LocalDate.parse("2030-03-05") + assertEquals(DayKind.Closed, model.kind(day, today)) + assertFalse(model.isSelectable(day, today)) + assertTrue(model.isSelectable(LocalDate.parse("2030-03-06"), today)) + } + + @Test fun aClosedDayInThePastIsJustPast() { + val model = CalendarModel(calendar(listOf("2030-02-27"))) + assertEquals(DayKind.PastOrUnknown, model.kind(LocalDate.parse("2030-02-27"), today)) + } + + @Test fun closedWinsOverFullyBookedForTheColour() { + val model = CalendarModel(calendar(listOf("2030-03-05"), full = listOf("2030-03-05"))) + assertEquals(DayKind.Closed, model.kind(LocalDate.parse("2030-03-05"), today)) + } + + @Test fun theAvailabilityPayloadReadsTheNewFieldsAndDefaultsOnOlderServers() { + val json = Json { ignoreUnknownKeys = true } + val new = json.decodeFromString( + """{"total_copies":1,"closed_dates":["2030-03-05","2030-03-12"],"loanable":false,"reservations_enabled":false}""", + ) + assertEquals(listOf("2030-03-05", "2030-03-12"), new.closedDates) + assertFalse(new.loanable) + assertFalse(new.reservationsEnabled) + val old = json.decodeFromString("""{"total_copies":1}""") + assertTrue(old.closedDates.isEmpty()) + assertTrue(old.loanable) + assertTrue(old.reservationsEnabled) + } + + @Test fun openDayFromWalksOverARunOfClosedDays() { + val cal = calendar(listOf("2030-03-09", "2030-03-10")) + assertEquals(LocalDate.parse("2030-03-11"), cal.openDayFrom(LocalDate.parse("2030-03-09"), forward = true)) + assertEquals(LocalDate.parse("2030-03-08"), cal.openDayFrom(LocalDate.parse("2030-03-10"), forward = false)) + assertNull(cal.openDayFrom(LocalDate.parse("2030-03-10"), forward = false, notBefore = LocalDate.parse("2030-03-09"))) + assertTrue(cal.isClosed("2030-03-09")) + } + + @Test fun thePreselectedStartSkipsClosedAndBookedDays() { + val cal = calendar(listOf("2030-03-02", "2030-03-03"), full = listOf("2030-03-04")) + assertEquals("2030-03-05", cal.firstRequestableDay("2030-03-02")) + // Nothing closed: the server's earliest day is kept as it is. + assertEquals("2030-03-02", calendar(emptyList()).firstRequestableDay("2030-03-02")) + assertNull(cal.firstRequestableDay(null)) + } + + @Test fun aDefaultReturnOnAClosedDayMovesToTheNextOpenDay() { + // 2030-03-04 + 14 = 2030-03-18 (closed), 2030-03-19 closed too → 2030-03-20. + val window = loanWindowFrom("2030-03-04", calendar(listOf("2030-03-18", "2030-03-19")), 14) + assertEquals("2030-03-20", window?.endDate) + assertEquals(16, window?.maxDays) + assertFalse(window!!.requiresEndDate) + assertFalse(window.capped) + } + + @Test fun anOlderServerWithoutTheMaximumKeepsTheDefaultReturn() { + // closed_dates only comes with Mobile API 1.9.0, which always sends the maximum. + val window = loanWindowFrom("2030-03-04", calendar(listOf("2030-03-18"), maxDuration = null), 14) + assertEquals("2030-03-18", window?.endDate) + assertFalse(window!!.requiresEndDate) + } + + @Test fun aReturnMovedOverAClosureNeverLandsOnABookedDay() { + // Default return 2030-03-18 is closed and 2030-03-19 is fully booked: the loan stops on + // the last open day before the booking, and the app sends it as end_date. + val window = loanWindowFrom("2030-03-04", calendar(listOf("2030-03-18"), full = listOf("2030-03-19")), 14) + assertEquals("2030-03-17", window?.endDate) + assertEquals("2030-03-19", window?.firstBlocked) + assertTrue(window!!.capped) + assertTrue(window.requiresEndDate) + assertEquals(13, window.maxDays) + } + + @Test fun aClosedAndBookedDefaultReturnCapsTheLoanBeforeIt() { + val window = loanWindowFrom("2030-03-04", calendar(listOf("2030-03-18", "2030-03-17"), full = listOf("2030-03-18")), 14) + assertEquals("2030-03-16", window?.endDate) + assertEquals("2030-03-18", window?.firstBlocked) + assertTrue(window!!.requiresEndDate) + } + + @Test fun aShortenedReturnOnAClosedDayMovesBackNotPastTheBookedDay() { + // Booked from 2030-03-10, so the return would be 2030-03-09, which is closed: 2030-03-08. + val window = loanWindowFrom("2030-03-04", calendar(listOf("2030-03-09"), full = listOf("2030-03-10")), 14) + assertEquals("2030-03-08", window?.endDate) + assertEquals("2030-03-10", window?.firstBlocked) + assertEquals(4, window?.maxDays) + assertTrue(window!!.requiresEndDate) + assertTrue(window.possible) + } + + @Test fun noOpenDayBeforeTheBookedOneMeansNoLoanFromThatStart() { + val window = loanWindowFrom("2030-03-04", calendar(listOf("2030-03-05", "2030-03-06"), full = listOf("2030-03-07")), 14) + assertFalse(window!!.possible) + } + + @Test fun withoutClosuresTheWindowIsUnchanged() { + val window = loanWindowFrom("2030-03-04", calendar(emptyList()), 14) + assertEquals("2030-03-18", window?.endDate) + assertEquals(14, window?.maxDays) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/ConsultationOnlyButtonTest.kt b/app/src/test/java/com/pinakes/app/server090/ConsultationOnlyButtonTest.kt new file mode 100644 index 0000000..08f2493 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/ConsultationOnlyButtonTest.kt @@ -0,0 +1,90 @@ +package com.pinakes.app.server090 + +import android.app.Application +import android.content.ComponentName +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import com.pinakes.app.data.model.Availability +import com.pinakes.app.data.model.BookDetail +import com.pinakes.app.ui.screens.detail.DetailContent +import com.pinakes.app.ui.screens.detail.ViewerRules +import com.pinakes.app.ui.theme.PinakesTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * `loanable=false` / `reservations_enabled=false` (Pinakes 0.9.0): the loan button stays on the + * page, disabled, with the reason under it; unknown rules keep the usual, tappable button. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = android.app.Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ConsultationOnlyButtonTest { + // Same RuleChain as DetailAvailabilityAlignmentTest: registers the host activity so the test + // also runs in the release unit tests (ui-test-manifest is a debug dependency). + private val compose = createComposeRule() + @get:Rule val rules: RuleChain = RuleChain.outerRule(object : TestWatcher() { + override fun starting(description: Description) { + val app = ApplicationProvider.getApplicationContext() + shadowOf(app.packageManager).addActivityIfNotPresent(ComponentName(app.packageName, ComponentActivity::class.java.name)) + } + }).around(compose) + + private var taps = 0 + + private fun show(available: Boolean, rules: ViewerRules?) { + compose.setContent { + PinakesTheme { + DetailContent( + BookDetail( + title = "Reference book", + availability = Availability( + copiesTotal = 1, copiesAvailable = if (available) 1 else 0, loanableNow = available, + state = if (available) "available" else "on_loan", + ), + ), + wishlisted = false, reserveBusy = false, + canBorrow = true, showWishlist = false, showReviews = false, + onReserve = { taps++ }, onToggleWishlist = {}, onShowMessage = {}, + rules = rules, + ) + } + } + } + + @Test fun consultationOnlyDisablesTheButtonAndSaysWhy() { + show(available = true, rules = ViewerRules(loanable = false)) + compose.onNodeWithTag("detail-loan").assertIsNotEnabled() + compose.onNodeWithTag("detail-loan-reason", useUnmergedTree = true).assertExists() + compose.onNodeWithTag("detail-loan").performClick() + assertEquals(0, taps) + } + + @Test fun aClosedWaitlistDisablesReserveWhenNoCopyIsFree() { + show(available = false, rules = ViewerRules(reservationsEnabled = false)) + compose.onNodeWithTag("detail-loan").assertIsNotEnabled() + compose.onNodeWithTag("detail-loan-reason", useUnmergedTree = true).assertExists() + } + + @Test fun unknownRulesKeepTheUsualButton() { + show(available = true, rules = null) + compose.onNodeWithTag("detail-loan").assertIsEnabled() + compose.onNodeWithTag("detail-loan-reason", useUnmergedTree = true).assertDoesNotExist() + compose.onNodeWithTag("detail-loan").performClick() + assertEquals(1, taps) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/FeatureFlags090Test.kt b/app/src/test/java/com/pinakes/app/server090/FeatureFlags090Test.kt new file mode 100644 index 0000000..de62098 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/FeatureFlags090Test.kt @@ -0,0 +1,87 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.data.model.HealthFeatures +import com.pinakes.app.data.model.HealthPayload +import com.pinakes.app.data.store.InstanceFeatures +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** `/health.features.{fines,closures_calendar,circulation_rules}` (Pinakes 0.9.0) → app flags. */ +class FeatureFlags090Test { + private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true } + + private fun health(features: String, catalogueMode: Boolean = false) = json.decodeFromString( + """{"name":"Lib","app_access_enabled":true,"catalogue_mode":$catalogueMode,"features":{"catalog":true,"loans":true,"reservations":true,$features}}""", + ) + + @Test fun anOlderServerWithoutTheKeysLeavesAllThreeOff() { + val f = InstanceFeatures.from(health(""""wishlist":true""")) + assertFalse(f.finesAvailable) + assertFalse(f.closuresCalendarAvailable) + assertFalse(f.circulationRulesAvailable) + assertFalse(f.showMovements) + } + + @Test fun aServerAdvertisingTheFlagsTurnsThemOn() { + val f = InstanceFeatures.from(health(""""fines":true,"closures_calendar":true,"circulation_rules":true""")) + assertTrue(f.finesAvailable) + assertTrue(f.closuresCalendarAvailable) + assertTrue(f.circulationRulesAvailable) + assertTrue(f.showMovements) + } + + @Test fun eachFlagIsReadOnItsOwn() { + val f = InstanceFeatures.from(health(""""fines":false,"closures_calendar":true,"circulation_rules":false""")) + assertFalse(f.finesAvailable) + assertTrue(f.closuresCalendarAvailable) + assertFalse(f.circulationRulesAvailable) + } + + @Test fun catalogueModeWinsOverAnyAdvertisedFlag() { + val f = InstanceFeatures.from(health(""""fines":true,"closures_calendar":true,"circulation_rules":true""", catalogueMode = true)) + assertFalse(f.finesAvailable) + assertFalse(f.closuresCalendarAvailable) + assertFalse(f.circulationRulesAvailable) + assertFalse(f.showMovements) + } + + @Test fun appAccessOffHidesTheThreeAreasLikeEveryOtherArea() { + val payload = json.decodeFromString( + """{"app_access_enabled":false,"features":{"loans":true,"fines":true,"closures_calendar":true,"circulation_rules":true}}""", + ) + val f = InstanceFeatures.from(payload) + assertFalse(f.finesAvailable) + assertFalse(f.closuresCalendarAvailable) + assertFalse(f.circulationRulesAvailable) + assertFalse(f.showMovements) + } + + @Test fun theMovementsPageNeedsLoansToo() { + val f = InstanceFeatures(finesAvailable = true, loans = false) + assertFalse(f.showMovements) + assertTrue(InstanceFeatures(finesAvailable = true, loans = true).showMovements) + } + + @Test fun theDefaultsBeforeAnyHealthKeepTheNewAreasHidden() { + val f = InstanceFeatures.AllEnabled + assertTrue(f.loans) + assertFalse(f.finesAvailable) + assertFalse(f.showMovements) + } + + @Test fun theMappingKeepsThePluginProbesAndTheExistingFlags() { + val payload = HealthPayload( + appAccessEnabled = true, + features = HealthFeatures(loans = true, staffCirculation = true, calendarFeed = true, fines = true), + ) + val f = InstanceFeatures.from(payload, bookClub = true, periodicals = false) + assertTrue(f.bookClubAvailable) + assertFalse(f.periodicalsAvailable) + assertTrue(f.staffCirculationAvailable) + assertTrue(f.calendarFeedAvailable) + assertEquals(true, f.finesAvailable) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/MovementsTest.kt b/app/src/test/java/com/pinakes/app/server090/MovementsTest.kt new file mode 100644 index 0000000..c972a31 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/MovementsTest.kt @@ -0,0 +1,130 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.R +import com.pinakes.app.data.model.MovementsPayload +import com.pinakes.app.data.model.UserProfile +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.ui.common.UiState +import com.pinakes.app.ui.screens.movements.BalanceKind +import com.pinakes.app.ui.screens.movements.MoneyFormat +import com.pinakes.app.ui.screens.movements.MovementsUiState +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Locale + +/** `GET /me/movements` (Mobile API 1.9.0): parsing, the page state and money formatting. */ +class MovementsTest { + private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true } + + private val serverBody = """{ + "currency":"EUR","balance_cents":450,"balance":"4.50","accrued_cents":120, + "accrued":[{"loan_id":9,"title":"Il nome della rosa","due_at":"2030-03-01","days_late":6,"amount_cents":120}], + "debt_block_threshold_cents":300,"debt_blocked":true, + "items":[{"id":2,"type":"pagamento","type_label":"Pagamento","amount_cents":-200,"amount":"-2.00","description":"Contanti","payment_method":"contanti","loan_id":null,"book_title":null,"created_at":"2030-03-05 10:00:00"}, + {"id":1,"type":"multa_ritardo","type_label":"Multa per ritardo","amount_cents":650,"amount":"6.50","description":"Ritardo","loan_id":7,"book_title":"Emma","created_at":"2030-03-04 09:00:00"}] + }""" + + @Test fun theServerPayloadIsReadInFull() { + val p = json.decodeFromString(serverBody) + assertEquals("EUR", p.currency) + assertEquals(450, p.balanceCents) + assertTrue(p.debtBlocked) + assertEquals(300, p.debtBlockThresholdCents) + assertEquals(2, p.items.size) + assertEquals(-200, p.items[0].amountCents) + assertNull(p.items[0].loanId) + assertEquals(7, p.items[1].loanId) + assertEquals(6, p.accrued.single().daysLate) + } + + @Test fun theAccruedTotalFallsBackToTheRowsOnlyWhenAbsent() { + val rowsOnly = json.decodeFromString("""{"accrued":[{"loan_id":1,"amount_cents":120},{"loan_id":2,"amount_cents":30}]}""") + assertEquals(150, rowsOnly.accruedTotalCents) + val explicit = json.decodeFromString("""{"accrued_cents":0,"accrued":[{"loan_id":1,"amount_cents":120}]}""") + assertEquals(0, explicit.accruedTotalCents) + assertEquals(120, json.decodeFromString(serverBody).accruedTotalCents) + } + + @Test fun theDecimalBalanceIsTheFallbackForAMissingBalanceCents() { + assertEquals(450, json.decodeFromString("""{"balance":"4.50"}""").balanceTotalCents) + assertEquals(-200, json.decodeFromString("""{"balance":"-2,00"}""").balanceTotalCents) + assertEquals(0, json.decodeFromString("""{"balance":"n/a"}""").balanceTotalCents) + // balance_cents wins when both are there. + assertEquals(300, json.decodeFromString("""{"balance_cents":300,"balance":"9.99"}""").balanceTotalCents) + } + + @Test fun thePlannedNameMovementsIsReadToo() { + val p = json.decodeFromString("""{"balance_cents":0,"movements":[{"id":5,"type":"condono","amount_cents":-100}]}""") + assertEquals(5, p.items.single().id) + } + + @Test fun aBarePayloadMissingEveryFieldStillParses() { + val p = json.decodeFromString("{}") + assertEquals(0, p.balanceTotalCents) + assertFalse(p.debtBlocked) + assertNull(p.debtBlockThresholdCents) + assertTrue(p.items.isEmpty()) + } + + @Test fun featureDisabledClosesThePageWithoutAnError() { + val s = MovementsUiState.after(MovementsUiState(), ApiResult.Failure("feature_disabled", "Funzione non disponibile", 404, fromEnvelope = true)) + assertTrue(s.disabled) + assertTrue(s.content is UiState.Loading) + } + + @Test fun aServerWithoutTheRouteIsTreatedAsDisabled() { + assertTrue(MovementsUiState.after(MovementsUiState(), ApiResult.Failure("not_found", "Not found.", 404)).disabled) + } + + @Test fun aServerErrorIsShownWithItsFallback() { + val s = MovementsUiState.after(MovementsUiState(), ApiResult.Failure("internal_error", "", 500)) + assertFalse(s.disabled) + assertEquals(R.string.movements_error_load, (s.content as UiState.Error).messageRes) + } + + @Test fun aFailedRefreshKeepsThePageOnScreenAndSaysSo() { + val loaded = MovementsUiState(content = UiState.Success(MovementsPayload(balanceCents = 100)), refreshing = true) + val s = MovementsUiState.after(loaded, ApiResult.Failure("network", "", 0)) + assertEquals(100, (s.content as UiState.Success).data.balanceCents) + assertFalse(s.refreshing) + assertTrue(s.refreshFailed) + // The next successful refresh clears it. + assertFalse(MovementsUiState.after(s, ApiResult.Success(MovementsPayload())).refreshFailed) + } + + @Test fun rowsWithoutIdsGetDistinctKeys() { + val p = json.decodeFromString("""{"items":[{"type":"pagamento"},{"type":"condono"},{"id":4,"type":"storno"}],"accrued":[{},{}]}""") + val keys = p.items.mapIndexed { i, m -> MoneyFormat.rowKey("movement", i, m.id) } + + p.accrued.mapIndexed { i, a -> MoneyFormat.rowKey("accrued", i, a.loanId) } + assertEquals(keys.size, keys.toSet().size) + assertEquals("movement-4", keys[2]) + } + + @Test fun theBalanceSignPicksTheWording() { + assertEquals(BalanceKind.Owed, MoneyFormat.balanceKind(1)) + assertEquals(BalanceKind.Settled, MoneyFormat.balanceKind(0)) + assertEquals(BalanceKind.Credit, MoneyFormat.balanceKind(-50)) + } + + @Test fun amountsAreFormattedInTheLibraryCurrency() { + val it = MoneyFormat.format(450, "EUR", Locale.ITALY) + assertTrue(it, it.contains("4,50") && it.contains("€")) + val us = MoneyFormat.format(123456, "usd", Locale.US) + assertEquals("$1,234.56", us) + } + + @Test fun anUnknownCurrencyIsAppendedNotLost() { + assertEquals("4.50 €", MoneyFormat.format(450, "€", Locale.US)) + assertEquals("4.50", MoneyFormat.format(450, null, Locale.US)) + } + + @Test fun theProfileReadsThePatronTypeWhenPresent() { + val withType = json.decodeFromString("""{"id":1,"patron_type":{"code":"student","name":"Studente"}}""") + assertEquals("Studente", withType.patronType?.name) + assertNull(json.decodeFromString("""{"id":1}""").patronType) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/StaffErrors090Test.kt b/app/src/test/java/com/pinakes/app/server090/StaffErrors090Test.kt new file mode 100644 index 0000000..432c054 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/StaffErrors090Test.kt @@ -0,0 +1,58 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.R +import com.pinakes.app.data.model.StaffCopy +import com.pinakes.app.data.network.parseErrorBody +import com.pinakes.app.data.staff.StaffErrors +import com.pinakes.app.ui.screens.staff.content.copySummary +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Staff side of the 0.9.0 circulation rules: refusal codes, eligibility reasons, copies. */ +class StaffErrors090Test { + + @Test fun theNewCirculationCodesAreMapped() { + assertEquals(R.string.staff_err_media_limit, StaffErrors.messageRes("media_limit_reached")) + assertEquals(R.string.staff_err_not_loanable, StaffErrors.messageRes("not_loanable")) + assertEquals(R.string.staff_err_reservations_disabled, StaffErrors.messageRes("reservations_disabled")) + assertEquals(R.string.staff_err_reservation_limit, StaffErrors.messageRes("reservation_limit_reached")) + assertEquals(R.string.staff_err_max_loans, StaffErrors.messageRes("max_loans_reached")) + } + + @Test fun notEligibleUsesMetaReason() { + val debt = parseErrorBody("""{"data":null,"meta":{"reason":"debt_block"},"error":{"code":"not_eligible","message":"x"}}""", 409) + assertEquals(R.string.staff_err_debt_block, StaffErrors.messageRes(debt)) + val card = parseErrorBody("""{"data":null,"meta":{"reason":"card_expired"},"error":{"code":"not_eligible","message":"x"}}""", 409) + assertEquals(R.string.staff_err_card_expired, StaffErrors.messageRes(card)) + } + + @Test fun notEligibleWithAnUnknownOrMissingReasonKeepsTheGenericText() { + val none = parseErrorBody("""{"data":null,"meta":{},"error":{"code":"not_eligible","message":"x"}}""", 409) + assertEquals(R.string.staff_err_not_eligible, StaffErrors.messageRes(none)) + val future = parseErrorBody("""{"data":null,"meta":{"reason":"something_new"},"error":{"code":"not_eligible","message":"x"}}""", 409) + assertEquals(R.string.staff_err_not_eligible, StaffErrors.messageRes(future)) + } + + @Test fun anUnknownCodeIsLeftToTheServerMessage() { + assertNull(StaffErrors.messageRes("brand_new_code")) + assertNull(StaffErrors.eligibilityRes(null)) + assertNull(StaffErrors.eligibilityRes("brand_new_reason")) + assertEquals(R.string.staff_err_user_suspended, StaffErrors.eligibilityRes("user_suspended")) + } + + @Test fun aConsultationOnlyCopySaysSoBetweenStateAndNotes() { + val copy = StaffCopy(id = 1, consultationOnly = true, notes = "Reference shelf") + assertEquals("Available · Consultation only · Reference shelf", copySummary(copy, "Available", "Consultation only")) + assertEquals("Available", copySummary(StaffCopy(id = 2), "Available", "Consultation only")) + } + + @Test fun theCopyFlagDefaultsToFalseOnOlderServers() { + val json = Json { ignoreUnknownKeys = true } + assertFalse(json.decodeFromString("""{"id":3,"status":"disponibile"}""").consultationOnly) + assertTrue(json.decodeFromString("""{"id":3,"consultation_only":true}""").consultationOnly) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/StaffLoanClosedDaysTest.kt b/app/src/test/java/com/pinakes/app/server090/StaffLoanClosedDaysTest.kt new file mode 100644 index 0000000..02d8fc2 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/StaffLoanClosedDaysTest.kt @@ -0,0 +1,67 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.data.model.AvailabilityCalendar +import com.pinakes.app.ui.screens.staff.circulation.LoanCreateUiState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import com.pinakes.app.ui.screens.staff.PickItem +import org.junit.Test + +/** The staff loan form refuses a start or a return on a day the library is closed. */ +class StaffLoanClosedDaysTest { + private val calendar = AvailabilityCalendar(closedDates = listOf("2030-03-09", "2030-03-10")) + + @Test fun aClosedStartOrEndIsNamed() { + assertEquals("2030-03-09", LoanCreateUiState(calendar = calendar, start = "2030-03-09", end = "2030-03-20").closedDateChosen) + assertEquals("2030-03-10", LoanCreateUiState(calendar = calendar, start = "2030-03-04", end = "2030-03-10").closedDateChosen) + } + + @Test fun aLoanWithoutAStartBeginsTodaySoTodayIsChecked() { + val today = java.time.LocalDate.now().toString() + val closedToday = AvailabilityCalendar(closedDates = listOf(today)) + assertEquals(today, LoanCreateUiState(calendar = closedToday).closedDateChosen) + // A reservation without a start has no implicit day. + assertNull(LoanCreateUiState(calendar = closedToday, reservation = true).closedDateChosen) + } + + @Test fun submitWaitsForTheCalendar() { + val picked = LoanCreateUiState(user = PickItem(1, "Ada"), book = PickItem(2, "Emma")) + assertTrue(picked.canSubmit) + assertFalse(picked.copy(calendarLoading = true).canSubmit) + // A failed fetch keeps the form usable: the server decides. + assertTrue(picked.copy(calendarFailed = true).canSubmit) + } + + @Test fun onlyTheLatestCalendarRequestIsApplied() { + val picked = LoanCreateUiState(user = PickItem(1, "Ada"), book = PickItem(2, "Emma")) + val first = picked.startCalendarRequest() + val second = first.startCalendarRequest() + // The answer to the superseded request (another book was picked meanwhile) is dropped, + // whether it succeeded or failed: the latest one is still loading, submit still waits. + val staleOk = second.withCalendarResult(first.calendarRequest, calendar) + assertTrue(staleOk.calendarLoading) + assertNull(staleOk.calendar) + assertFalse(staleOk.canSubmit) + val staleFail = second.copy(calendar = calendar).withCalendarResult(first.calendarRequest, null) + assertTrue(staleFail.calendarLoading) + assertFalse(staleFail.calendarFailed) + assertEquals(calendar, staleFail.calendar) + // The latest answer ends the loading. + val latest = staleOk.withCalendarResult(second.calendarRequest, calendar) + assertFalse(latest.calendarLoading) + assertEquals(calendar, latest.calendar) + assertTrue(latest.canSubmit) + val latestFail = staleOk.withCalendarResult(second.calendarRequest, null, forbidden = true) + assertTrue(latestFail.calendarFailed) + assertTrue(latestFail.forbidden) + assertFalse(latestFail.calendarLoading) + } + + @Test fun openDaysOrNoCalendarPass() { + assertNull(LoanCreateUiState(calendar = calendar, start = "2030-03-04", end = "2030-03-18").closedDateChosen) + assertNull(LoanCreateUiState(calendar = null, start = "2030-03-09").closedDateChosen) + assertEquals(setOf("2030-03-09", "2030-03-10"), LoanCreateUiState(calendar = calendar).closedDates) + } +} diff --git a/i18n/de.json b/i18n/de.json index d8fd814..8b76b1a 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1453,5 +1453,46 @@ "digital_locked_login": "Zum Öffnen anmelden", "digital_locked_loan": "Für Leser, die dieses Buch ausgeliehen haben", "digital_locked_other": "Für dein Konto nicht verfügbar", - "digital_link_refresh_error": "Der Link konnte nicht aktualisiert werden. Bitte versuche es erneut." + "digital_link_refresh_error": "Der Link konnte nicht aktualisiert werden. Bitte versuche es erneut.", + "book_loan_legend_closed": "Bibliothek geschlossen", + "book_loan_closed_day": "Am %1$s ist die Bibliothek geschlossen: Wähle einen anderen Tag.", + "book_consultation_only": "Nur zur Einsicht", + "book_consultation_only_hint": "Du kannst diesen Titel in der Bibliothek einsehen, aber mit deinem Ausweis nicht ausleihen.", + "book_reservations_disabled_hint": "Gerade ist kein Exemplar frei, und mit deinem Ausweis sind keine Vormerkungen möglich. Versuche es wieder, wenn ein Exemplar zurück ist.", + "request_err_not_loanable": "Dieser Titel ist für deinen Ausweis nur zur Einsicht: Er kann nicht ausgeliehen werden.", + "request_err_reservations_disabled": "Mit deinem Ausweis sind keine Vormerkungen möglich.", + "request_err_reservation_limit": "Du hast die maximale Anzahl an Vormerkungen erreicht.", + "request_err_media_limit": "Du hast die maximale Anzahl an Ausleihen für diese Medienart erreicht.", + "request_err_max_loans": "Du hast die maximale Anzahl aktiver Ausleihen erreicht.", + "request_err_debt_block": "Ausleihen sind gesperrt, bis dein Kontostand bei der Bibliothek ausgeglichen ist.", + "request_err_card_expired": "Dein Bibliotheksausweis ist abgelaufen: Bitte die Bibliothek um eine Verlängerung.", + "request_err_suspended": "Dein Konto ist gesperrt: Wende dich an die Bibliothek.", + "staff_err_media_limit": "Diese Person hat die maximale Anzahl an Ausleihen für diese Medienart erreicht.", + "staff_err_not_loanable": "Dieses Medium ist für die Leserkategorie dieser Person nur zur Einsicht.", + "staff_err_reservations_disabled": "Für die Leserkategorie dieser Person sind keine Vormerkungen erlaubt.", + "staff_err_reservation_limit": "Diese Person hat die maximale Anzahl an Vormerkungen erreicht.", + "staff_err_debt_block": "Ausleihen gesperrt: Der Kontostand dieser Person liegt über der Schuldengrenze.", + "staff_err_card_expired": "Der Bibliotheksausweis dieser Person ist abgelaufen.", + "staff_err_user_suspended": "Diese Person ist gesperrt.", + "staff_copy_consultation_only": "Nur zur Einsicht", + "profile_patron_type": "Leserkategorie", + "profile_action_movements": "Kontostand und Bewegungen", + "title_movements": "Kontostand und Bewegungen", + "movements_loading": "Kontostand wird geladen…", + "movements_error_load": "Der Kontostand konnte nicht geladen werden.", + "movements_balance": "Kontostand", + "movements_balance_owed": "Betrag, den du der Bibliothek schuldest.", + "movements_balance_credit": "Guthaben zu deinen Gunsten.", + "movements_balance_settled": "Nichts zu bezahlen.", + "movements_debt_blocked_title": "Ausleihen gesperrt", + "movements_debt_blocked_body": "Dein Kontostand liegt über der Grenze der Bibliothek von %1$s: Neue Ausleihen und Verlängerungen sind gesperrt, bis er ausgeglichen ist.", + "movements_debt_blocked_body_plain": "Neue Ausleihen und Verlängerungen sind gesperrt, bis dein Kontostand ausgeglichen ist.", + "movements_accrued_title": "Laufende Gebühren für überfällige Ausleihen: %1$s", + "movements_accrued_row": "Fällig am %1$s · Tage überfällig: %2$d · wird bei Rückgabe berechnet", + "movements_history_title": "Bewegungen", + "movements_empty_title": "Keine Bewegungen", + "movements_empty_subtitle": "Hier erscheinen Gebühren, Zahlungen und Erlasse.", + "movements_untitled": "Ausleihe", + "movements_refresh_failed": "Aktualisierung fehlgeschlagen: Der angezeigte Kontostand ist möglicherweise nicht aktuell.", + "staff_loan_closed_day": "Am %1$s ist die Bibliothek geschlossen: Wähle ein anderes Start- oder Rückgabedatum." } diff --git a/i18n/en.json b/i18n/en.json index 33f4962..b546822 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1453,5 +1453,46 @@ "digital_locked_login": "Sign in to open it", "digital_locked_loan": "For readers who have this book on loan", "digital_locked_other": "Not available to your account", - "digital_link_refresh_error": "Could not refresh the link. Please try again." + "digital_link_refresh_error": "Could not refresh the link. Please try again.", + "book_loan_legend_closed": "Library closed", + "book_loan_closed_day": "The library is closed on %1$s: choose another day.", + "book_consultation_only": "Consultation only", + "book_consultation_only_hint": "You can consult this title in the library, but it cannot be borrowed with your card.", + "book_reservations_disabled_hint": "No copy is free right now, and reservations are not available with your card. Try again when a copy comes back.", + "request_err_not_loanable": "This title is consultation only for your card: it cannot be borrowed.", + "request_err_reservations_disabled": "Reservations are not available with your card.", + "request_err_reservation_limit": "You have reached the maximum number of reservations.", + "request_err_media_limit": "You have reached the maximum number of loans for this type of material.", + "request_err_max_loans": "You have reached the maximum number of active loans.", + "request_err_debt_block": "Loans are blocked until your balance with the library is settled.", + "request_err_card_expired": "Your library card has expired: ask the library to renew it.", + "request_err_suspended": "Your account is suspended: contact the library.", + "staff_err_media_limit": "This user has reached the maximum number of loans for this type of material.", + "staff_err_not_loanable": "This material is consultation only for the user's patron type.", + "staff_err_reservations_disabled": "Reservations are not allowed for the user's patron type.", + "staff_err_reservation_limit": "This user has reached the maximum number of reservations.", + "staff_err_debt_block": "Loans are blocked: this user's balance is above the debt limit.", + "staff_err_card_expired": "This user's library card has expired.", + "staff_err_user_suspended": "This user is suspended.", + "staff_copy_consultation_only": "Consultation only", + "profile_patron_type": "Patron type", + "profile_action_movements": "Balance and movements", + "title_movements": "Balance and movements", + "movements_loading": "Loading your balance…", + "movements_error_load": "Could not load your balance.", + "movements_balance": "Balance", + "movements_balance_owed": "Amount you owe the library.", + "movements_balance_credit": "Credit in your favour.", + "movements_balance_settled": "Nothing to pay.", + "movements_debt_blocked_title": "Loans blocked", + "movements_debt_blocked_body": "Your balance is above the library's limit of %1$s: new loans and renewals are blocked until it is settled.", + "movements_debt_blocked_body_plain": "New loans and renewals are blocked until your balance is settled.", + "movements_accrued_title": "Fines accruing on overdue loans: %1$s", + "movements_accrued_row": "Due %1$s · days late: %2$d · charged when returned", + "movements_history_title": "Movements", + "movements_empty_title": "No movements", + "movements_empty_subtitle": "Charges, payments and waivers will appear here.", + "movements_untitled": "Loan", + "movements_refresh_failed": "Could not refresh: the balance shown may be out of date.", + "staff_loan_closed_day": "The library is closed on %1$s: choose another start or return date." } diff --git a/i18n/fr.json b/i18n/fr.json index f51d7c5..2f10545 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -1453,5 +1453,46 @@ "digital_locked_login": "Connectez-vous pour l'ouvrir", "digital_locked_loan": "Pour les lecteurs qui ont emprunté ce livre", "digital_locked_other": "Non disponible pour votre compte", - "digital_link_refresh_error": "Impossible d’actualiser le lien. Réessayez." + "digital_link_refresh_error": "Impossible d’actualiser le lien. Réessayez.", + "book_loan_legend_closed": "Bibliothèque fermée", + "book_loan_closed_day": "La bibliothèque est fermée le %1$s : choisissez un autre jour.", + "book_consultation_only": "Consultation sur place uniquement", + "book_consultation_only_hint": "Vous pouvez consulter ce titre à la bibliothèque, mais il ne peut pas être emprunté avec votre carte.", + "book_reservations_disabled_hint": "Aucun exemplaire n'est libre pour le moment et votre carte ne permet pas de réserver. Réessayez quand un exemplaire sera revenu.", + "request_err_not_loanable": "Ce titre est en consultation sur place uniquement pour votre carte : il ne peut pas être emprunté.", + "request_err_reservations_disabled": "Votre carte ne permet pas de réserver.", + "request_err_reservation_limit": "Vous avez atteint le nombre maximal de réservations.", + "request_err_media_limit": "Vous avez atteint le nombre maximal de prêts pour ce type de document.", + "request_err_max_loans": "Vous avez atteint le nombre maximal de prêts en cours.", + "request_err_debt_block": "Les prêts sont bloqués tant que votre solde auprès de la bibliothèque n'est pas réglé.", + "request_err_card_expired": "Votre carte de bibliothèque a expiré : demandez à la bibliothèque de la renouveler.", + "request_err_suspended": "Votre compte est suspendu : contactez la bibliothèque.", + "staff_err_media_limit": "Cet utilisateur a atteint le nombre maximal de prêts pour ce type de document.", + "staff_err_not_loanable": "Ce document est en consultation sur place uniquement pour la catégorie de ce lecteur.", + "staff_err_reservations_disabled": "Les réservations ne sont pas autorisées pour la catégorie de ce lecteur.", + "staff_err_reservation_limit": "Ce lecteur a atteint le nombre maximal de réservations.", + "staff_err_debt_block": "Prêts bloqués : le solde de cet utilisateur dépasse le seuil d'endettement.", + "staff_err_card_expired": "La carte de bibliothèque de cet utilisateur a expiré.", + "staff_err_user_suspended": "Cet utilisateur est suspendu.", + "staff_copy_consultation_only": "Consultation sur place", + "profile_patron_type": "Catégorie de lecteur", + "profile_action_movements": "Solde et mouvements", + "title_movements": "Solde et mouvements", + "movements_loading": "Chargement du solde…", + "movements_error_load": "Impossible de charger le solde.", + "movements_balance": "Solde", + "movements_balance_owed": "Montant dû à la bibliothèque.", + "movements_balance_credit": "Crédit en votre faveur.", + "movements_balance_settled": "Rien à payer.", + "movements_debt_blocked_title": "Prêts bloqués", + "movements_debt_blocked_body": "Votre solde dépasse le seuil de %1$s fixé par la bibliothèque : les nouveaux prêts et les prolongations sont bloqués jusqu'à son règlement.", + "movements_debt_blocked_body_plain": "Les nouveaux prêts et les prolongations sont bloqués jusqu'au règlement de votre solde.", + "movements_accrued_title": "Amendes en cours sur les prêts en retard : %1$s", + "movements_accrued_row": "Échéance %1$s · jours de retard : %2$d · facturée au retour", + "movements_history_title": "Mouvements", + "movements_empty_title": "Aucun mouvement", + "movements_empty_subtitle": "Les frais, paiements et remises apparaîtront ici.", + "movements_untitled": "Prêt", + "movements_refresh_failed": "Échec de l'actualisation : le solde affiché n'est peut-être pas à jour.", + "staff_loan_closed_day": "La bibliothèque est fermée le %1$s : choisissez une autre date de début ou de retour." } diff --git a/i18n/it.json b/i18n/it.json index 580b07f..166cac7 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -1453,5 +1453,46 @@ "digital_locked_login": "Accedi per aprirlo", "digital_locked_loan": "Per chi ha il libro in prestito", "digital_locked_other": "Non disponibile per il tuo account", - "digital_link_refresh_error": "Impossibile aggiornare il collegamento. Riprova." + "digital_link_refresh_error": "Impossibile aggiornare il collegamento. Riprova.", + "book_loan_legend_closed": "Biblioteca chiusa", + "book_loan_closed_day": "Il %1$s la biblioteca è chiusa: scegli un altro giorno.", + "book_consultation_only": "Solo consultazione", + "book_consultation_only_hint": "Puoi consultare questo titolo in biblioteca, ma non è prestabile con la tua tessera.", + "book_reservations_disabled_hint": "Al momento non ci sono copie libere e la tua tessera non consente prenotazioni. Riprova quando una copia torna disponibile.", + "request_err_not_loanable": "Questo titolo è solo in consultazione per la tua tessera: non si può prendere in prestito.", + "request_err_reservations_disabled": "La tua tessera non consente prenotazioni.", + "request_err_reservation_limit": "Hai raggiunto il numero massimo di prenotazioni.", + "request_err_media_limit": "Hai raggiunto il numero massimo di prestiti per questo tipo di materiale.", + "request_err_max_loans": "Hai raggiunto il numero massimo di prestiti attivi.", + "request_err_debt_block": "I prestiti sono bloccati finché non saldi il tuo debito con la biblioteca.", + "request_err_card_expired": "La tua tessera è scaduta: chiedi alla biblioteca di rinnovarla.", + "request_err_suspended": "Il tuo account è sospeso: contatta la biblioteca.", + "staff_err_media_limit": "L'utente ha raggiunto il numero massimo di prestiti per questo tipo di materiale.", + "staff_err_not_loanable": "Questo materiale è solo in consultazione per la tipologia del lettore.", + "staff_err_reservations_disabled": "Le prenotazioni non sono consentite per la tipologia del lettore.", + "staff_err_reservation_limit": "Il lettore ha raggiunto il numero massimo di prenotazioni.", + "staff_err_debt_block": "Prestiti bloccati: il saldo dell'utente supera la soglia di debito.", + "staff_err_card_expired": "La tessera dell'utente è scaduta.", + "staff_err_user_suspended": "L'utente è sospeso.", + "staff_copy_consultation_only": "Solo consultazione", + "profile_patron_type": "Tipologia lettore", + "profile_action_movements": "Saldo e movimenti", + "title_movements": "Saldo e movimenti", + "movements_loading": "Caricamento del saldo…", + "movements_error_load": "Impossibile caricare il saldo.", + "movements_balance": "Saldo", + "movements_balance_owed": "Importo che devi alla biblioteca.", + "movements_balance_credit": "Credito a tuo favore.", + "movements_balance_settled": "Nulla da pagare.", + "movements_debt_blocked_title": "Prestiti bloccati", + "movements_debt_blocked_body": "Il tuo saldo supera la soglia di %1$s fissata dalla biblioteca: nuovi prestiti e rinnovi sono bloccati finché non lo saldi.", + "movements_debt_blocked_body_plain": "Nuovi prestiti e rinnovi sono bloccati finché non saldi il tuo debito.", + "movements_accrued_title": "Multe in maturazione sui prestiti scaduti: %1$s", + "movements_accrued_row": "Scadenza %1$s · giorni di ritardo: %2$d · addebitata alla restituzione", + "movements_history_title": "Movimenti", + "movements_empty_title": "Nessun movimento", + "movements_empty_subtitle": "Qui compariranno addebiti, pagamenti e condoni.", + "movements_untitled": "Prestito", + "movements_refresh_failed": "Aggiornamento non riuscito: il saldo mostrato potrebbe non essere aggiornato.", + "staff_loan_closed_day": "Il %1$s la biblioteca è chiusa: scegli un'altra data di inizio o di restituzione." }