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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions STATUS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Pinakes Android — Build Status

## 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.

- **Loan requests stop before a scheduled loan** ([#53](https://github.com/fabiodalez-dev/Pinakes-Android/pull/53), Pinakes #490): when a scheduled loan or a reservation on every copy begins inside the requested period, the sheet ends the loan the day before, says how long it can be and from when the book is booked, and sends that day as `end_date` of `POST /reservations`. A start whose next day is already booked says no loan is possible from it and keeps the request button off. The window also respects the library's `max_loan_duration_days`. One predicate decides which days are fully booked, for the calendar and the cap alike. The cap needs a server that reads `end_date`, which is the one that sends `max_loan_duration_days`: on Pinakes 0.8.6 (Mobile API 1.8.1) the sheet keeps the 1.9.0 behaviour, uncapped and without `end_date`.
- No change to the local cache database (Room version 3).
- Verification on this release branch: 446 debug unit tests with none failed or skipped, lint and `assembleDebug`; 17 device tests on an Android 15 emulator (`LoanRequestSheetUiTest`, `CalendarLayoutTest`, `DesiderataStaffFlowTest`, `DigitalLinkRefreshFlowTest`), none failed.

## 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.
Expand Down
2 changes: 1 addition & 1 deletion _contract/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -839,7 +839,7 @@
"type": "string",
"format": "date",
"nullable": true,
"deprecated": true
"description": "Optional return date (Mobile API 1.8.2). Omitted, the library's loan_duration_days is added to the start. Send the day before the first fully-booked day of GET /catalog/books/{id}/availability when the default would cross it; must not precede the start nor exceed max_loan_duration_days (422 max_duration_exceeded)."
}
}
},
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 = 23
versionName = "1.9.0"
versionCode = 24
versionName = "1.10.0"

