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
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
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
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
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,10 @@ 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(calendar: AvailabilityCalendar) {
private class CalendarModel(private val source: AvailabilityCalendar) {
private val byDate: Map<String, String> =
calendar.days.associate { it.date to it.state } // date -> state
private val available: Map<String, Int> =
calendar.days.associate { it.date to it.available }
private val unavailable: Set<String> = calendar.unavailableDates.toSet()
source.days.associate { it.date to it.state } // date -> state
private val unavailable: Set<String> = source.unavailableDates.toSet()
private val knownDates: Set<String> = byDate.keys

/** True only when we have fetched data covering this day at all. */
Expand All @@ -101,11 +99,11 @@ private class CalendarModel(calendar: AvailabilityCalendar) {
fun kind(date: LocalDate, today: LocalDate): DayKind {
val iso = date.toString()
if (date.isBefore(today)) return DayKind.PastOrUnknown
if (unavailable.contains(iso)) return DayKind.Full
// 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]) {
"free" -> if ((available[iso] ?: 1) > 0) DayKind.Free else DayKind.Full
"free" -> DayKind.Free
"partial" -> DayKind.Partial
"full" -> DayKind.Full
else -> DayKind.PastOrUnknown // beyond the fetched window → unknown/disabled
}
}
Expand Down Expand Up @@ -136,6 +134,8 @@ 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. */
onBlockedTap: ((isoDate: String) -> Unit)? = null,
) {
val dark = LocalPinakesColors.current.darkMode
val deviceToday = remember { LocalDate.now() }
Expand Down Expand Up @@ -167,6 +167,7 @@ fun LoanCalendar(
dark = dark,
onSelect = onSelect,
anyFutureDay = anyFutureDay,
onBlockedTap = onBlockedTap,
)
if (!anyFutureDay) {
Spacer(Modifier.height(Spacing.md))
Expand Down Expand Up @@ -234,6 +235,7 @@ private fun MonthGrid(
dark: Boolean,
onSelect: (String) -> Unit,
anyFutureDay: Boolean = false,
onBlockedTap: ((String) -> Unit)? = null,
) {
val firstOfMonth = month.atDay(1)
// Mon=1 .. Sun=7 → leading blanks before day 1.
Expand Down Expand Up @@ -262,6 +264,7 @@ private fun MonthGrid(
dark = dark,
onSelect = onSelect,
anyFutureDay = anyFutureDay,
onBlockedTap = onBlockedTap,
)
}
}
Expand All @@ -280,6 +283,7 @@ private fun DayCell(
dark: Boolean,
onSelect: (String) -> Unit,
anyFutureDay: Boolean = false,
onBlockedTap: ((String) -> Unit)? = null,
) {
val iso = date.toString()
val kind = model.kind(date, today)
Expand Down Expand Up @@ -310,7 +314,12 @@ private fun DayCell(
if (rule != Color.Transparent) Modifier.border(1.dp, rule, CircleShape) else Modifier
)
.then(
if (selectable) Modifier.clickable { onSelect(iso) } else Modifier
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) }
else -> Modifier
}
)

Box(cellModifier, contentAlignment = Alignment.Center) {
Expand Down
Loading
Loading