Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d76b0d6
Restyle the loan request sheet after the web's loan calendar
fabiodalez-dev Oct 13, 2026
3177b28
Test the loan sheet's seventh column in a real 360dp layout
fabiodalez-dev Oct 13, 2026
a5fd001
Receive a wanted title with a clear action, then open its new copy
fabiodalez-dev Oct 14, 2026
0177791
Show the digital files a reader cannot open yet, with the reason
fabiodalez-dev Oct 15, 2026
c8c283a
Say the loan request lasts the library's loan period, not about a month
fabiodalez-dev Oct 20, 2026
df26241
Show the return date in the loan-request dialog
fabiodalez-dev Oct 20, 2026
bdb593c
Document the loan-request return-date tests
fabiodalez-dev Oct 20, 2026
539b437
Merge current calendar behaviour and add 15 regression tests
fabiodalez-dev Oct 20, 2026
e09b9ef
Merge styled calendar and retain the server-based return date
fabiodalez-dev Oct 20, 2026
1568f7d
Fix signed file renewal without device-clock or stale-cache assumptions
fabiodalez-dev Oct 20, 2026
847cd72
Open the received copy on its record even when the server leaves the …
fabiodalez-dev Oct 21, 2026
2784ecd
Keep a playing audiobook when another file of the record is renewed
fabiodalez-dev Oct 21, 2026
b2e5c39
Keep the 1.6.2 verification record at the 22 device tests it ran
fabiodalez-dev Oct 21, 2026
5ccd695
Merge the restored 1.6.2 device-test count from the calendar sheet br…
fabiodalez-dev Oct 21, 2026
c6ea0f2
Merge remote-tracking branch 'origin/fix/loan-calendar-style' into re…
fabiodalez-dev Oct 21, 2026
106eb7d
Merge remote-tracking branch 'origin/fix/wanted-receive-button' into …
fabiodalez-dev Oct 21, 2026
ba68795
Merge remote-tracking branch 'origin/feat/digital-access-480' into re…
fabiodalez-dev Oct 21, 2026
bbcbfc7
Merge the loan request return date (#51) into release/1.9.0
fabiodalez-dev Oct 21, 2026
95f4db8
Release 1.9.0 (versionCode 23)
fabiodalez-dev Oct 21, 2026
048ce7f
Pin the loan sheet's today in its test, match the copy code as the ed…
fabiodalez-dev Oct 21, 2026
b26be11
Say what the received copy's editor is for: status, notes and label
fabiodalez-dev Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,15 @@ The reference layout (top to bottom on a phone):
- then dark "Copia negli appunti", outline "Scarica RIS" and outline "Cita" (all styles).
15. **Potrebbero interessarti:** white band with a top `line` border, Fraunces 40 heading, a horizontal row of book cards.

### Loan request sheet

The sheet the "Richiedi prestito" button opens, as the web's loan request:

- A plain dialog, not Material's `DatePickerDialog` (that one caps its height and cut the month in half): 20dp corners, 8dp from the screen edges, at most 480dp wide, a Fraunces title, scrolling only on a screen too short for it.
- The month as the web's loan calendar: a free day is a plain number in `onSurface`; only the days that need a word carry a tint, amber for limited and `errorContainer` for unavailable, each ringed by a faint rule of its own text colour. Today has an accent ring; the chosen day is filled with the theme's `button` colour and `buttonText`, as the button that opened the sheet. The legend uses dots, like the days it explains.
- Confirm (filled) and cancel (outline) stacked at full width, never Material's text-button row. Confirm waits for a day and shows a spinner while the request is sent.
- When availability cannot be loaded, the same responsive month grid lets the reader select any future day. It omits the availability legend and shows a warning: the server checks the request. Weekday and month names follow the chosen app language; the legend wraps on narrow screens.

### Other screens
Wishlist, loans and reservations, profile, events, periodicals (Emeroteca), archive, static pages:

Expand Down
11 changes: 11 additions & 0 deletions STATUS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Pinakes Android — Build Status

## 1.9.0 (versionCode 23): digital file access, loan request sheet, wanted-title receipts

Pairs with Pinakes 0.8.6 (Mobile API 1.8.1). Works with older servers: every new field is optional and its absence keeps the 1.8.0 behaviour.

- **Digital files a reader cannot open yet** ([#50](https://github.com/fabiodalez-dev/Pinakes-Android/pull/50), Pinakes #480): a file the server closes (`url: null`, `access` = `login_required` or `loan_required`) keeps its card on the book page with a lock and the reason instead of the Read/Play button. Signed links last a day; a tap revalidates the file on the server and opens the renewed address, whatever the phone clock says, bypassing both caches and never falling back to an expired link. A playing audiobook keeps playing when another file of the record is renewed. Servers before 0.8.6 send neither field and every file stays open as before.
- **Loan request sheet** ([#46](https://github.com/fabiodalez-dev/Pinakes-Android/pull/46)): a plain dialog styled after the web's loan calendar instead of `DatePickerDialog`, which cut the month in half; free days are plain numbers, limited and unavailable days keep their tint, the fallback calendar (no availability) lets every future day be chosen and says the server checks the request.
- **Return date of a loan request** ([#51](https://github.com/fabiodalez-dev/Pinakes-Android/pull/51), Pinakes #485): with Mobile API 1.8.1 the sheet shows "Start · Return by" from the library's `loan_duration_days`; older servers get a hint about the library's standard loan period instead of "about 1 month".
- **Wanted titles** ([#49](https://github.com/fabiodalez-dev/Pinakes-Android/pull/49)): the row action reads "Receive" (it read as a status, "Received") and asks before adding the copy; after receiving a wanted title or an offer the app opens the record on the new copy's editor, which shows the code the server assigned and lets staff set its status and notes or print its label.
- No change to the local cache database (Room version 3).
- Verification on this release branch: 422 debug unit tests with none failed or skipped, lint and `assembleDebug`, locally and in Android CI; 17 device tests on an Android 15 emulator (`LoanRequestSheetUiTest`, `CalendarLayoutTest`, `DesiderataStaffFlowTest`, `DigitalLinkRefreshFlowTest`), none failed.

## 1.8.0 (versionCode 22): circulation desk, book tools and plugin content for staff

Requires Mobile API 1.7.0. Every area is gated on its own `/health` flag (`staff_circulation`, `staff_copies`, `staff_series`, `staff_merges`, `staff_labels`, `staff_imports`, `staff_covers`, `staff_desiderata`, `staff_archives`) plus the staff role and `staff_catalog`; a server that answers `false` or omits a flag keeps the 1.7.0 staff area for that part. The 403 `forbidden` contract is unchanged: every new call goes through the shared `staffCall`, which drops the stored role.
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,8 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
minSdk = 26
targetSdk = 35
versionCode = 22
versionName = "1.8.0"
versionCode = 23
versionName = "1.9.0"

vectorDrawables {
useSupportLibrary = true
Expand Down
1 change: 1 addition & 0 deletions app/src/androidTest/assets/fixtures/desiderata_wanted.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"data":[{"id":77,"title":"Non diciamo no da sole","author":"Sara Ahmed","publisher":"Fandango Libri","isbn":"9781256361909","cover_url":null,"open_offers":0}],"meta":{"next_cursor":null},"error":null}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"data":{"book_id":77,"copy_id":901,"offer_id":55},"meta":{},"error":null}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"data": {"id": 5, "title": "Orlando", "subtitle": "A Biography", "isbn13": "9780156701600", "year": 1928, "pages": 333, "language": "English", "media_type": "libro", "copies_total": 4, "copies_available": 1, "genre": {"id": 11, "name": "Romanzo"}, "authors": [{"id": 1, "name": "Virginia Woolf", "role": "principale"}, {"id": 7, "name": "Nadia Fusini", "role": "traduttore"}], "publishers": [{"id": 4, "name": "Hogarth"}], "location": {"shelf_id": 7, "shelf_code": "A", "level_id": 70, "level_number": 1, "position": 3, "label": "A.1.3"}, "copies": [{"id": 11, "inventory_number": "INV-0011", "status": "disponibile", "notes": null}, {"id": 12, "inventory_number": "INV-0012", "status": "prestato", "notes": "Spine worn"}, {"id": 77, "inventory_number": "LIB-5-C77", "status": "disponibile", "notes": null}, {"id": 78, "inventory_number": "LIB-5-C78", "status": "disponibile", "notes": null}], "is_wanted": false}, "meta": {}, "error": null}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"data": {"id": 77, "title": "Non diciamo no da sole", "subtitle": "A Biography", "isbn13": "9780156701600", "year": 1928, "pages": 333, "language": "English", "media_type": "libro", "copies_total": 3, "copies_available": 1, "genre": {"id": 11, "name": "Romanzo"}, "authors": [{"id": 1, "name": "Virginia Woolf", "role": "principale"}, {"id": 7, "name": "Nadia Fusini", "role": "traduttore"}], "publishers": [{"id": 4, "name": "Hogarth"}], "location": {"shelf_id": 7, "shelf_code": "A", "level_id": 70, "level_number": 1, "position": 3, "label": "A.1.3"}, "copies": [{"id": 11, "inventory_number": "INV-0011", "status": "disponibile", "notes": null}, {"id": 12, "inventory_number": "INV-0012", "status": "prestato", "notes": "Spine worn"}, {"id": 901, "inventory_number": "LIB-77-C901", "status": "disponibile", "notes": null}], "is_wanted": false}, "meta": {}, "error": null}
113 changes: 113 additions & 0 deletions app/src/androidTest/java/com/pinakes/app/LoanRequestSheetUiTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package com.pinakes.app

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.width
import com.pinakes.app.data.model.AvailabilityCalendar
import com.pinakes.app.data.model.AvailabilityDay
import com.pinakes.app.ui.screens.detail.LoanRequestSheet
import com.pinakes.app.ui.screens.detail.LoanRequestSheetContent
import com.pinakes.app.ui.theme.PinakesTheme
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import java.time.LocalDate

class LoanRequestSheetUiTest {
@get:Rule val compose = createComposeRule()

// A fixed day, passed to the sheet as its today: a run that crosses midnight cannot move
// the device clock away from the calendar built here.
private val today = LocalDate.of(2030, 3, 4)

/** Six weeks from today: mostly free, a partial day and a full one in the first week. */
private fun calendar(earliest: String? = today.plusDays(1).toString()) = AvailabilityCalendar(
totalCopies = 2,
earliestAvailable = earliest,
unavailableDates = listOf(today.plusDays(3).toString()),
days = (0L until 42L).map { offset ->
val date = today.plusDays(offset)
when (offset) {
2L -> AvailabilityDay(date.toString(), available = 1, loaned = 1, state = "partial")
3L -> AvailabilityDay(date.toString(), available = 0, loaned = 2, state = "full")
else -> AvailabilityDay(date.toString(), available = 2, state = "free")
}
},
)

private fun sheet(
calendar: AvailabilityCalendar? = calendar(),
fallback: Boolean = false,
submitting: Boolean = false,
onDismiss: () -> Unit = {},
onConfirm: (String) -> Unit = {},
) = compose.setContent {
PinakesTheme {
LoanRequestSheet(
loading = false,
calendar = calendar,
fallback = fallback,
submitting = submitting,
onDismiss = onDismiss,
onConfirm = onConfirm,
today = today,
)
}
}

@Test fun bothButtonsSpanTheSheetAndConfirmSendsTheEarliestDay() {
var confirmed: String? = null
var dismissed = false
sheet(onDismiss = { dismissed = true }, onConfirm = { confirmed = it })

val sheet = compose.onNodeWithTag("loan-sheet").getBoundsInRoot()
val confirm = compose.onNodeWithTag("loan-confirm").assertIsDisplayed().assertIsEnabled().getBoundsInRoot()
val cancel = compose.onNodeWithTag("loan-cancel").assertIsDisplayed().getBoundsInRoot()
assertEquals("Confirm and cancel share one width", confirm.width.value, cancel.width.value, .5f)
assertTrue("Buttons stay inside the sheet", confirm.left >= sheet.left && confirm.right <= sheet.right)
assertTrue("Cancel sits under confirm", cancel.top >= confirm.bottom)

compose.onNodeWithTag("loan-confirm").performClick()
compose.runOnIdle { assertEquals(today.plusDays(1).toString(), confirmed) }
compose.onNodeWithTag("loan-cancel").performClick()
compose.runOnIdle { assertTrue(dismissed) }
}

@Test fun confirmWaitsForADay() {
sheet(calendar = calendar(earliest = null))
compose.onNodeWithTag("loan-confirm").assertIsNotEnabled()
}

@Test fun confirmIsDisabledWhileTheRequestIsSent() {
sheet(submitting = true)
compose.onNodeWithTag("loan-confirm").assertIsNotEnabled()
compose.onNodeWithTag("loan-cancel").assertIsEnabled()
}

@Test fun fallbackGridKeepsItsSeventhColumnInsideTheSheet() {
// The sheet's content in a 360dp column, the narrowest common phone, on any emulator:
// the Dialog itself is a window with the device density, so it cannot be narrowed here.
compose.setContent {
PinakesTheme {
Box(Modifier.width(360.dp)) {
LoanRequestSheetContent(loading = false, calendar = null, fallback = true, submitting = false, onDismiss = {}, onConfirm = {}, today = today)
}
}
}
val sheet = compose.onNodeWithTag("loan-sheet").getBoundsInRoot()
val month = java.time.YearMonth.from(today)
val days = (1..month.lengthOfMonth()).map { day ->
compose.onNodeWithTag("calendar-day-${month.atDay(day)}", useUnmergedTree = true).fetchSemanticsNode()
}
assertEquals("Every day of the month is laid out", today.lengthOfMonth(), days.size)
// The fallback now uses the same weighted grid as the availability calendar.
val density = compose.density.density
val narrowest = days.minOf { it.boundsInRoot.width } / density
assertTrue("A day is clipped by the sheet: ${narrowest}dp", narrowest >= 30f)
assertTrue("A day overflows the sheet", days.maxOf { it.boundsInRoot.right } / density <= sheet.right.value + .5f)
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package com.pinakes.app.staffe2e

import com.pinakes.app.R
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onLast
import androidx.compose.ui.test.performClick
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test

/** Hub → Donation offers: receive an offer for a wanted title, receive one into a picked record, a closed offer. */
Expand All @@ -18,21 +23,53 @@ class DesiderataStaffFlowTest : AppFlowTest() {
}

@Test fun receiveAnOfferForAWantedTitle() {
open { json("POST staff/desiderata/offers/41/receive", "offer_41_received") }
open {
json("POST staff/desiderata/offers/41/receive", "offer_41_received")
json("GET staff/books/5", "staff_book_5_received")
}
clickTag("offer-41")
clickTag("offer-receive")
assertEquals("{}", lastBody("POST", "staff/desiderata/offers/41/receive"))
waitText(text(R.string.staff_offer_received))
// The new copy opens on the record, on its editor (status, notes, label).
waitFor(hasTestTag("copy-save"))
}

@Test fun anOfferWithoutATitleIsReceivedIntoAPickedRecord() {
open { json("POST staff/desiderata/offers/42/receive", "offer_42_received") }
open {
json("POST staff/desiderata/offers/42/receive", "offer_42_received")
json("GET staff/books/5", "staff_book_5_received")
}
clickTag("offer-42")
clickTag("offer-receive")
waitText(text(R.string.staff_offer_receive_into))
clickTag("pick-5")
assertEquals("""{"received_book_id":5}""", lastBody("POST", "staff/desiderata/offers/42/receive"))
waitText(text(R.string.staff_offer_received))
waitFor(hasTestTag("copy-save"))
}

@Test fun aWantedTitleIsReceivedOnlyAfterConfirming() {
open {
json("GET staff/desiderata/wanted", "desiderata_wanted")
json("POST staff/desiderata/wanted/77/receive", "desiderata_wanted_77_received", 201)
json("GET staff/books/77", "staff_book_77_received")
}
click(text(R.string.staff_offers_wanted))
waitText("Non diciamo no da sole")
// The row's button is an action, not a status: a tap only asks.
click(text(R.string.staff_wanted_mark_received))
waitText(text(R.string.staff_wanted_receive_confirm_title))
assertTrue(api.recorded("POST", "staff/desiderata/wanted/77/receive").isEmpty())
click(text(R.string.action_cancel))
assertTrue(api.recorded("POST", "staff/desiderata/wanted/77/receive").isEmpty())
click(text(R.string.staff_wanted_mark_received))
waitText(text(R.string.staff_wanted_receive_confirm_title))
compose.onAllNodesWithText(text(R.string.staff_wanted_mark_received)).onLast().performClick()
waitRequest("POST", "staff/desiderata/wanted/77/receive")
assertEquals(1, api.recorded("POST", "staff/desiderata/wanted/77/receive").size)
// Received: the record opens on the new copy's editor, for its status and notes. The
// server gives the copy its code, which no client changes afterwards (web included).
waitFor(hasTestTag("copy-save"))
waitText(text(R.string.staff_copy_code_value, "LIB-77-C901"))
}

@Test fun aClosedOfferSaysSo() {
Expand Down
Loading
Loading