vectorDrawables {
useSupportLibrary = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class CalendarLayoutTest {
}

@Test fun loanDialogMonthFitsThePhoneScreenAndNavigates() {
compose.setContent { PinakesTheme { LoanRequestSheet(loading = false, calendar = calendar(), fallback = false, submitting = false, onDismiss = {}, onConfirm = {}) } }
compose.setContent { PinakesTheme { LoanRequestSheet(loading = false, calendar = calendar(), fallback = false, submitting = false, onDismiss = {}, onConfirm = { _, _ -> }) } }
val metrics = context.resources.displayMetrics
val screen = Rect(0f, 0f, metrics.widthPixels.toFloat(), metrics.heightPixels.toFloat())
val dialog = compose.onNodeWithTag("loan-dialog").fetchSemanticsNode().boundsInRoot
Expand All @@ -70,7 +70,7 @@ class CalendarLayoutTest {
}

@Test fun fallbackDialogUsesTheSameFittingGrid() {
compose.setContent { PinakesTheme { LoanRequestSheet(loading = false, calendar = null, fallback = true, submitting = false, onDismiss = {}, onConfirm = {}) } }
compose.setContent { PinakesTheme { LoanRequestSheet(loading = false, calendar = null, fallback = true, submitting = false, onDismiss = {}, onConfirm = { _, _ -> }) } }
val dialog = compose.onNodeWithTag("loan-dialog").fetchSemanticsNode().boundsInRoot
assertMonthInside(YearMonth.now(), dialog)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ class LoanRequestSheetUiTest {
fallback: Boolean = false,
submitting: Boolean = false,
onDismiss: () -> Unit = {},
onConfirm: (String) -> Unit = {},
onConfirm: (String, String?) -> Unit = { _, _ -> },
) = compose.setContent {
PinakesTheme {
LoanRequestSheet(
Expand All @@ -61,8 +61,9 @@ class LoanRequestSheetUiTest {

@Test fun bothButtonsSpanTheSheetAndConfirmSendsTheEarliestDay() {
var confirmed: String? = null
var confirmedEnd: String? = "unset"
var dismissed = false
sheet(onDismiss = { dismissed = true }, onConfirm = { confirmed = it })
sheet(onDismiss = { dismissed = true }, onConfirm = { start, end -> confirmed = start; confirmedEnd = end })

val sheet = compose.onNodeWithTag("loan-sheet").getBoundsInRoot()
val confirm = compose.onNodeWithTag("loan-confirm").assertIsDisplayed().assertIsEnabled().getBoundsInRoot()
Expand All @@ -73,6 +74,8 @@ class LoanRequestSheetUiTest {

compose.onNodeWithTag("loan-confirm").performClick()
compose.runOnIdle { assertEquals(today.plusDays(1).toString(), confirmed) }
// No loan duration in this calendar: nothing caps the window, so no end_date is sent.
compose.runOnIdle { assertNull(confirmedEnd) }
compose.onNodeWithTag("loan-cancel").performClick()
compose.runOnIdle { assertTrue(dismissed) }
}
Expand All @@ -94,7 +97,7 @@ class LoanRequestSheetUiTest {
compose.setContent {
PinakesTheme {
Box(Modifier.width(360.dp)) {
LoanRequestSheetContent(loading = false, calendar = null, fallback = true, submitting = false, onDismiss = {}, onConfirm = {}, today = today)
LoanRequestSheetContent(loading = false, calendar = null, fallback = true, submitting = false, onDismiss = {}, onConfirm = { _, _ -> }, today = today)
}
}
}
Expand Down
12 changes: 11 additions & 1 deletion app/src/main/java/com/pinakes/app/data/model/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ data class Meta(
val replayed: Boolean? = null,
// 409 `merge_error` on an author/publisher merge: ids that no longer exist (already merged).
val missing: List<Int>? = null,
// 409 `not_available` on POST /reservations (Mobile API 1.8.2): when the start day is free
// but a scheduled loan or reservation begins inside the requested window, the longest loan
// still possible, so the app can propose that return date. Absent otherwise and on older servers.
@SerialName("max_end_date") val maxEndDate: String? = null,
@SerialName("max_duration_days") val maxDurationDays: Int? = null,
@SerialName("first_conflict_date") val firstConflictDate: String? = null,
)

@Serializable
Expand Down Expand Up @@ -516,6 +522,8 @@ data class AvailabilityCalendar(
// Days POST /reservations adds to the chosen start to set the return date (Mobile API
// 1.8.1). Null on older servers: the dialog then names the standard period without a date.
@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,
)

@Serializable
Expand All @@ -535,7 +543,9 @@ data class ReservationRequest(
// request flow sends.
@SerialName("desired_date") val desiredDate: String? = null,
@SerialName("start_date") val startDate: String? = null, // legacy/compat, yyyy-MM-dd
@SerialName("end_date") val endDate: String? = null, // legacy/compat, yyyy-MM-dd
// Optional return date (Mobile API 1.8.2): the day before a fully-booked day when the
// library's duration would cross it (see LoanWindow). Omitted, the server adds the duration.
@SerialName("end_date") val endDate: String? = null, // yyyy-MM-dd
)

/** Authoritative result of POST /reservations (loan request or FIFO reservation). */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,17 +63,19 @@ class LibraryRepository(
/**
* Request a loan/reservation for [bookId] starting on [desiredDate] (yyyy-MM-dd). The
* backend sets the return date from the library's configured loan duration
* (loan_duration_days), matching the website behaviour.
* (loan_duration_days), matching the website behaviour, unless [endDate] names an earlier
* one: the day before a fully-booked day the default would cross (Mobile API 1.8.2).
*/
suspend fun reserve(
bookId: Int,
desiredDate: String? = null,
endDate: String? = null,
): ApiResult<CirculationRequestResult> {
val api = network.api()
// The request lands in pending loans or in reservations; the caller refreshes the
// offline copy afterwards (refreshCache) so the confirmation is not held back by
// two more round trips.
return apiCall { api.createReservation(ReservationRequest(bookId = bookId, desiredDate = desiredDate)) }
return apiCall { api.createReservation(ReservationRequest(bookId = bookId, desiredDate = desiredDate, endDate = endDate)) }
}

suspend fun cancelReservation(reservationId: Int): ApiResult<Unit> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ fun BookDetailScreen(
fallback = state.availabilityFallback,
submitting = state.reserveBusy,
onDismiss = vm::dismissLoanSheet,
onConfirm = vm::reserve,
onConfirm = { start, end -> vm.reserve(start, end) },
)
}
}
Expand All @@ -238,7 +238,8 @@ internal fun LoanRequestSheet(
fallback: Boolean,
submitting: Boolean,
onDismiss: () -> Unit,
onConfirm: (isoDate: String) -> Unit,
/** The chosen start and, when a fully-booked day shortens the default duration, the return date. */
onConfirm: (isoDate: String, endIso: String?) -> Unit,
/** Passed to [LoanCalendar]; null reads the device clock. */
today: LocalDate? = null,
) {
Expand Down Expand Up @@ -269,14 +270,20 @@ internal fun LoanRequestSheetContent(
fallback: Boolean,
submitting: Boolean,
onDismiss: () -> Unit,
onConfirm: (isoDate: String) -> Unit,
/** The chosen start and, when a fully-booked day shortens the default duration, the return date. */
onConfirm: (isoDate: String, endIso: String?) -> Unit,
/** Passed to [LoanCalendar]; null reads the device clock. */
today: LocalDate? = null,
) {
// Pre-select earliest_available when offered, else null until the user picks.
var selectedIso by remember(calendar) {
mutableStateOf(calendar?.earliestAvailable?.takeIf { it.isNotBlank() })
}
// A fully-booked day the reader tapped: explained under the calendar until the next pick.
var blockedIso by remember(calendar) { mutableStateOf<String?>(null) }
// The loan still possible from the chosen start: the default duration, or shorter when a
// scheduled loan or reservation begins inside it (then the request carries end_date).
val window = selectedIso?.let { loanWindowFrom(it, calendar, calendar?.loanDurationDays) }

Surface(
shape = RoundedCornerShape(20.dp),
Expand Down Expand Up @@ -322,8 +329,9 @@ internal fun LoanRequestSheetContent(
LoanCalendar(
calendar = calendar,
selected = selectedIso,
onSelect = { selectedIso = it },
onSelect = { selectedIso = it; blockedIso = null },
today = today,
onBlockedTap = { blockedIso = it },
)
}

Expand All @@ -345,27 +353,56 @@ internal fun LoanRequestSheetContent(
}
}

// The return date uses the server's configured duration (#51).
// The return date uses the server's configured duration (#51), stopped the day
// before a fully-booked day when one falls inside it.
val hint = selectedIso?.let { start ->
calendar?.loanDurationDays?.let { loanReturnDate(start, it) }?.let { end ->
stringResource(R.string.book_loan_period_range, formatDisplayDate(start), formatDisplayDate(end))
window?.let { w ->
stringResource(R.string.book_loan_period_range, formatDisplayDate(start), formatDisplayDate(w.endDate))
} ?: stringResource(R.string.book_loan_period_hint, formatDisplayDate(start))
} ?: stringResource(R.string.book_loan_pick_prompt)
Text(
text = hint,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = Spacing.md),
modifier = Modifier.padding(top = Spacing.md, bottom = if (window?.capped == true || blockedIso != null) Spacing.xs else Spacing.md),
)
// Why the loan is shorter than the library's standard duration, and why a tapped
// red day cannot be chosen: the same explanation the website gives.
val limitText = window?.takeIf { it.capped }?.let { w ->
if (!w.possible) {
// The next day is blocked: no loan of at least one day from this start.
stringResource(R.string.book_loan_no_window, formatDisplayDate(w.firstBlocked ?: w.endDate))
} else if (w.maxDays == 1) {
// The string generator has no plurals: a singular variant instead of "1 days".
stringResource(R.string.book_loan_limit_note_one, formatDisplayDate(w.endDate), formatDisplayDate(w.firstBlocked ?: w.endDate))
} else {
stringResource(
R.string.book_loan_limit_note,
w.maxDays,
formatDisplayDate(w.endDate),
formatDisplayDate(w.firstBlocked ?: w.endDate),
)
}
}
val blockedText = blockedIso?.let { stringResource(R.string.book_loan_blocked_day, formatDisplayDate(it)) }
val explanation = listOfNotNull(blockedText, limitText).joinToString(" ")
if (explanation.isNotEmpty()) {
Text(
text = explanation,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.padding(bottom = Spacing.md).testTag("loan-limit"),
)
}

Column(
Modifier.padding(bottom = Spacing.lg),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
PrimaryButton(
label = stringResource(R.string.book_loan_confirm),
onClick = { selectedIso?.let(onConfirm) },
enabled = selectedIso != null,
onClick = { selectedIso?.let { start -> onConfirm(start, window?.takeIf { it.requiresEndDate }?.endDate) } },
enabled = selectedIso != null && window?.possible != false,
loading = submitting,
modifier = Modifier.fillMaxWidth().testTag("loan-confirm"),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,14 +179,16 @@ class BookDetailViewModel @Inject constructor(

/**
* Submit a loan/reservation request for [desiredDate] (yyyy-MM-dd, today-or-future). The
* backend sets the return from the library's configured loan duration. On success we surface
* the chosen date so the UI can confirm exactly what was requested.
* backend sets the return from the library's configured loan duration, unless [endDate]
* asks for an earlier one (the day before a fully-booked day the default would cross, see
* LoanWindow). On success we surface the chosen date so the UI can confirm exactly what
* was requested.
*/
fun reserve(desiredDate: String) {
fun reserve(desiredDate: String, endDate: String? = null) {
if (_state.value.reserveBusy) return
_state.update { it.copy(reserveBusy = true) }
viewModelScope.launch {
when (val res = library.reserve(bookId, desiredDate)) {
when (val res = library.reserve(bookId, desiredDate, endDate)) {
is ApiResult.Success -> {
_state.update {
it.copy(
Expand All @@ -207,6 +209,9 @@ class BookDetailViewModel @Inject constructor(
is ApiResult.Failure -> {
// Close the calendar dialog so the error is visible: the snackbar
// lives at the screen Scaffold level, behind the open DatePickerDialog.
// 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.
when {
res.code == ErrorCodes.CONFLICT ->
_state.update { it.copy(reserveBusy = false, showLoanSheet = false, snackbar = null, snackbarRes = R.string.snackbar_request_conflict) }
Expand Down
Loading
Loading