Skip to content
Closed
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
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
110 changes: 110 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,110 @@
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()

private val today = LocalDate.now()

/** 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,
)
}
}

@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 = {})
}
}
}
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)
}
}
8 changes: 6 additions & 2 deletions app/src/main/java/com/pinakes/app/data/model/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,9 @@ data class AvailabilityCalendar(
@SerialName("earliest_available") val earliestAvailable: String? = null, // "yyyy-MM-dd"
@SerialName("unavailable_dates") val unavailableDates: List<String> = emptyList(),
val days: List<AvailabilityDay> = emptyList(),
// 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,
)

@Serializable
Expand All @@ -488,8 +491,9 @@ data class AvailabilityDay(
@Serializable
data class ReservationRequest(
@SerialName("book_id") val bookId: Int,
// Single desired start date "yyyy-MM-dd"; the backend computes the return as +1 month
// (same as the website). This is the authoritative field the request flow sends.
// Single desired start date "yyyy-MM-dd"; the backend sets the return from the library's
// configured loan duration (same as the website). This is the authoritative field the
// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ class LibraryRepository(

/**
* Request a loan/reservation for [bookId] starting on [desiredDate] (yyyy-MM-dd). The
* backend computes the return date as +1 month, matching the website behaviour.
* backend sets the return date from the library's configured loan duration
* (loan_duration_days), matching the website behaviour.
*/
suspend fun reserve(
bookId: Int,
Expand Down
201 changes: 126 additions & 75 deletions app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -222,13 +222,9 @@ fun BookDetailScreen(
}

/**
* Loan-request dialog. While [loading], shows a spinner. With a [calendar], renders the colored
* Loan-request sheet. While [loading], shows a spinner. With a [calendar], renders the colored
* [LoanCalendar] (availability-tinted month grid). If [fallback] (availability fetch failed),
* the same month grid lets the user pick any future day, with a note. Returns yyyy-MM-dd.
*
* A plain [Dialog] sized to the screen (max 420dp, 16dp side margins, scrollable) replaces the
* Material DatePickerDialog, whose fixed 360dp width and capped height clipped the month on
* 320-360dp phones.
* uses the same month grid for any future day and a note. Returns the chosen date as yyyy-MM-dd.
*/
@Composable
internal fun LoanRequestSheet(
Expand All @@ -238,89 +234,136 @@ internal fun LoanRequestSheet(
submitting: Boolean,
onDismiss: () -> Unit,
onConfirm: (isoDate: String) -> Unit,
) {
// Same sheet as the web's loan request: the card shape of the availability box (20dp), a
// serif title, and two buttons stacked at full width in the page's own styles — the filled
// confirm and the outlined cancel — instead of Material's text-button row.
// A plain Dialog, not DatePickerDialog: that one caps its height at 568dp, which cut the
// month in half and pushed the legend and the buttons below the fold. This one is as tall
// as its content and scrolls only on a screen too short for it.
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(Modifier.testTag("loan-dialog")) {
LoanRequestSheetContent(loading, calendar, fallback, submitting, onDismiss, onConfirm)
}
}
}

/**
* The sheet itself, without its [Dialog]: a Dialog is a window of its own and takes the device
* density, so the tests lay this out in a fixed width to check a narrow phone on any emulator.
*/
@Composable
internal fun LoanRequestSheetContent(
loading: Boolean,
calendar: AvailabilityCalendar?,
fallback: Boolean,
submitting: Boolean,
onDismiss: () -> Unit,
onConfirm: (isoDate: String) -> Unit,
) {
// Pre-select earliest_available when offered, else null until the user picks.
var selectedIso by remember(calendar) {
mutableStateOf(calendar?.earliestAvailable?.takeIf { it.isNotBlank() })
}

Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surface,
modifier = Modifier
.padding(horizontal = Spacing.lg, vertical = Spacing.xl)
.widthIn(max = 420.dp)
.fillMaxWidth()
.testTag("loan-dialog"),
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = Modifier
// 8dp from the screen edges, as the web sheet on a phone (100vw - 1rem).
.padding(horizontal = Spacing.sm, vertical = Spacing.lg)
.widthIn(max = 480.dp)
.fillMaxWidth()
.testTag("loan-sheet"),
) {
Column(
Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = Spacing.lg)
.fillMaxWidth(),
) {
Column(Modifier.verticalScroll(rememberScrollState()).padding(Spacing.lg)) {
Text(
stringResource(R.string.book_loan_dialog_title),
modifier = Modifier.padding(bottom = Spacing.sm),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
when {
loading -> {
Row(
Modifier.fillMaxWidth().padding(vertical = Spacing.xxl),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(Spacing.md))
Text(
stringResource(R.string.book_loan_availability_loading),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

calendar != null && !fallback -> {
LoanCalendar(
calendar = calendar,
selected = selectedIso,
onSelect = { selectedIso = it },
)
}

else -> {
// Availability unknown: any future day, the server decides.
LoanCalendar(
calendar = AvailabilityCalendar(),
selected = selectedIso,
onSelect = { selectedIso = it },
anyFutureDay = true,
)
Text(
stringResource(R.string.book_loan_dialog_title),
modifier = Modifier.padding(top = Spacing.lg, bottom = Spacing.md),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
when {
loading -> {
Row(
Modifier.fillMaxWidth().padding(vertical = Spacing.xxl),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(Spacing.md))
Text(
stringResource(R.string.book_loan_availability_error),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = Spacing.sm),
stringResource(R.string.book_loan_availability_loading),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

// Period hint / prompt.
val hint = selectedIso?.let {
stringResource(R.string.book_loan_period_hint, formatDisplayDate(it))
} ?: stringResource(R.string.book_loan_pick_prompt)
Text(
text = hint,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = Spacing.md),
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.book_loan_cancel)) }
TextButton(
onClick = { selectedIso?.let(onConfirm) },
enabled = selectedIso != null && !submitting,
) { Text(stringResource(R.string.book_loan_confirm)) }
calendar != null && !fallback -> {
LoanCalendar(
calendar = calendar,
selected = selectedIso,
onSelect = { selectedIso = it },
)
}

else -> {
// Unknown availability: preserve main's selectable, localised month grid.
LoanCalendar(
calendar = AvailabilityCalendar(),
selected = selectedIso,
onSelect = { selectedIso = it },
anyFutureDay = true,
)
Text(
stringResource(R.string.book_loan_availability_error),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = Spacing.sm),
)
}
}

// The return date uses the server's configured duration (#51).
val hint = selectedIso?.let { start ->
calendar?.loanDurationDays?.let { loanReturnDate(start, it) }?.let { end ->
stringResource(R.string.book_loan_period_range, formatDisplayDate(start), formatDisplayDate(end))
} ?: 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),
)

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,
loading = submitting,
modifier = Modifier.fillMaxWidth().testTag("loan-confirm"),
)
SecondaryButton(
label = stringResource(R.string.book_loan_cancel),
onClick = onDismiss,
modifier = Modifier.fillMaxWidth().testTag("loan-cancel"),
)
}
}
}
}
Expand Down Expand Up @@ -707,6 +750,14 @@ private fun digitalFilename(url: String, fallback: String): String =
private val displayDateFormatter: DateTimeFormatter
get() = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(com.pinakes.app.ui.common.AppLocale.current())

/**
* Return date (yyyy-MM-dd) of a loan request starting on [startIso]: the server adds the
* library's loan duration to the start. Null for an unparseable start or a duration below 1.
*/
internal fun loanReturnDate(startIso: String, loanDurationDays: Int): String? =
if (loanDurationDays < 1) null
else runCatching { LocalDate.parse(startIso).plusDays(loanDurationDays.toLong()).toString() }.getOrNull()

/** "2026-06-20" (yyyy-MM-dd) → locale-formatted date for display. Falls back to the raw input. */
private fun formatDisplayDate(iso: String): String =
runCatching { LocalDate.parse(iso).format(displayDateFormatter) }.getOrDefault(iso)
Loading
Loading