From 72c15ed870a2201f7d12ef5ca49b153fd9bf2fdc Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 00:13:20 +0100 Subject: [PATCH 1/7] Align with Pinakes 0.9.0: feature toggles, closures calendar, patron-type rules, fines ledger Read the three 0.9.0 flags from /health (fines, closures_calendar, circulation_rules), default off and forced off in catalogue mode; the /health mapping is now a pure InstanceFeatures.from(). Closures calendar: closed_dates paints closed days grey with their own legend entry, they cannot be a start, a tap says the library is closed, the pre-selected day skips them, and the proposed return always lands on an open day (forward for the default return, back for a shortened end_date). Circulation rules: with circulation_rules on, the book page reads loanable and reservations_enabled and keeps the loan button visible but disabled with the reason. The 409 codes not_loanable, reservations_disabled, reservation_limit_reached, media_limit_reached, max_loans_reached and not_eligible with meta.reason are mapped for readers and staff; unknown codes keep the server message. Balance and movements page behind features.fines (GET /me/movements); a 404 feature_disabled closes it. Additive patron_type on the profile and consultation_only on staff copies. 48 new unit tests (pure functions plus a Robolectric check of the disabled button). --- STATUS.md | 12 + .../staffe2e/DigitalLinkRefreshFlowTest.kt | 2 +- .../java/com/pinakes/app/data/model/Models.kt | 23 ++ .../pinakes/app/data/model/MovementsModels.kt | 53 +++++ .../com/pinakes/app/data/model/StaffModels.kt | 2 + .../pinakes/app/data/network/PinakesApi.kt | 4 + .../app/data/repository/LibraryRepository.kt | 6 + .../com/pinakes/app/data/staff/StaffErrors.kt | 27 ++- .../pinakes/app/data/store/FeatureStore.kt | 105 ++++++--- .../pinakes/app/ui/navigation/MainScaffold.kt | 2 + .../app/ui/navigation/PinakesNavHost.kt | 10 + .../com/pinakes/app/ui/navigation/Routes.kt | 2 + .../app/ui/screens/detail/BookDetailScreen.kt | 35 ++- .../ui/screens/detail/BookDetailViewModel.kt | 38 +++- .../app/ui/screens/detail/CirculationRules.kt | 84 +++++++ .../app/ui/screens/detail/LoanCalendar.kt | 27 ++- .../app/ui/screens/detail/LoanWindow.kt | 62 +++++- .../ui/screens/movements/MovementsScreen.kt | 209 ++++++++++++++++++ .../screens/movements/MovementsViewModel.kt | 111 ++++++++++ .../app/ui/screens/profile/ProfileScreen.kt | 14 +- .../circulation/StaffLoanCreateScreen.kt | 5 +- .../staff/circulation/StaffUserScreen.kt | 4 +- .../staff/content/StaffCopiesSection.kt | 13 +- .../app/server090/CirculationRulesTest.kt | 85 +++++++ .../app/server090/ClosedDatesCalendarTest.kt | 120 ++++++++++ .../server090/ConsultationOnlyButtonTest.kt | 90 ++++++++ .../app/server090/FeatureFlags090Test.kt | 76 +++++++ .../pinakes/app/server090/MovementsTest.kt | 103 +++++++++ .../app/server090/StaffErrors090Test.kt | 58 +++++ i18n/de.json | 41 +++- i18n/en.json | 41 +++- i18n/fr.json | 41 +++- i18n/it.json | 41 +++- 33 files changed, 1480 insertions(+), 66 deletions(-) create mode 100644 app/src/main/java/com/pinakes/app/data/model/MovementsModels.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/detail/CirculationRules.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/movements/MovementsScreen.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/movements/MovementsViewModel.kt create mode 100644 app/src/test/java/com/pinakes/app/server090/CirculationRulesTest.kt create mode 100644 app/src/test/java/com/pinakes/app/server090/ClosedDatesCalendarTest.kt create mode 100644 app/src/test/java/com/pinakes/app/server090/ConsultationOnlyButtonTest.kt create mode 100644 app/src/test/java/com/pinakes/app/server090/FeatureFlags090Test.kt create mode 100644 app/src/test/java/com/pinakes/app/server090/MovementsTest.kt create mode 100644 app/src/test/java/com/pinakes/app/server090/StaffErrors090Test.kt 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/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..58bd2cb --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/model/MovementsModels.kt @@ -0,0 +1,53 @@ +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, + @SerialName("balance_cents") val balanceCents: Int = 0, + /** Decimal string in [currency] ("4.50"); the app formats [balanceCents] when it is missing. */ + val balance: String? = null, + @SerialName("accrued_cents") val accruedCents: Int = 0, + 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(), +) + +/** 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..921bad7 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,14 @@ 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, + 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 +90,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 && circulation, + closuresCalendarAvailable = f.closuresCalendar && circulation, + circulationRulesAvailable = f.circulationRules && circulation, + ) + } } } @@ -112,39 +171,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 +207,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 +269,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 +307,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..2ff43a0 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,31 +93,44 @@ 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) { + // The server moves a return that falls on a closed day to the next open one. + val end = start.plusDays(loanDurationDays.toLong()).let { calendar?.openDayFrom(it, forward = true) ?: it } return LoanWindow( - endDate = start.plusDays(loanDurationDays.toLong()).toString(), + endDate = end.toString(), firstBlocked = null, - maxDays = loanDurationDays, + maxDays = ChronoUnit.DAYS.between(start, end).toInt(), capped = false, 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 + val rawEnd = firstBlocked?.let { LocalDate.parse(it).minusDays(1) } ?: defaultEnd + val shortened = rawEnd.isBefore(start.plusDays(loanDurationDays.toLong())) + // A return on a closed day: the server's default return moves to the next open day, but a + // shortened one (sent as end_date) cannot move past the day that shortened it, so it moves + // back to the last open day after the start; when there is none, no loan fits. + val end = when { + !cal.isClosed(rawEnd.toString()) -> rawEnd + shortened -> cal.openDayFrom(rawEnd, forward = false, notBefore = start.plusDays(1)) ?: start + else -> cal.openDayFrom(rawEnd, forward = true) ?: rawEnd + } 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, ) } 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..3d13ec6 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/movements/MovementsScreen.kt @@ -0,0 +1,209 @@ +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.items +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.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.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() + } + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.title_movements), onNavigateUp = onNavigateUp) }, + ) { padding -> + PullToRefreshBox( + isRefreshing = state.refreshing, + onRefresh = vm::refresh, + modifier = Modifier.fillMaxSize().padding(padding), + ) { + when (val content = state.content) { + is UiState.Loading -> LoadingState(label = stringResource(R.string.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) } + if (data.accrued.isNotEmpty()) { + item(key = "accrued-title") { + SectionHeader(title = stringResource(R.string.movements_accrued_title, money(data.accruedCents))) + } + items(data.accrued, key = { "accrued-${it.loanId}" }) { AccruedRow(it, 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 { + items(data.items, key = { "movement-${it.id}" }) { MovementRow(it, money) } + } + } +} + +@Composable +private fun BalanceCard(data: MovementsPayload, money: (Int) -> String) { + val kind = MoneyFormat.balanceKind(data.balanceCents) + 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.balanceCents)), + 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..bb32175 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/movements/MovementsViewModel.kt @@ -0,0 +1,111 @@ +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, +) { + 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) + 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) + 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 { + + 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() + } + + 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/circulation/StaffLoanCreateScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt index dccc753..9b3a64b 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 @@ -276,7 +276,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), 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..dc638c5 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/ClosedDatesCalendarTest.kt @@ -0,0 +1,120 @@ +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 anOlderServerWithoutTheMaximumAlsoMovesTheReturn() { + val window = loanWindowFrom("2030-03-04", calendar(listOf("2030-03-18"), maxDuration = null), 14) + assertEquals("2030-03-19", window?.endDate) + assertFalse(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..42ef06c --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/FeatureFlags090Test.kt @@ -0,0 +1,76 @@ +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 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..bafdf61 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/MovementsTest.kt @@ -0,0 +1,103 @@ +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 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.balanceCents) + 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 aFailedRefreshKeepsThePageOnScreen() { + 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) + } + + @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/i18n/de.json b/i18n/de.json index d8fd814..b8a04e9 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1453,5 +1453,44 @@ "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" } diff --git a/i18n/en.json b/i18n/en.json index 33f4962..35aff8b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1453,5 +1453,44 @@ "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" } diff --git a/i18n/fr.json b/i18n/fr.json index f51d7c5..1c19e34 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -1453,5 +1453,44 @@ "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" } diff --git a/i18n/it.json b/i18n/it.json index 580b07f..c0e3538 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -1453,5 +1453,44 @@ "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" } From e3e6c13a5e0abfba9490bc4e8fceeeb7a8c83a47 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 00:13:49 +0100 Subject: [PATCH 2/7] Contract: health sample from a Pinakes 0.9.0 server with the feature toggles --- _contract/health-sample.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 4e25f7122b7e00dc937380ead501274a805e5e24 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 00:30:56 +0100 Subject: [PATCH 3/7] Review fixes: return over a closure stops before a booking, safer movements page - A default return moved forward over closed days now stops on the last open day before a fully-booked day it meets, capped and sent as end_date, mirroring the server's renewal cap. Without max_loan_duration_days (servers before closures existed) the default return is left as it is. - Movements: LazyColumn keys fall back to the row position when the server omits an id; the accrued total shows even without per-loan rows; a failed refresh keeps the balance and says it may be out of date. - Document why the calendar follows closed_dates rather than the closures_calendar flag. --- .../pinakes/app/data/store/FeatureStore.kt | 5 +++ .../app/ui/screens/detail/LoanWindow.kt | 38 +++++++++++++------ .../ui/screens/movements/MovementsScreen.kt | 23 +++++++++-- .../screens/movements/MovementsViewModel.kt | 14 ++++++- .../app/server090/ClosedDatesCalendarTest.kt | 23 ++++++++++- .../pinakes/app/server090/MovementsTest.kt | 13 ++++++- i18n/de.json | 3 +- i18n/en.json | 3 +- i18n/fr.json | 3 +- i18n/it.json | 3 +- 10 files changed, 104 insertions(+), 24 deletions(-) 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 921bad7..c3ac7ef 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 @@ -76,6 +76,11 @@ data class InstanceFeatures( * 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, ) { 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 2ff43a0..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 @@ -93,12 +93,12 @@ 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) { - // The server moves a return that falls on a closed day to the next open one. - val end = start.plusDays(loanDurationDays.toLong()).let { calendar?.openDayFrom(it, forward = true) ?: it } + // 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 = end.toString(), + endDate = start.plusDays(loanDurationDays.toLong()).toString(), firstBlocked = null, - maxDays = ChronoUnit.DAYS.between(start, end).toInt(), + maxDays = loanDurationDays, capped = false, requiresEndDate = false, ) @@ -116,21 +116,37 @@ internal fun loanWindowFrom(startIso: String, calendar: AvailabilityCalendar?, l } } val defaultEnd = start.plusDays(durationDays.toLong()) - val rawEnd = 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 return on a closed day: the server's default return moves to the next open day, but a - // shortened one (sent as end_date) cannot move past the day that shortened it, so it moves - // back to the last open day after the start; when there is none, no loan fits. + // 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 - shortened -> cal.openDayFrom(rawEnd, forward = false, notBefore = start.plusDays(1)) ?: start - else -> cal.openDayFrom(rawEnd, forward = true) ?: 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 = shortened, + 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 index 3d13ec6..fe9ed64 100644 --- 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 @@ -8,18 +8,21 @@ 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.items +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 @@ -60,9 +63,19 @@ fun MovementsScreen( 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, @@ -88,11 +101,13 @@ private fun MovementsContent(data: MovementsPayload) { verticalArrangement = Arrangement.spacedBy(Spacing.sm), ) { item(key = "balance") { BalanceCard(data, money) } - if (data.accrued.isNotEmpty()) { + // The total shows even when the server sends it without the per-loan rows. + if (data.accruedCents != 0 || data.accrued.isNotEmpty()) { item(key = "accrued-title") { SectionHeader(title = stringResource(R.string.movements_accrued_title, money(data.accruedCents))) } - items(data.accrued, key = { "accrued-${it.loanId}" }) { AccruedRow(it, money) } + // 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()) { @@ -105,7 +120,7 @@ private fun MovementsContent(data: MovementsPayload) { ) } } else { - items(data.items, key = { "movement-${it.id}" }) { MovementRow(it, money) } + itemsIndexed(data.items, key = { i, m -> MoneyFormat.rowKey("movement", i, m.id) }) { _, m -> MovementRow(m, money) } } } } 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 index bb32175..fc53f2c 100644 --- 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 @@ -27,6 +27,8 @@ data class MovementsUiState( * 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 { /** @@ -35,10 +37,10 @@ data class MovementsUiState( * 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) + 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) + 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) } } @@ -50,6 +52,12 @@ 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 @@ -102,6 +110,8 @@ class MovementsViewModel @Inject constructor( fetch() } + fun consumeRefreshFailed() = _state.update { it.copy(refreshFailed = false) } + private fun fetch() { viewModelScope.launch { val res = library.movements() diff --git a/app/src/test/java/com/pinakes/app/server090/ClosedDatesCalendarTest.kt b/app/src/test/java/com/pinakes/app/server090/ClosedDatesCalendarTest.kt index dc638c5..0136128 100644 --- a/app/src/test/java/com/pinakes/app/server090/ClosedDatesCalendarTest.kt +++ b/app/src/test/java/com/pinakes/app/server090/ClosedDatesCalendarTest.kt @@ -91,12 +91,31 @@ class ClosedDatesCalendarTest { assertFalse(window.capped) } - @Test fun anOlderServerWithoutTheMaximumAlsoMovesTheReturn() { + @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-19", window?.endDate) + 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) diff --git a/app/src/test/java/com/pinakes/app/server090/MovementsTest.kt b/app/src/test/java/com/pinakes/app/server090/MovementsTest.kt index bafdf61..59b8cf0 100644 --- a/app/src/test/java/com/pinakes/app/server090/MovementsTest.kt +++ b/app/src/test/java/com/pinakes/app/server090/MovementsTest.kt @@ -70,11 +70,22 @@ class MovementsTest { assertEquals(R.string.movements_error_load, (s.content as UiState.Error).messageRes) } - @Test fun aFailedRefreshKeepsThePageOnScreen() { + @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() { diff --git a/i18n/de.json b/i18n/de.json index b8a04e9..469a867 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1492,5 +1492,6 @@ "movements_history_title": "Bewegungen", "movements_empty_title": "Keine Bewegungen", "movements_empty_subtitle": "Hier erscheinen Gebühren, Zahlungen und Erlasse.", - "movements_untitled": "Ausleihe" + "movements_untitled": "Ausleihe", + "movements_refresh_failed": "Aktualisierung fehlgeschlagen: Der angezeigte Kontostand ist möglicherweise nicht aktuell." } diff --git a/i18n/en.json b/i18n/en.json index 35aff8b..94163af 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1492,5 +1492,6 @@ "movements_history_title": "Movements", "movements_empty_title": "No movements", "movements_empty_subtitle": "Charges, payments and waivers will appear here.", - "movements_untitled": "Loan" + "movements_untitled": "Loan", + "movements_refresh_failed": "Could not refresh: the balance shown may be out of date." } diff --git a/i18n/fr.json b/i18n/fr.json index 1c19e34..ea8f1b4 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -1492,5 +1492,6 @@ "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_untitled": "Prêt", + "movements_refresh_failed": "Échec de l'actualisation : le solde affiché n'est peut-être pas à jour." } diff --git a/i18n/it.json b/i18n/it.json index c0e3538..0f548a0 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -1492,5 +1492,6 @@ "movements_history_title": "Movimenti", "movements_empty_title": "Nessun movimento", "movements_empty_subtitle": "Qui compariranno addebiti, pagamenti e condoni.", - "movements_untitled": "Prestito" + "movements_untitled": "Prestito", + "movements_refresh_failed": "Aggiornamento non riuscito: il saldo mostrato potrebbe non essere aggiornato." } From 6ad575758602fd725cb69ac3b2795386080d1356 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 01:35:07 +0100 Subject: [PATCH 4/7] Movements: an omitted accrued_cents falls back to the sum of the accrued rows --- .../java/com/pinakes/app/data/model/MovementsModels.kt | 8 ++++++-- .../pinakes/app/ui/screens/movements/MovementsScreen.kt | 4 ++-- .../test/java/com/pinakes/app/server090/MovementsTest.kt | 8 ++++++++ 3 files changed, 16 insertions(+), 4 deletions(-) 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 index 58bd2cb..29536d0 100644 --- a/app/src/main/java/com/pinakes/app/data/model/MovementsModels.kt +++ b/app/src/main/java/com/pinakes/app/data/model/MovementsModels.kt @@ -18,13 +18,17 @@ data class MovementsPayload( @SerialName("balance_cents") val balanceCents: Int = 0, /** Decimal string in [currency] ("4.50"); the app formats [balanceCents] when it is missing. */ val balance: String? = null, - @SerialName("accrued_cents") val accruedCents: Int = 0, + /** 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 } +} /** One ledger entry: a charge (positive amount), a payment, waiver or reversal (negative). */ @Serializable 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 index fe9ed64..b4324c5 100644 --- 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 @@ -102,9 +102,9 @@ private fun MovementsContent(data: MovementsPayload) { ) { item(key = "balance") { BalanceCard(data, money) } // The total shows even when the server sends it without the per-loan rows. - if (data.accruedCents != 0 || data.accrued.isNotEmpty()) { + if (data.accruedTotalCents != 0 || data.accrued.isNotEmpty()) { item(key = "accrued-title") { - SectionHeader(title = stringResource(R.string.movements_accrued_title, money(data.accruedCents))) + 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) } diff --git a/app/src/test/java/com/pinakes/app/server090/MovementsTest.kt b/app/src/test/java/com/pinakes/app/server090/MovementsTest.kt index 59b8cf0..2579115 100644 --- a/app/src/test/java/com/pinakes/app/server090/MovementsTest.kt +++ b/app/src/test/java/com/pinakes/app/server090/MovementsTest.kt @@ -41,6 +41,14 @@ class MovementsTest { 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 thePlannedNameMovementsIsReadToo() { val p = json.decodeFromString("""{"balance_cents":0,"movements":[{"id":5,"type":"condono","amount_cents":-100}]}""") assertEquals(5, p.items.single().id) From 9d1e87f18665ff06ba8b700c6bae936c36a02abc Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 02:58:07 +0100 Subject: [PATCH 5/7] Review fixes: app access gates the 0.9.0 flags, closed days in the staff loan form, balance fallback - fines, closures_calendar and circulation_rules also need app_access_enabled, like every other area flag. - Staff new loan/reservation: both date pickers refuse the library's closed days, and submit names a closed start or return instead of sending it. - Movements: a missing balance_cents falls back to the decimal balance. --- .../pinakes/app/data/model/MovementsModels.kt | 13 +++++++++-- .../pinakes/app/data/store/FeatureStore.kt | 6 ++--- .../ui/screens/movements/MovementsScreen.kt | 4 ++-- .../app/ui/screens/staff/StaffCommon.kt | 13 ++++++++++- .../circulation/StaffLoanCreateScreen.kt | 15 ++++++++++-- .../app/server090/FeatureFlags090Test.kt | 11 +++++++++ .../pinakes/app/server090/MovementsTest.kt | 10 +++++++- .../app/server090/StaffLoanClosedDaysTest.kt | 23 +++++++++++++++++++ i18n/de.json | 3 ++- i18n/en.json | 3 ++- i18n/fr.json | 3 ++- i18n/it.json | 3 ++- 12 files changed, 92 insertions(+), 15 deletions(-) create mode 100644 app/src/test/java/com/pinakes/app/server090/StaffLoanClosedDaysTest.kt 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 index 29536d0..c57e59b 100644 --- a/app/src/main/java/com/pinakes/app/data/model/MovementsModels.kt +++ b/app/src/main/java/com/pinakes/app/data/model/MovementsModels.kt @@ -15,8 +15,9 @@ import kotlinx.serialization.json.JsonNames @Serializable data class MovementsPayload( val currency: String? = null, - @SerialName("balance_cents") val balanceCents: Int = 0, - /** Decimal string in [currency] ("4.50"); the app formats [balanceCents] when it is missing. */ + /** 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, @@ -28,6 +29,14 @@ data class MovementsPayload( ) { /** 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). */ 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 c3ac7ef..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 @@ -144,9 +144,9 @@ data class InstanceFeatures( 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 && circulation, - closuresCalendarAvailable = f.closuresCalendar && circulation, - circulationRulesAvailable = f.circulationRules && circulation, + finesAvailable = f.fines && app && circulation, + closuresCalendarAvailable = f.closuresCalendar && app && circulation, + circulationRulesAvailable = f.circulationRules && app && circulation, ) } } 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 index b4324c5..274809c 100644 --- 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 @@ -127,7 +127,7 @@ private fun MovementsContent(data: MovementsPayload) { @Composable private fun BalanceCard(data: MovementsPayload, money: (Int) -> String) { - val kind = MoneyFormat.balanceKind(data.balanceCents) + val kind = MoneyFormat.balanceKind(data.balanceTotalCents) Column(Modifier.padding(horizontal = Spacing.lg), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { Surface( shape = MaterialTheme.shapes.medium, @@ -138,7 +138,7 @@ private fun BalanceCard(data: MovementsPayload, money: (Int) -> String) { Text(stringResource(R.string.movements_balance), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Text( - money(kotlin.math.abs(data.balanceCents)), + money(kotlin.math.abs(data.balanceTotalCents)), style = MaterialTheme.typography.headlineMedium, color = if (kind == BalanceKind.Owed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, ) 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 9b3a64b..4e64e76 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 @@ -96,6 +96,12 @@ data class LoanCreateUiState( val canHandOver: Boolean get() = !reservation && (start == null || start == today) val canSubmit: Boolean get() = user != null && book != null && !saving + + /** Closed days of the library (closures calendar) from the availability payload. */ + val closedDates: Set get() = calendar?.closedDates?.toSet().orEmpty() + + /** The chosen start or end when it falls on a day the library is closed, else null. */ + val closedDateChosen: String? get() = listOfNotNull(start, end).firstOrNull { it in closedDates } } /** @@ -195,6 +201,11 @@ class StaffLoanCreateViewModel @Inject constructor( _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) { @@ -292,8 +303,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/test/java/com/pinakes/app/server090/FeatureFlags090Test.kt b/app/src/test/java/com/pinakes/app/server090/FeatureFlags090Test.kt index 42ef06c..de62098 100644 --- a/app/src/test/java/com/pinakes/app/server090/FeatureFlags090Test.kt +++ b/app/src/test/java/com/pinakes/app/server090/FeatureFlags090Test.kt @@ -48,6 +48,17 @@ class FeatureFlags090Test { 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) diff --git a/app/src/test/java/com/pinakes/app/server090/MovementsTest.kt b/app/src/test/java/com/pinakes/app/server090/MovementsTest.kt index 2579115..c972a31 100644 --- a/app/src/test/java/com/pinakes/app/server090/MovementsTest.kt +++ b/app/src/test/java/com/pinakes/app/server090/MovementsTest.kt @@ -49,6 +49,14 @@ class MovementsTest { 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) @@ -56,7 +64,7 @@ class MovementsTest { @Test fun aBarePayloadMissingEveryFieldStillParses() { val p = json.decodeFromString("{}") - assertEquals(0, p.balanceCents) + assertEquals(0, p.balanceTotalCents) assertFalse(p.debtBlocked) assertNull(p.debtBlockThresholdCents) assertTrue(p.items.isEmpty()) 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..484f4a5 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/StaffLoanClosedDaysTest.kt @@ -0,0 +1,23 @@ +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.assertNull +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 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 469a867..8b76b1a 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1493,5 +1493,6 @@ "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." + "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 94163af..b546822 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1493,5 +1493,6 @@ "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." + "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 ea8f1b4..2f10545 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -1493,5 +1493,6 @@ "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." + "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 0f548a0..166cac7 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -1493,5 +1493,6 @@ "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." + "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." } From 4c565c95de4bc4e35594b1d3c3a8934d7a655d14 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 03:53:24 +0100 Subject: [PATCH 6/7] Staff loan form: check today for a loan without a start, wait for the calendar before submit --- .../circulation/StaffLoanCreateScreen.kt | 19 +++++++++++++------ .../app/server090/StaffLoanClosedDaysTest.kt | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) 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 4e64e76..a9a87cb 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 @@ -83,6 +83,8 @@ 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, val picker: DeskPicker? = null, val saving: Boolean = false, val error: StaffMessage? = null, @@ -95,13 +97,16 @@ 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 chosen start or end when it falls on a day the library is closed, else null. */ - val closedDateChosen: String? get() = listOfNotNull(start, end).firstOrNull { it in closedDates } + /** + * 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 } } /** @@ -180,9 +185,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 + _state.update { it.copy(calendarLoading = true) } 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.copy(calendar = res.data, calendarFailed = false, calendarLoading = false) } + // A failed fetch keeps the old fallback: the form can be sent, the server decides. + is ApiResult.Failure -> _state.update { it.copy(calendar = null, calendarFailed = true, calendarLoading = false, forbidden = res.isForbidden()) } } } @@ -196,7 +203,7 @@ 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 diff --git a/app/src/test/java/com/pinakes/app/server090/StaffLoanClosedDaysTest.kt b/app/src/test/java/com/pinakes/app/server090/StaffLoanClosedDaysTest.kt index 484f4a5..f7eef8b 100644 --- a/app/src/test/java/com/pinakes/app/server090/StaffLoanClosedDaysTest.kt +++ b/app/src/test/java/com/pinakes/app/server090/StaffLoanClosedDaysTest.kt @@ -3,7 +3,10 @@ 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. */ @@ -15,6 +18,22 @@ class StaffLoanClosedDaysTest { 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 openDaysOrNoCalendarPass() { assertNull(LoanCreateUiState(calendar = calendar, start = "2030-03-04", end = "2030-03-18").closedDateChosen) assertNull(LoanCreateUiState(calendar = null, start = "2030-03-09").closedDateChosen) From c4e60f73fcea0f019e60dd3b36bd3eb62c21f9e6 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 05:29:22 +0100 Subject: [PATCH 7/7] Staff loan form: apply only the latest availability answer Picking another book or user while the availability was still loading let the older answer end the loading and, on failure, clear the calendar, so the form could be sent without the closures check. Each request now carries a number and an answer to a superseded request is dropped. --- .../circulation/StaffLoanCreateScreen.kt | 21 +++++++++++++--- .../app/server090/StaffLoanClosedDaysTest.kt | 25 +++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) 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 a9a87cb..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 } @@ -85,6 +86,8 @@ data class LoanCreateUiState( 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, @@ -107,6 +110,18 @@ data class LoanCreateUiState( * 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) } /** @@ -185,11 +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 - _state.update { it.copy(calendarLoading = true) } + 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, calendarLoading = false) } + 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.copy(calendar = null, calendarFailed = true, calendarLoading = false, forbidden = res.isForbidden()) } + is ApiResult.Failure -> _state.update { it.withCalendarResult(request, null, forbidden = res.isForbidden()) } } } diff --git a/app/src/test/java/com/pinakes/app/server090/StaffLoanClosedDaysTest.kt b/app/src/test/java/com/pinakes/app/server090/StaffLoanClosedDaysTest.kt index f7eef8b..02d8fc2 100644 --- a/app/src/test/java/com/pinakes/app/server090/StaffLoanClosedDaysTest.kt +++ b/app/src/test/java/com/pinakes/app/server090/StaffLoanClosedDaysTest.kt @@ -34,6 +34,31 @@ class StaffLoanClosedDaysTest { 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)