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
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)
}
}
191 changes: 116 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,134 @@ 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),
)
}
}

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

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
Loading
Loading