diff --git a/DESIGN.md b/DESIGN.md index dbcf5fa..6fd6602 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,66 +1,270 @@ -# Pinakes Android — Design system (committed) - -Color strategy: **Restrained**. Tinted-neutral surfaces + **one accent** (magenta). -Magenta carries primary actions, current selection, and key status ONLY. No -indigo/violet anywhere. Neutrals are cool and clean (faint mauve tint, never -brown). No gradients. Light is the default theme. - -These are the ONLY colours allowed in the app. Do not invent new ones. Reference -them via `MaterialTheme.colorScheme.*`; the availability tints below are the only -extra semantic colours. - -## Brand -- Magenta `#D70161` — primary / accent. Deep `#B0235A` for pressed. -- The logo is the magenta wordmark/palm. Show it bare (no circle, ring, or card). - -## Light scheme (default) -- primary `#D70161` · onPrimary `#FFFFFF` -- primaryContainer `#FFD9E5` · onPrimaryContainer `#3E001C` (selected nav indicator, selected chips) -- secondary `#6C5C62` · onSecondary `#FFFFFF` · secondaryContainer `#F3E6EB` · onSecondaryContainer `#25141B` (muted mauve-grey, NOT purple; used rarely) -- tertiary `#B0235A` (magenta family) · onTertiary `#FFFFFF` -- error `#BA1A1A` · onError `#FFFFFF` · errorContainer `#FFDAD6` · onErrorContainer `#410002` -- background `#FCFAFB` · onBackground `#1C1B1C` -- surface `#FFFFFF` · onSurface `#1C1B1C` -- surfaceVariant `#ECE9EB` · onSurfaceVariant `#5A585B` -- surfaceContainerLowest `#FFFFFF` · Low `#F8F5F7` · Container `#F3F0F2` · High `#EDEAEC` · Highest `#E7E4E6` -- surfaceDim `#E0DDDF` · surfaceBright `#FCFAFB` · surfaceTint `#D70161` -- outline `#C7C4C7` (field borders) · outlineVariant `#E6E2E5` -- inverseSurface `#313031` · inverseOnSurface `#F3EFF1` · inversePrimary `#FFB1C8` · scrim `#000000` - -## Dark scheme (opt-in) -- primary `#FFB1C8` · onPrimary `#5E1133` -- primaryContainer `#83254A` · onPrimaryContainer `#FFD9E5` -- secondary `#D7BFC6` · onSecondary `#3B2930` · secondaryContainer `#534149` · onSecondaryContainer `#F3E6EB` -- tertiary `#FFB1C5` · onTertiary `#5E112B` -- error `#FFB4AB` · onError `#690005` · errorContainer `#93000A` · onErrorContainer `#FFDAD6` -- background `#141315` · onBackground `#E6E1E3` -- surface `#141315` · onSurface `#E6E1E3` -- surfaceVariant `#48464A` · onSurfaceVariant `#C9C5CA` -- surfaceContainerLowest `#0F0E10` · Low `#1C1B1D` · Container `#201F21` · High `#2B292C` · Highest `#363437` -- surfaceDim `#141315` · surfaceBright `#3A383B` · surfaceTint `#FFB1C8` -- outline `#928F94` · outlineVariant `#48464A` -- inverseSurface `#E6E1E3` · inverseOnSurface `#313031` · inversePrimary `#D70161` · scrim `#000000` - -## Availability tints (the only extra semantic colours) -- Available (green): light container `#C7EBD1` / on `#0B5733`; dark container `#14442B` / on `#A9E6C0` -- Limited (amber): light container `#FBE2B3` / on `#6B4E00`; dark container `#4A3500` / on `#F0C36B` -- Unavailable (red): use `error` / `errorContainer`. +# Pinakes Android — Design system (2026) + +This replaces the previous design system. It ports to the app the 2026 restyle of the Pinakes web frontend (branch `design/restyling-2026` of `fabiodalez-dev/Pinakes`). The web is the reference: when this file and the web disagree, look at the web. Source files on the web side: + +- `public/assets/pinakes-2026.css`: every token and component below, as CSS +- `public/assets/pinakes-2026.js`: cover-tone sampling, wishlist hearts, grid/list toggle +- `app/Views/frontend/partials/pk-book-card.php`: the book card, element by element +- `app/Views/frontend/book-detail.php`: the book page +- `app/Views/frontend/home-sections/hero.php`: the home hero +- The original mockup: `Restyling home e catalogo Pinakes.zip` (`Pinakes Restyling.dc.html`), in Fabio's Downloads + +Two rules sit above everything else: + +1. **Change the form, never remove an element.** Every badge, icon, label, button and text the app shows today stays. A restyle that drops the media-type icon, the "wanted by the library" badge, a share target or a plugin button is wrong, even when the mockup does not show it. Restyle it in this language instead. +2. **The library's theme drives the colour.** Pinakes installs choose a theme in the admin (`/admin/themes`: Classic, Ocean Blue, Forest Green, Sunset Orange, Navy, Minimal, Coral, …). Nothing below hardcodes magenta: magenta is only the default theme. See *Theming*. + +## What changed from the previous design system + +| Before | Now | +|---|---| +| Inter everywhere | **Geist** (UI text) + **Fraunces** (titles, headings, book titles) | +| Fixed magenta brand | Accent = the server theme's `primary`; magenta only as default | +| "No gradients" | One gradient only: the soft accent wash at the top of the hero (see Book detail). Still no gradient on buttons, text or cards | +| "Cards sparingly" | Still true for lists of text. Books are shown as **3D book covers**, not as cards with borders | +| Availability as a coloured chip | A white pill with a coloured **dot** over the cover; a dot + label in the detail | + +Everything else that was a hard ban stays banned: indigo/violet unless the theme says so, glassmorphism, gradient text, side-stripe borders, the logo inside a circle, light text on a light field, em dashes in copy. + +## Theming + +The web derives every accent shade from three theme values with `color-mix` in sRGB. Do the same in Kotlin (linear interpolation per channel in sRGB, 0–255): + +``` +mix(a, b, t) = a * t + b * (1 - t) // t = share of a +``` + +| Token | Formula | Default (Classic theme) | +|---|---|---| +| `accent` | theme `primary` | `#D70161` | +| `accentStrong` | mix(accent, black, 0.78) | `#A8014C` | +| `accentSoft` | mix(accent, white, 0.09) | `#FBE8F1` | +| `accentSofter` | mix(accent, white, 0.05) | `#FDF2F7` | +| `accentLine` | mix(accent, white, 0.16) | `#F9D6E6` | +| `heroWash` | mix(accent, `#F7F1F3`, 0.06) | `#F5E3EA` | +| `dark` | theme `secondary` | `#111827` (web fallback `#1B1720`) | +| `button` | theme `button` (fallback accent) | `#D70262` | +| `buttonText` | theme `button_text` (fallback white) | `#FFFFFF` | +| `buttonHover`/pressed | mix(button, black, 0.85) | | + +The other themes, for testing (primary · button · secondary · button_text): + +- Ocean Blue `#0284C7` · `#0EA5E9` · `#0C4A6E` · `#FFFFFF` +- Forest Green `#059669` · `#10B981` · `#064E3B` · `#FFFFFF` +- Sunset Orange `#EA580C` · `#F97316` · `#7C2D12` · `#FFFFFF` +- Navy Classic `#1E40AF` · `#3B82F6` · `#1E3A8A` · `#FFFFFF` +- Minimal `#404040` · `#808080` · `#000000` · `#FFFFFF` +- Coral Warm `#F43F5E` · `#FB7185` · `#9F1239` · `#FFFFFF` + +**Pairing rule (the most common bug on the web):** a filled surface always carries its paired text colour. `button` ↔ `buttonText`. `dark` ↔ white. In dark mode the dark action fill is lifted just enough to contrast 3:1 with its card, while retaining 4.5:1 with white text. `accent` is for text, icons, dots, outlines and tinted backgrounds (`accentSoft`), and is never the fill under accent-coloured text. Never put `accent` text on a `button` fill. Check every themed screen with at least Classic, Ocean Blue and Minimal. + +**Contrast:** the current web uses `ThemeColorizer::readableSurface()` and readable accent tokens. Preserve each theme's hue and the `button` / `buttonText` pairing, moving the surface or accent text only as far as needed for 4.5:1. The Android palette applies the same rule in sRGB; raw `primary` remains the source for washes, rules and cover tints. This supersedes the earlier instruction to retain inaccessible light-button/white-text pairs. + +**Where the app gets the theme:** the Mobile API plugin does not expose it today (`storage/plugins/mobile-api`, no theme endpoint). Until it does, ship Classic as the built-in palette, and build the colour scheme from a `ThemePalette(primary, secondary, button, buttonText)` data class so a server palette can be plugged in later. Adding the endpoint is a server change in the Pinakes repo: ask Fabio before doing it. + +### Neutrals (fixed, not themed) + +| Token | Value | Use | +|---|---|---| +| `ink` | `#1B1720` | titles, primary text | +| `ink2` / `ink3` | `#2D2630` / `#3D3640` | citation text / long-form body | +| `text` | `#5D5662` | secondary body | +| `muted` | `#6B6470` | labels, metadata, eyebrow captions | +| `faint` | `#6B6470` | quick-fact keys, disabled, separators | +| `bg` | `#FBFAF9` | screen background (warm off-white) | +| `surface` | `#FFFFFF` | boxes, sheets, chips | +| `soft` / `soft2` | `#F1EDEF` / `#F4F0F2` | search field fill, segmented control track | +| `line` | `#ECE8EA` | box borders, dividers | +| `line2` | `#E2DCE0` | chip and outline-button borders | +| `line3` | `#F3EFF1` | rows inside a box | +| `coverBlank` | `#2A2230` | a book without a cover | +| available dot | `#16A34A`, halo `#DCFCE7` | | +| unavailable dot | `#9CA3AF`, halo `#F1F1F3` | | + +Map to Material 3: `background = bg`, `surface = surface`, `onSurface = ink`, `onSurfaceVariant = muted`, `outline = line2`, `outlineVariant = line`, `primary = accent`, `onPrimary = white`, `primaryContainer = accentSoft`, `onPrimaryContainer = accentStrong`, `secondary = dark`, `onSecondary = white`. Keep the old dark scheme as an opt-in, deriving `primary` from the theme accent lightened (mix(accent, white, 0.55)). ## Typography -- One family: **Inter** (already bundled). Headings/body/labels/data all Inter. -- Fixed scale, ratio ~1.2. Weight contrast for hierarchy (Regular 400 / Medium 500 / SemiBold 600). No display fonts in labels or data. - -## Layout & components -- **Cards sparingly.** Lists are rows with dividers or generous spacing, not a wall of elevated cards. Never nest cards. -- Vary spacing for rhythm (e.g. 8 / 12 / 16 / 24 / 32). Not the same padding everywhere. -- **Bottom nav**: selected item = magenta (indicator = primaryContainer, selected icon+label = primary/onPrimaryContainer). Unselected = onSurfaceVariant. NEVER purple. -- **Text fields**: outlined, `outline` border, `onSurface` text, `onSurfaceVariant` label/placeholder, magenta (`primary`) only on focus. Must be clearly readable on white. -- **Book metadata block** (ISBN, year, pages, language, format): a clean key/value list, label in `onSurfaceVariant` small-caps/medium, value in `onSurface`. Not cramped, not a grey blob. -- **Genre**: show as a magenta-tinted chip (primaryContainer) in the detail; keep the genre filter usable. -- Buttons: filled magenta primary (onPrimary text); secondary = outlined/text in `primary` or neutral. One button shape throughout. - -## Motion -150–250ms, ease-out. State changes only. No page-load choreography, no gradient sweeps. - -## Hard bans (in addition to the above) -Gradients · indigo/violet · brown surfaces · gradient text · glassmorphism · side-stripe borders · logo inside a circle/ring · light text on a light field · em dashes in copy. + +- **Geist** (sans, OFL) for all interface text. **Fraunces** (serif, OFL, with italic) for: screen titles, section headings, book and article titles, big numbers in stats, the citation text, a book's subtitle (italic). +- Bundle both as TTF in `res/font/` (Google Fonts has both). The current web self-hosts Geist and Fraunces, latin + latin-ext: keep latin-ext, the catalogue has Danish and German titles. + +| Role | Font | Size / line | Weight | Tracking | +|---|---|---|---|---| +| Hero title | Fraunces | 40–54 / 1.04 | 500 | −2% | +| Screen title (Catalogo, Eventi) | Fraunces | 34–44 / 1.0 | 500 | −2% | +| Section heading (Descrizione, Potrebbero interessarti) | Fraunces | 28–32 | 500 | −1.5% | +| Card book title | Fraunces | 17 / 1.2 | 500 | 0, max 2 lines | +| Body | Geist | 16–17 / 1.7 | 400 | 0 | +| UI label, button | Geist | 15 | 600 (primary) / 500 (secondary) | 0 | +| Metadata, author line | Geist | 13 | 400 | 0 | +| Eyebrow / small caps label (CONTENUTI DIGITALI, ANNO) | Geist | 12 | 600 | +8%, uppercase, `muted` | +| Badge on a cover | Geist | 11 | 600 | 0 | + +In a hero title the **last word is italic, in accent colour** ("La tua biblioteca *digitale*"). + +## Shape, spacing, elevation, motion + +- Radii: boxes 20, digital-file cards 16, cover panels 16, buttons 12, small buttons 10, inputs 10, chips and pills fully round, the 3D book 2/5/5/2 (spine side tighter). +- Screen side padding 16 on phones (web gutter 28, max width 1240 on tablets). +- Vertical rhythm between sections 48–80. Inside a box 18–22. +- One elevation: the availability box, `0 16 40 −28` in `rgba(80,20,50,.35)`. Book covers have their own book shadow (below). Nothing else floats. +- Motion: ease-out `cubic-bezier(.2,.8,.2,1)`, 300ms. A card lifts 4dp on press. Covers tilt slightly (rotateY −14°) on hover on the web; on Android use the 4dp lift only. + +## Components + +### Book cover (3D book) +The signature element. A 2:3 cover drawn as a book: + +- the **complete** cover image, fitted inside the 2:3 frame without cropping, on an opaque `coverBlank` background (or `coverBlank` with the title in Fraunces 17 white, a 22×2 accent rule and the word "PINAKES" at the bottom when there is no cover); +- a 14dp spine on the left: a horizontal gradient of dark/light streaks (`rgba(0,0,0,.28)` → `rgba(255,255,255,.28)` → transparent); +- a page block on the right edge: 12dp wide, inset 3dp top and bottom, offset −5dp, striped `#F6F2EE`/`#E4DDD6` 1dp; +- a soft gloss (115°, white 22% fading to black 12%) and a 1dp inner hairline; +- shadow `0 1 1 rgba(0,0,0,.12)`, `0 6 10 −4 rgba(50,15,35,.25)`, `0 24 32 −16 rgba(50,15,35,.5)`. + +### Book card (home, catalogue, related, wishlist) +The theme has a **card style** option, stored next to the colours as `card_style`: + +- `classic` (the default after every install and upgrade): the book alone, no panel behind it. The status pill and the heart sit on the cover itself, so a blank book's title starts 40dp down, below the pill. +- `tinted`: the book on a 16dp-radius panel tinted from the cover's average colour (16% colour + 84% white), with 18/22/22 padding. + +Both come with the theme, like `hero_style`. Until the Mobile API exposes them, use `classic`. + +Top to bottom: + +1. The 3D book. Over it, top-left at 10dp: the **status pill**, white 94% with a 6dp dot and the label: Disponibile (green), Non disponibile (grey), In prestito, Prenotato, Cercato dalla biblioteca (the wanted badge, accent). Digital-content icons (eBook, audiobook) sit inside the same pill after the label. Top-right: the **wishlist heart**, a 32dp white circle, outline heart in accent, filled when the book is in the wishlist. Signed out, it leads to login. Not shown for wanted books or in catalogue-only mode. If the item is not a book (CD, DVD, …), show the **media-type icon** too. +2. Title, Fraunces 17, 2 lines. +3. Subtitle, Geist 13 italic, `faint` (when present). +4. Author, Geist 13 `muted`, one line. No author → "Autore sconosciuto". +5. Publisher line "Editore: …", 12 `faint` (catalogue only; keep the row height when empty so rows align). +6. "Dettagli" with an eye icon, accent, 13 semibold. + +Grid: 2 columns on phones, 28 vertical / 14 horizontal gap. An optional list view shows the cover small on the left and the same texts on the right. + +### Buttons +- **Primary:** fill `button`, text `buttonText`, 13×22 padding, radius 12, Geist 15/600. Pressed → `buttonHover`. Leading icon allowed (keep the ones the app has). +- **Secondary (outline):** fill `surface`, 1dp `line2`, text `ink` 15/500. Pressed → border `accent`. +- **Dark:** fill `dark`, white text (Leggi PDF, Copia negli appunti, Carica altri). +- **Small:** 9×14, radius 10, 13sp. +- **Chip link** (Cerca su, Condividi): transparent, 1dp `line2`, 13sp `ink`, fully round, 5×11. Pressed → accent border and text. + +### Inputs +Fill `soft` on white cards and dialogs (including search), radius 10, **no border** (Fabio's call). The neutral fill keeps an unfocused field distinguishable from its host. Edit-profile and password dialogs explicitly use `surface`, so their container never equals the `soft` field fill in either mode. Use `ink` text, `faint` placeholder. On Android, outlined fields use Material’s accent focus outline so floating labels and validation copy stay outside it; the label-free search control adds a 3dp `accentLine` ring. + +### Chips +- Author chip: white pill, 1dp `line`, a 24dp circle with the initials (`accentSoft` fill, `accentStrong` text, 11/700), the name 14/500. Roles other than author follow the name: "· Traduttore". +- Keyword chip: `accentSoft` fill, `accentStrong` text, 14/500, keep the tag icon small. +- Genre path: plain text with "›" separators, tag icon in `faint`. + +## Screens + +### Home +1. **Hero:** an `accentSofter` → `bg` wash. Content: + - a count badge ("2.268 titoli in catalogo"); + - the title with the italic accent last word, and the subtitle; + - the search field (white, round, with a search icon and a primary "Cerca" button inside); + - two quick links with icons: latest arrivals ↓, catalogue →. + - **No background photo any more.** Beside or under the text, a **fan of up to 4 covers** (3D books, slightly rotated and overlapping). The admin picks either the latest covers or 4 chosen books. + - The theme has a **hero style** option: `covers` (default, the layout above) or `centered` (everything centred in an 860dp column, title up to 84sp, no fan). Implement both; default to `covers`. +2. **Stats strip:** 4 cells, Fraunces 40 numbers, Geist 13 `muted` labels (Libri totali, Disponibili in accent, Categorie, Sempre online). 2×2 on phones. +3. **Latest arrivals:** eyebrow "Novità" in accent, heading, "Visualizza tutto il catalogo" outline pill, 2-column book grid, then a dark "Carica altri" pill. +4. **Genre carousels:** a heading per genre with a "Vedi tutti" outline pill, then a horizontal row of book cards (2.3 visible on phones). +5. **Features:** numbered cards 01–04 with the CMS icon, white, 1dp `line`, radius 20. +6. **Story band** (the "Πίνακες" text section): `dark` background, white text, the big word in Fraunces. +7. **Events:** eyebrow, heading, "Vedi tutti gli eventi" pill, event cards in **landscape** (16:10 image or a light `line` placeholder with a calendar icon), radius 20, title Fraunces 20, date 13 `muted`. +8. **Call to action:** a `button`-filled band, radius 24, title and text in `buttonText`, an inverted button (white fill, `button` text) and an outline one (white border and text). + +### Catalogue / search +- Title block: breadcrumb, Fraunces title, subtitle. +- Filters: a sheet (phones) with sections separated by 28. Each section has a small-caps label with its icon. The sections: + - availability (Tutti, Disponibili, Prenotati, In prestito as rows with an icon tile and a count); + - search; + - authors (with a filter field and "N autori"); + - publishers (chips with counts); + - genres (tree); + - media type; + - publication year (range); + - "Pulisci filtri". +- Results bar: "N Risultati", sort dropdown, Grid/List segmented control (`soft2` track, white selected segment). +- Pagination: 40dp squares radius 12, current one `ink` filled. + +### Book detail +The reference layout (top to bottom on a phone): + +1. **Hero** on the wash `heroWash` → `bg`. It is the theme's accent, not the cover colour. +2. Breadcrumb (Home / Catalogo / title, 13sp, `muted`, last item `ink`). +3. The big 3D cover (max 340dp wide, radius 4/8/8/4, shadow `0 2 4 rgba(0,0,0,.08)`, `0 40 60 −28 rgba(50,15,35,.55)`). +4. Kicker row: media-type pill (white, 1dp `line`, with its icon), publisher in accent, "· year" in `faint`. +5. Title, Fraunces 34–54, balanced wrap. Subtitle in Fraunces italic, `muted`. +6. Author chips. +7. Genre path. +8. **Availability box:** white, radius 20, 1dp `line`, the one shadow, padding 22. + - Left: a 9dp status dot with a 4dp halo plus the status 16/600, and "1 copia disponibile su 1" 14 `muted` right under it (4dp gap). + - Right: primary "Richiedi prestito" and outline "Aggiungi ai preferiti" with an outline heart (filled when added). + - On phones the buttons go full width under the status. +9. **Quick facts:** a row of 4 (2×2 on phones) between two `line` dividers: key 12 uppercase `faint` (ANNO, PAGINE, FORMATO, ISBN), value 15/600. Other media keep their own keys (tracks, duration). +10. **Contenuti digitali** (only when the book has a digital file): eyebrow, then **one card per file**. + - Card: white, radius 16, 1dp `line`, padding 14×16. + - A type tile: 40×48, radius 6, `accentSoft` fill, `accentStrong` "PDF"/"EPUB" 11/700; round 40dp for audio, "MP3". + - The file name (14/600, one line, ellipsis) over the kind ("Edizione digitale", "Audiobook", "Recensione o documento correlato"). + - Actions on the right: dark small "Leggi PDF" (opens the PDF under the card), outline small "Scarica". + - An audiobook card holds the player under the header: 34dp tall, `soft` track. + - Only one audio plays at a time. +11. **Cerca su** (external sources, GoodLib plugin): one line of small chip links after the digital cards (Anna's Archive, Z-Library, Project Gutenberg, each with its icon and an external-link glyph). +12. Body sections, Fraunces 32 headings (the section icons stay, small, `faint`): + - **Descrizione:** 17/1.7, `ink3`, max ~720dp, "Leggi tutto" in accent. + - **Dettagli libro:** key/value rows, key `muted` left, value 500 right, `line` divider. Covers ISBN-13, ISBN-10, EAN, genre, language, price, year, publication date, pages, format, dimensions, weight, series, … + - **Parole chiave:** keyword chips. + - Plugin sections (book club, desiderata) come after, in the same style. +13. **Informazioni libro** box (publisher, place, status, copies, added on) and **Condividi** box, both white radius 20. + - Info rows are 14sp with `line3` dividers. + - Share targets are chip links with icon + short name (Facebook, X, WhatsApp, Telegram, Email, Copy link) and use the system share sheet where available. +14. **Cita questo libro:** + - style tabs (APA, Chicago, MLA, Harvard, Oxford; selected = `ink` fill, white text; others `soft`); + - the citation in Fraunces 18 on a white radius-16 box; + - 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. + +### Other screens +Wishlist, loans and reservations, profile, events, periodicals (Emeroteca), archive, static pages: + +- same `bg`, same title block (breadcrumb + Fraunces title + subtitle); +- white radius-20 boxes for grouped content; +- the book card wherever a book appears; +- landscape cards for events. + +The account dashboard keeps its list rows (icon tile, title, author, status pill, eye button in a 40dp outline square), not the book card. + +Sign-in and registration: + +- the gradient wash background; +- a centred white card, radius 20, soft shadow; +- inputs radius 10, primary button radius 12; +- links in accent. + +## Checks before calling a screen done + +- Every element the old screen had is still there. +- Classic, Ocean Blue and Minimal themes: no accent text on a `button` fill; every filled surface uses its paired text colour. +- 360dp wide: nothing scrolls sideways; chips and buttons wrap. +- A book with no cover, no author, no subtitle and no publisher still lays out (blank book, "Autore sconosciuto", reserved rows). +- A book with an eBook **and** an audiobook shows two cards. A book with neither shows no "Contenuti digitali". + +## Android implementation scope + +The client applies these shared tokens to every existing screen. Home uses the real available-now/recent shelf from the Mobile API, with a searchable hero and cover fan. Catalog has a two-column grid and a compact list, preserving sort, every supported filter and cursor pagination. Account rows keep their status and action slots. Book detail keeps zoom, the date-based circulation flow, personal history, wishlist, reviews, metadata, PDF reading and audio playback. Each exposed digital asset gets a separate card. + +`ThemePalette` supports both hero styles and both card styles. Defaults remain Classic / Covers until discovery supplies server theme settings. Tinted cards sample the loaded cover on a 12×12 bitmap; they do not fetch a second image. Dark mode remains opt-in and follows the app preference, including calendar colours and status dots. + +The coordinated server contract supplies library Desiderata, Archives, complete digital attachments and citations. Home CMS sections/statistics/selected covers, author/publisher count facets, related-title recommendations and external-source/share targets remain outside the mobile API; the client does not fabricate them. The original web layouts remain the reference for future additions. + +Fonts are bundled as static faces for reliable rendering from API 26, with Latin Extended coverage. Font sources and OFL licences are recorded in `docs/fonts/`. + +## Implemented optional collections (2026-10-08) + +Archives and library Desiderata are reachable from Home/Profile, preserving +Home/Catalog/Library/Wishlist/Profile. They use dedicated paginated models, not +book availability. Articles appear in Catalog and issue detail, carry analytic +and anthology fields and the shared five-style Cite dialog; author IDs connect +books and articles without conflating homonyms. Staff actions open protected +PHP pages. New routes/capabilities require the matching server plugin update; +older servers cannot supply native collections merely because the client updates. diff --git a/PRODUCT.md b/PRODUCT.md index de246c7..ff40308 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -14,14 +14,14 @@ Library members on their own phone, mostly in daylight or normal indoor light, in a calm "browsing / reading" mood. Not power users; not a dark-room tool. ## Brand & tone -Pinakes. One signature colour: **magenta `#D70161`** (the palm logo). Tone: +Pinakes. Default accent: **magenta `#D70161`** (the palm logo); other library themes supply their own accent. Tone: quiet, precise, trustworthy, a little warm. Think a good reading app, not a flashy consumer social app. ## Strategic principles - **Minimal, not decorated.** Content (book covers, titles, availability) is the hero. Chrome recedes. -- **One accent.** Magenta is the only colour with meaning: primary actions, +- **One accent per library theme.** It marks primary actions, current selection, key status. Everything else is a clean neutral. No second brand hue, no indigo/violet, no rainbow chips. - **Readable first.** Real contrast on every label and field. Never colour-on- @@ -30,8 +30,8 @@ flashy consumer social app. surface. Dark is available, not assumed. ## Anti-references (what this must NOT look like) -- Gradients of any kind (hero banners, headers, buttons). Banned. +- Decorative gradients on buttons, text or cards. The subtle 2026 hero wash and the book spine/gloss are the deliberate exceptions. - Purple/indigo selection states. The "random colours" look. Banned. -- Warm brown/dun surfaces. Neutrals stay cool and clean. +- Heavy brown surfaces. The 2026 warm off-white neutrals stay light and quiet. - Decorated circular logo badges, ringed avatars, heavy cards everywhere. - "AI slop": a washed-out gradient panel with light text on a light field. diff --git a/README.md b/README.md index 93e4975..55f3ca1 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ Native Android client for a [Pinakes](https://github.com/fabiodalez-dev/Pinakes) library instance. Browse the catalog, check real availability, borrow and reserve books, read ebooks and listen to audiobooks, and manage your loans, all from your -phone. +phone. Optional Archives, Desiderata and analytic articles are available when the +server advertises their APIs. The five bottom navigation destinations are retained. ![Platform](https://img.shields.io/badge/platform-Android-3DDC84) ![minSdk](https://img.shields.io/badge/minSdk-26-blue) @@ -30,8 +31,14 @@ The app is server-agnostic: it works against any Pinakes instance that has the Mobile API enabled, and it adapts to that instance's settings (language, catalogue-only mode, push availability). +## 2026 interface + +The Android interface follows the public Pinakes 2026 restyle: shared theme tokens, Geist for controls, Fraunces for titles, fitted book artwork and a quiet warm background. `ThemePalette` accepts library colours and hero/card styles; Classic / Covers are the built-in defaults until the Mobile API exposes theme settings. Existing circulation, account and plugin flows remain available. See [DESIGN.md](DESIGN.md) for the component contract and API limitations. + ## Screenshots +Login, home, catalog, book detail and calendar captures use the 2026 interface on Android 15 at 360 dp, with fixture data. The remaining account captures document the earlier flow layout. + @@ -53,10 +60,10 @@ catalogue-only mode, push availability). |------|--------------| | **Onboarding** | Enter the instance URL, `/health` discovery card (library name, logo, HTTPS check, mobile-access check) | | **Sign in & sign up** | Email/password login, **in-app registration** and **password recovery**, mapped error messages, secure token storage | -| **Home** | An "Available now" landing showing what you can borrow today | -| **Catalog** | The full catalog with infinite scroll, search, and a filter sheet (availability, genre, author, publisher, language) | -| **Book detail** | HTML-rendered description, tap-to-zoom cover, full metadata block (ISBN, year, pages …), genre chip | -| **Availability** | Colour-coded state: green available, red on loan, amber reserved | +| **Home** | Searchable library hero, a fan of real shelf covers, and an "Available now" / recent shelf | +| **Catalog** | Two-column book grid or compact list, infinite scroll, search, sort and a filter sheet (availability, genre, author, publisher, language) | +| **Book detail** | HTML-rendered description, tap-to-zoom cover, full metadata block (ISBN, year, pages …), genre hierarchy | +| **Availability** | Neutral status pills with a coloured dot: green available, red on loan, amber reserved | | **Loan calendar** | Pick a start date on a calendar that paints already-booked days and pre-selects the first free day | | **Audiobooks** | In-app player (Media3 ExoPlayer) when the title has an audio file | | **Ebooks** | In-app PDF reader (PdfRenderer); other formats open externally | @@ -65,13 +72,13 @@ catalogue-only mode, push availability). | **Book Club** | When the instance runs the **Book Club** plugin: browse your clubs and the directory, open a club's reading list / polls / meetings, join, vote in-app (simple / multi / weighted ballots), RSVP to meetings and track your reading progress — advanced poll modes and proposing a title deep-link to the web | | **Profile** | Edit profile, change password, device list, theme switcher, language switcher, logout | | **Notifications** | Loan due/overdue, reservation ready, book available | -| **Themes** | Material 3 light and dark, light by default; pick light/dark/system in Profile | +| **Themes** | 2026 warm neutrals, Geist + Fraunces, complete 3D covers and paired theme colours. Light by default; pick light/dark/system in Profile | | **Languages** | Italian, English, French, German, following the device locale or an in-app choice | ## Tech stack - **Kotlin 2.0** + **Jetpack Compose** (Material 3), single-module app -- **Navigation-Compose** + `ViewModel`/`StateFlow`, manual DI via a `ServiceLocator` +- **Navigation-Compose** + `ViewModel`/`StateFlow` + Hilt - **Retrofit + OkHttp + kotlinx.serialization** for the `/api/v1` client (`{data, meta, error}` envelope) - **Coil** for cover images, **Media3 ExoPlayer** for audio, platform `PdfRenderer` for PDFs - **AndroidX Security** (`EncryptedSharedPreferences`) for the bearer token and instance URL @@ -89,6 +96,9 @@ catalogue-only mode, push availability). ```bash ./gradlew assembleDebug # debug APK → app/build/outputs/apk/debug/app-debug.apk ./gradlew lintDebug # static analysis +./gradlew testDebugUnitTest +./gradlew connectedDebugAndroidTest # UI regression tests, with a running emulator +./gradlew assembleRelease # R8 + resource shrinking, unsigned without release credentials ``` Create a `local.properties` with `sdk.dir=/path/to/android-sdk` (or set `ANDROID_HOME`). @@ -102,6 +112,29 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk A prebuilt debug APK is published on the [Releases](../../releases) page. +### Standalone emulator on macOS + +```bash +./tools/run-emulator.sh my_avd -no-snapshot-load -gpu auto +``` + +The launcher forwards the remaining options to the Android SDK emulator. On macOS, +it holds a `caffeinate` assertion for that emulator PID, then releases it when the +emulator exits; Ctrl+C stops both. This reduces host power throttling during local +testing. It does not change global power +settings or Android crash reporting. + +An Android 15 startup ANR was reproduced before application initialization, along +with system/launcher stalls. With the same debug APK, host priority dropped to 4 +without the assertion and startup exceeded 21 seconds; with the assertion, three +cold starts completed in 2.3–3.1 seconds. After restarting the VM through this +launcher, five more cold starts completed in 1.66–1.80 seconds with no ANR events. +An older AVD still stalled after a cold boot, and a restored snapshot retained a +system-not-responding dialog. The assertion alone does not repair an unhealthy +AVD; use a fresh development AVD without deleting needed user data. This is a +development-emulator mitigation; +device ANRs still require their own [trace diagnosis](https://developer.android.com/topic/performance/anrs/diagnose-and-fix-anrs). + ## Point it at a Pinakes instance On first launch the app asks for the instance URL. @@ -188,6 +221,27 @@ Released under the same license as Pinakes: **AGPL-3.0**. On compatible servers, **Emeroteca → Articles** searches and displays standalone newspaper and magazine articles, without requiring ownership of their issues. -Publication screens also link to their associated articles. Public PDFs use the -URL supplied by the server. Older servers retain the existing periodicals browser. +Publication screens also link to their associated articles. Article covers, subtitles +and published online resources follow the server data. Public PDFs use the +URL supplied by the server; the website action opens the article’s full page. Older servers retain the existing periodicals browser. See [the article integration notes](docs/emeroteca-standalone-articles.md). + +## Android 1.6 / current server parity + +The updated client reads Mobile API 1.5.0, Desiderata 1.2.0, Emeroteca 1.13.0 and +Archives 1.5.1. Update the server/plugins before expecting the new optional +collection screens. Older instances remain usable; unsupported article facets +show an upgrade message rather than silently returning unfiltered results. + +Desiderata is the library's wanted collection, separate from a member's wishlist. +Donation contact details come from the verified account. Proposals require +consent and survive transport retries/process death without creating inventory. +Archives supports paged hierarchy, text/level/year search, authorities, all +public documents and export links. Article/book citations are formatted by the +server, with five styles and text/HTML clipboard copies. Every digital book +attachment is shown; audio switches one native player at a time. + +Administrative cataloguing and uploads open the protected PHP website, including +the existing article form. No native administrative CRUD API is invented. + +See [the request-by-request Uwe verification](docs/UWE-PARITY.md). diff --git a/STATUS.md b/STATUS.md index ca5dff3..f16182d 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,14 +1,19 @@ # Pinakes Android — Build Status -**Build: GREEN.** `./gradlew assembleDebug` succeeds; `./gradlew lintDebug` passes (0 errors). Kotlin compiles clean. +## 2026 restyle (2026-10-08) -- **APK:** `pinakes-debug.apk` (repo root, ~20 MB) — copied from `app/build/outputs/apk/debug/app-debug.apk`. - Install: `adb install -r pinakes-debug.apk`. -- **Package:** `com.pinakes.app` · versionName `1.0` · minSdk 26 · target/compileSdk 35 · launchable `MainActivity`. -- **Verified on an emulator against a live Pinakes instance** (Android 15 / API 35 AVD → `http://10.0.2.2:8081`): - onboarding → `/health` discovery → login (`200`) → catalog search with real books → book cards with - correct titles/authors/availability. Two real bugs were found and fixed during this smoke test — see - **Fixes applied** below. The app is **fully localized in 4 languages** (German verified live) — see **i18n**. +The app now uses the 2026 web design: Geist / Fraunces, warm neutrals, a server-ready `ThemePalette`, complete 3D book covers, a searchable home hero with a cover fan, catalog grid/list views, dot status pills, grouped availability/actions and one card per exposed digital asset. Shared tokens apply to all existing account and plugin screens. The five-section bottom navigation is retained. Article lists and details now show server-resolved covers, subtitles, published online resources and access conditions, with a website action for the existing staff management flows. Native collections require the additive Mobile API 1.5.0 server update; existing circulation and authentication routes are retained. + +- Debug APK: `pinakes-debug.apk`, copied from `app/build/outputs/apk/debug/app-debug.apk` (generated locally and ignored by Git). +- Package: `com.pinakes.app`, version `1.6.0` (17), minSdk 26, target/compileSdk 35. +- Local verification: **185 unit tests, 19 Compose device tests, zero lint errors**, and successful debug + R8 release builds. +- Verification commands: `assembleDebug`, `testDebugUnitTest`, `lintDebug`, `assembleRelease`, `assembleDebugAndroidTest`; the 19 device tests run through `am instrument` on a separate Android 15 AVD, preserving the authenticated demo device. +- Unit tests cover the existing contracts and theme contrast/mixing. Compose device tests cover full tall artwork, missing metadata, grid actions, view selection, digital-file cards, narrow search placeholders, circulation actions theme pairings, reachable empty-home actions and query preservation during loading. +- Release builds exercise R8 and resource shrinking. Without release credentials the output is unsigned; no store release is published by this change. +- macOS standalone emulator startup: `tools/run-emulator.sh` keeps a power assertion scoped to the emulator PID. A control with the identical debug APK reproduced a 21-second startup timeout at host priority 4; three starts with the assertion completed in 2.3–3.1 seconds. After a VM restart through the launcher, five more cold starts completed in 1.66–1.80 seconds with no ANR events. The original startup ANR also coincided with system/launcher stalls. An older AVD still stalled on a later cold boot, and a snapshot retained a system ANR dialog: the assertion does not repair an unhealthy AVD. No Sentry events are suppressed, and no production-device ANR fix is claimed. +- New native sections: Desiderata with verified-account donation proposals and durable retry recovery; Archives with hierarchy, filters, authorities, documents and exports; analytic articles with all bibliographic fields, five citation styles, rich clipboard, shared-author navigation and issue contributions. Home and catalog support author sorting; every digital attachment and publisher is shown. Staff management uses protected website pages. See [Uwe parity](docs/UWE-PARITY.md). +- Headless Android 15 completed the device tests while older GUI AVDs stalled despite the scoped power assertion. GUI and headless emulator processes had different host scheduling priorities; this is development-environment evidence, not a production ANR fix. +- `ThemePalette` defaults to Classic / Covers. Discovery does not expose theme, CMS home sections, richer catalog facets, CMS-driven home ordering or share data yet; these are documented as future API work in DESIGN.md. ## Install & point at an instance @@ -39,22 +44,21 @@ On first launch the app shows **Onboarding**: enter your Pinakes instance URL. | 8. Notifications | ✅ | Feed w/ per-type icons, read/unread styling, **pull-to-refresh** | | 9. Contact | ✅ | `POST /messages` subject+body form, success state | -- **Bottom nav:** Search / Library / Wishlist / Profile. **Nested routes:** Book Detail, Notifications, Contact. -- **Design system:** Material 3 light **and** dark, brand magenta `#D70161` + indigo `#6366F1`, - Inter (bundled), rounded cards, soft shadows, brand-gradient header on onboarding/login, - navigation transitions + list/press animations, adaptive launcher icon. -- **Architecture:** Navigation-Compose + ViewModel/StateFlow, manual DI (`ServiceLocator` via a - `LocalServices` composition local), Retrofit + OkHttp + kotlinx.serialization, Coil for covers. +- **Bottom nav:** Home / Catalog / Library / Wishlist / Profile. **Nested routes:** Book Detail, Notifications, Contact and optional collection details. +- **Design system:** Material 3 light **and** dark, 2026 theme-derived colours (Classic magenta by default), + bundled Geist / Fraunces, warm neutrals, complete book covers, rounded controls and subtle hero washes. + Calendar and status colours follow the app theme. The adaptive launcher icon is preserved. +- **Architecture:** Navigation-Compose + ViewModel/StateFlow + Hilt, Retrofit + OkHttp + kotlinx.serialization, Coil for covers. All loading/empty/error states handled per screen. ## Internationalization (i18n) -The app is **fully localized in 4 languages — Italian, English, French, German** — matching the -Pinakes backend locales. It follows the **device locale** by default and offers an **in-app language +The app supports **4 languages — Italian, English, French, German**; the PHP server also supports Danish. +It follows the **device locale** by default and offers an **in-app language switcher** in Profile (System default / Italiano / English / Français / Deutsch) via `AppCompatDelegate.setApplicationLocales(...)`, persisted across restarts (`autoStoreLocales`). -- **Single source of truth = JSON.** Translations live in `i18n/{en,it,fr,de}.json` (209 keys each, +- **Single source of truth = JSON.** Translations live in `i18n/{en,it,fr,de}.json` (622 keys each, en = default/source). A Gradle task (`GenerateI18nResTask`) generates `res/values*/strings.xml` from those JSONs at build time, so the app uses standard Android string resources but the editable source stays JSON — syncable with the web app's `locale/*.json`. diff --git a/_contract/MOBILE_API_SPEC.md b/_contract/MOBILE_API_SPEC.md index 9cb0c7c..8d67633 100644 --- a/_contract/MOBILE_API_SPEC.md +++ b/_contract/MOBILE_API_SPEC.md @@ -10,6 +10,14 @@ ## Goal +### Implemented extension: Mobile API 1.5.0 / Android 1.6.0 + +The coordinated changes in Pinakes #458 and Android #41 add capability-gated Archives and library Desiderata, separate from circulation inventory and personal wishlist. Authenticated collection routes provide independent cursors, public archive hierarchy/metadata/documents/exports, and verified-account donation proposals with consent. Proposal UUIDs survive retries and process death; the server deduplicates per account and exposes account-scoped outcome recovery. + +Analytic article search supports shared author IDs, publisher, complete genre ancestry, language, container and keyword filters. Unsupported analytic filters require a server upgrade rather than returning unfiltered results. Book and article details expose complete bibliographic metadata, five server-generated citation styles and digital attachments; article RIS/MARCXML and protected staff-management links are retained. Old circulation routes stay compatible. See [the parity matrix](../docs/UWE-PARITY.md) and [release preparation](../docs/PLAY-RELEASES.md) for version dependencies and verified boundaries. + +### Original API scope + Expose **everything a logged-in library user can do on the web** through a versioned REST API, so a native app can deliver: catalog search, book detail, loan/reservation requests, wishlist, profile, contact messaging, and push @@ -31,7 +39,7 @@ stock). The app stores the instance URL + a long-lived per-device token. | Pagination | **Cursor-based** for catalog/lists: `meta.next_cursor` (opaque), `?cursor=...&limit=...`. | | Localization | Strings in the **instance locale** (decided at install); **dates ISO-8601 UTC**; the app formats locally. | | Write actions | reserve / request loan, **cancel reservation**, wishlist add/remove, **edit profile + change password**, **send contact message**. | -| Search filters | text (title/author/keyword), author/publisher, **genre cascade (3 levels) + language**, **availability (loanable now)**. | +| Search filters | text (title/author/keyword), author identity/publisher, **complete genre ancestry + language**, **availability (loanable now)**. | | Book detail | **Full** payload (availability, copies, shelf/location, absolute cover URL, full metadata, related) **+ personal history** (has the user read/reserved/wishlisted it). | | Caching | **ETag / Last-Modified** + cache headers on read endpoints; honor `If-None-Match` → 304. | | Push transport | **UnifiedPush** primary (the library manager self-registers a provider and creates the credentials — minimal setup). Behind a **pluggable `PushProvider` abstraction** (UnifiedPush impl now; FCM impl optional/stub). | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 507859a..ca97ff0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -163,10 +163,11 @@ android { defaultConfig { applicationId = "com.pinakes.app" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" minSdk = 26 targetSdk = 35 - versionCode = 16 - versionName = "1.5.2" + versionCode = 17 + versionName = "1.6.0" vectorDrawables { useSupportLibrary = true @@ -281,6 +282,10 @@ dependencies { implementation(libs.androidx.material3) implementation(libs.androidx.material.icons.extended) debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + androidTestImplementation(libs.androidx.test.runner) implementation(libs.androidx.navigation.compose) diff --git a/app/src/androidTest/java/com/pinakes/app/CollectionsUiTest.kt b/app/src/androidTest/java/com/pinakes/app/CollectionsUiTest.kt new file mode 100644 index 0000000..2ef0d1b --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/CollectionsUiTest.kt @@ -0,0 +1,57 @@ +package com.pinakes.app + +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.platform.app.InstrumentationRegistry +import com.pinakes.app.data.model.* +import com.pinakes.app.ui.screens.collections.DonationContent +import com.pinakes.app.ui.screens.collections.DonationState +import com.pinakes.app.ui.screens.periodicals.StandaloneArticleContent +import com.pinakes.app.ui.theme.PinakesTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class CollectionsUiTest { + @get:Rule val compose = createComposeRule() + private fun text(id: Int) = InstrumentationRegistry.getInstrumentation().targetContext.getString(id) + + @Test fun unconfirmedDonationIsReadOnlyAndOffersARecoveryAction() { + var retries = 0 + compose.setContent { PinakesTheme { + DonationContent(DonationState(title = "Grüße 日本語", consent = true, uncertain = true), + onEdit = { _, _ -> }, onConsent = {}, onSubmit = { retries++ }, onReload = {}, onFree = {}, onDone = {}) + } } + compose.onNodeWithText(text(R.string.donation_book_title)).assertIsNotEnabled() + compose.onNodeWithText(text(R.string.donation_uncertain)).performScrollTo().assertIsDisplayed() + compose.onNodeWithText(text(R.string.action_retry)).performScrollTo().performClick() + assertEquals(1, retries) + } + + @Test fun confirmedProposalHasADoneActionAndNoSecondSendButton() { + var done = 0 + compose.setContent { PinakesTheme { + DonationContent(DonationState(submitted = true), onEdit = { _, _ -> }, onConsent = {}, onSubmit = {}, onReload = {}, onFree = {}, onDone = { done++ }) + } } + compose.onNodeWithText(text(R.string.donation_success)).assertIsDisplayed() + compose.onNodeWithText(text(R.string.donation_send)).assertDoesNotExist() + compose.onNodeWithText(text(R.string.donation_done)).performClick() + assertEquals(1, done) + } + + @Test fun articleCreditsOpenSharedAuthorWorksAndCiteDialogOffersOxford() { + var selectedAuthor: Int? = null + compose.setContent { PinakesTheme { + StandaloneArticleContent(StandaloneArticle(title = "Article without a cover", authors = "Petersen, Hans Uwe", + authorCredits = listOf(ArticleCredit(42, "Hans Uwe Petersen")), containerType = "antologia", editors = "Müller, A.", + citations = listOf(ArticleCitation("apa", "APA 7", "APA reference"), ArticleCitation("oxford", "Oxford (Umeå)", "Oxford reference", "Oxford reference"))), + onOpenPdf = {}, onOpenPeriodical = {}, onOpenIssue = {}, onFindWorks = { _, id -> selectedAuthor = id }) + } } + compose.onNodeWithText("Hans Uwe Petersen").performScrollTo().performClick() + assertEquals(42, selectedAuthor) + compose.onNodeWithText(text(R.string.citation_cite)).performScrollTo().performClick() + compose.onNodeWithText("Oxford (Umeå)").performClick() + compose.onNodeWithText("Oxford reference").assertIsDisplayed() + compose.onNodeWithText(text(R.string.citation_copy)).assertHasClickAction() + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/RestylingUiTest.kt b/app/src/androidTest/java/com/pinakes/app/RestylingUiTest.kt new file mode 100644 index 0000000..4cf8960 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/RestylingUiTest.kt @@ -0,0 +1,172 @@ +package com.pinakes.app + +import android.graphics.Bitmap +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Surface +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import androidx.test.platform.app.InstrumentationRegistry +import com.pinakes.app.data.model.BookDetail +import com.pinakes.app.ui.components.* +import com.pinakes.app.ui.screens.detail.DetailContent +import com.pinakes.app.ui.screens.home.HomeContent +import com.pinakes.app.ui.screens.home.HomeUiState +import com.pinakes.app.ui.theme.* +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import java.io.File + +class RestylingUiTest { + @get:Rule val compose = createComposeRule() + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + private fun text(id: Int) = context.getString(id) + + @Test fun tallCoverKeepsTheTopAndBottomOfTheArtwork() { + val bitmap = Bitmap.createBitmap(100, 250, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(android.graphics.Color.BLUE) + for (y in 0 until 15) for (x in 0 until 100) bitmap.setPixel(x, y, android.graphics.Color.RED) + for (y in 235 until 250) for (x in 0 until 100) bitmap.setPixel(x, y, android.graphics.Color.GREEN) + val file = File(context.cacheDir, "tall-cover.png") + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + compose.setContent { PinakesTheme { BookCover("Tall cover", file.toURI().toString(), Modifier.size(100.dp, 150.dp).testTag("cover")) } } + // Coil decodes asynchronously. Wait for the expected artwork, not a fixed delay. + compose.waitUntil(10000) { + val pixels = compose.onNodeWithTag("cover").captureToImage().toPixelMap() + val top = pixels[pixels.width / 2, (pixels.height * .03f).toInt()] + top.red > .7f && top.green < .3f + } + val pixels = compose.onNodeWithTag("cover").captureToImage().toPixelMap() + val bottom = pixels[pixels.width / 2, (pixels.height * .97f).toInt()] + assertTrue("Publisher band at the bottom is cropped", bottom.green > .7f && bottom.red < .3f) + } + + @Test fun tintedCardSamplesTheLoadedCover() { + val bitmap = Bitmap.createBitmap(20, 30, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(android.graphics.Color.RED) + val file = File(context.cacheDir, "red-cover.png") + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + compose.setContent { PinakesTheme(palette = ThemePalette(cardStyle = CardStyle.Tinted)) { + BookCardGrid("Red cover", "Author", file.toURI().toString(), modifier = Modifier.width(180.dp).testTag("card")) + } } + compose.waitUntil(10000) { + val pixels = compose.onNodeWithTag("card").captureToImage().toPixelMap() + val panel = pixels[pixels.width / 2, (12 * context.resources.displayMetrics.density).toInt()] + // The real book shadow can darken the panel; assert the cover's hue. + panel.red > panel.green + .10f && kotlin.math.abs(panel.green - panel.blue) < .02f && panel.green < .90f + } + } + + @Test fun legacyImageUsesTheBindingColourAndAnnouncesTheTitleOnce() { + val bitmap = Bitmap.createBitmap(20, 30, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(android.graphics.Color.RED) + val file = File(context.cacheDir, "placeholder.png") + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + var tone: Color? = null + val title = "Il nome della rosa" + compose.setContent { PinakesTheme { + BookCover(title, file.toURI().toString(), Modifier.size(160.dp, 240.dp), + author = "Umberto Eco", publisher = "Bompiani", onTone = { tone = it }) + } } + compose.waitUntil(10000) { tone != null } + compose.runOnIdle { assertEquals(BookPlaceholderPapers[bookPlaceholderTone(title)], tone) } + compose.onAllNodesWithContentDescription(title).assertCountEquals(1) + // The merged accessibility tree stops at the decorative binding. + compose.onNodeWithText("Umberto Eco").assertDoesNotExist() + } + + @Test fun gridFallbackKeepsMetadataAndOpensTheBook() { + var clicked = false + compose.setContent { PinakesTheme { Surface { Row(Modifier.width(328.dp)) { + BookCardGrid("A very long title with København, Grüße and 日本語", "", null, + AvailabilityStatus.Unavailable, onClick = { clicked = true }, modifier = Modifier.weight(1f), mediaType = "DVD") + Spacer(Modifier.width(14.dp)) + BookCardGrid("Another book", "Author", null, modifier = Modifier.weight(1f)) + } } } } + compose.onNodeWithText(text(R.string.restyle_unknown_author), useUnmergedTree = true).assertIsDisplayed() + compose.onNodeWithContentDescription("DVD", useUnmergedTree = true).assertIsDisplayed() + compose.onNode(hasClickAction() and hasText("A very long title", substring = true)).performClick() + assertTrue(clicked) + } + + @Test fun catalogViewToggleKeepsItsSelectedSemantics() { + compose.setContent { PinakesTheme { var grid by remember { mutableStateOf(true) }; CatalogViewToggle(grid) { grid = it } } } + compose.onNodeWithContentDescription(text(R.string.restyle_grid)).assertIsSelected() + compose.onNodeWithContentDescription(text(R.string.restyle_list)).performClick().assertIsSelected() + compose.onNodeWithContentDescription(text(R.string.restyle_grid)).assertIsNotSelected() + } + + @Test fun detailWithNoDigitalFilesOmitsTheDigitalSectionAndRetainsActions() { + var borrowed = false + compose.setContent { PinakesTheme { DetailContent(BookDetail(title = "Blank book"), false, false, + canBorrow = true, showWishlist = true, showReviews = false, + onReserve = { borrowed = true }, onToggleWishlist = {}, onShowMessage = {}) } } + compose.onNodeWithText(text(R.string.restyle_digital)).assertDoesNotExist() + compose.onNodeWithText(text(R.string.book_reserve)).performScrollTo().performClick() + assertTrue(borrowed) + compose.onNodeWithText(text(R.string.book_wishlist)).performScrollTo().assertIsDisplayed() + } + + @Test fun digitalFilesRemainSeparateOnANarrowScreen() { + compose.setContent { PinakesTheme { Column(Modifier.width(328.dp)) { + DigitalFileCard("PDF", "a-long-digital-edition-name.pdf", text(R.string.restyle_digital_edition)) { + PrimaryButton(text(R.string.book_read), {}, dark = true) + } + DigitalFileCard("MP3", "audiobook.mp3", text(R.string.restyle_audiobook)) { + SecondaryButton("Play", {}) + } + } } } + compose.onNodeWithText("PDF").assertIsDisplayed() + compose.onNodeWithText("MP3").assertIsDisplayed() + compose.onNodeWithText(text(R.string.book_read)).assertIsDisplayed() + } + + @Test fun narrowSearchFieldKeepsItsPlaceholderOnOneLine() { + compose.setContent { PinakesTheme { SearchField("", {}, Modifier.width(180.dp).testTag("search"), + placeholder = "Search the entire catalog by title, author or publisher") } } + val bounds = compose.onNodeWithTag("search").getUnclippedBoundsInRoot() + assertTrue("Placeholder expanded the search field to multiple lines", (bounds.bottom - bounds.top) <= 60.dp) + } + + @Test fun emptyHomeKeepsItsCatalogActionReachableOnAShortPhone() { + var browsed = false + compose.setContent { PinakesTheme { Surface(Modifier.size(360.dp, 480.dp)) { + HomeContent(HomeUiState(libraryName = "Library", loading = false), false, {}, { browsed = true }, {}, {}) + } } } + compose.onNode(hasScrollToIndexAction()).performScrollToNode(hasText(text(R.string.home_browse_catalog))) + compose.onNodeWithText(text(R.string.home_browse_catalog)).performClick() + assertTrue(browsed) + } + + @Test fun homeSearchSurvivesTheLoadingPhaseTransition() { + var state by mutableStateOf(HomeUiState(libraryName = "Library", loading = true)) + compose.setContent { PinakesTheme { HomeContent(state, false, {}, {}, {}, {}) } } + compose.onNode(hasSetTextAction()).performTextInput("Eco") + compose.runOnIdle { state = state.copy(loading = false) } + compose.onNode(hasSetTextAction()).assertTextEquals("Eco") + } + + @Test fun oceanThemeUsesItsButtonTextPair() = themedButtons(ThemePalette(Color(0xFF0284C7), Color(0xFF0C4A6E), Color(0xFF0EA5E9))) + @Test fun minimalThemeUsesItsButtonTextPair() = themedButtons(ThemePalette(Color(0xFF404040), Color.Black, Color(0xFF808080))) + @Test fun darkThemeKeepsTheControlsReadable() = themedButtons(ThemePalette(), ThemeMode.DARK) + + private fun themedButtons(palette: ThemePalette, mode: ThemeMode = ThemeMode.LIGHT) { + compose.setContent { PinakesTheme(mode = mode, palette = palette) { Column { + PrimaryButton("Borrow", {}, modifier = Modifier.testTag("primary")) + SecondaryButton("Wishlist", {}) + AvailabilityChip(AvailabilityStatus.Available) + } } } + compose.onNodeWithText("Borrow").assertIsDisplayed() + val pixels = compose.onNodeWithTag("primary").captureToImage().toPixelMap() + val expected = PinakesColors(palette, mode == ThemeMode.DARK).button + val actual = pixels[pixels.width / 2, 5] + assertEquals(expected.red, actual.red, .02f) + assertEquals(expected.blue, actual.blue, .02f) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/StandaloneArticleUiTest.kt b/app/src/androidTest/java/com/pinakes/app/StandaloneArticleUiTest.kt new file mode 100644 index 0000000..dfa309a --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/StandaloneArticleUiTest.kt @@ -0,0 +1,63 @@ +package com.pinakes.app + +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.platform.app.InstrumentationRegistry +import com.pinakes.app.data.model.StandaloneArticle +import com.pinakes.app.ui.screens.periodicals.StandaloneArticleContent +import com.pinakes.app.ui.theme.PinakesTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class StandaloneArticleUiTest { + @get:Rule val compose = createComposeRule() + private fun text(id: Int) = InstrumentationRegistry.getInstrumentation().targetContext.getString(id) + + @Test fun publishedActionsOpenThePdfResourceAndWebsite() { + var pdf = 0; var resource = 0; var web = 0 + compose.setContent { PinakesTheme { + StandaloneArticleContent( + article = StandaloneArticle(title = "Article", subtitle = "Article subtitle", + hasPublicPdf = true, pdfUrl = "https://library.example/42/pdf", + hasPublicResource = true, resourceAddress = "https://archive.example/42", + resourceLabel = "Archive copy", resourceAccess = "Reading room use"), + onOpenPdf = { pdf++ }, onOpenPeriodical = {}, onOpenIssue = {}, + onOpenResource = { resource++ }, onOpenWeb = { web++ }, + ) + } } + compose.onNodeWithText("Article subtitle").assertIsDisplayed() + compose.onNodeWithText(text(R.string.periodicals_open_pdf)).performScrollTo().performClick() + compose.onNodeWithText("Archive copy").performScrollTo().performClick() + compose.onNodeWithText("Reading room use").assertIsDisplayed() + compose.onNodeWithText(text(R.string.standalone_article_open_web)).performScrollTo().performScrollTo().performClick() + compose.runOnIdle { assertEquals(listOf(1, 1, 1), listOf(pdf, resource, web)) } + } + + @Test fun unpublishedResourcesAndPdfsHaveNoActionsOrAccessNotes() { + compose.setContent { PinakesTheme { + StandaloneArticleContent( + article = StandaloneArticle(title = "Article", pdfUrl = "https://library.example/private/pdf", + resourceAddress = "https://archive.example/private", resourceLabel = "Private copy", + resourceAccess = "Private access note"), + onOpenPdf = {}, onOpenPeriodical = {}, onOpenIssue = {}, + ) + } } + compose.onNodeWithText(text(R.string.periodicals_open_pdf)).assertDoesNotExist() + compose.onNodeWithText("Private copy").assertDoesNotExist() + compose.onNodeWithText("Private access note").assertDoesNotExist() + } + + @Test fun publishedLocalReferenceIsTextWithItsAccessConditions() { + compose.setContent { PinakesTheme { + StandaloneArticleContent( + article = StandaloneArticle(title = "Article", hasPublicResource = true, + resourceAddress = "archive/1988/petersen.pdf", resourceAccess = "In library only"), + onOpenPdf = {}, onOpenPeriodical = {}, onOpenIssue = {}, + ) + } } + compose.onNodeWithText("archive/1988/petersen.pdf").performScrollTo().assertIsDisplayed().assertHasNoClickAction() + compose.onNodeWithText("In library only").performScrollTo().assertIsDisplayed() + compose.onNodeWithText(text(R.string.standalone_article_online_resource)).assertDoesNotExist() + } +} diff --git a/app/src/main/java/com/pinakes/app/data/model/CollectionsModels.kt b/app/src/main/java/com/pinakes/app/data/model/CollectionsModels.kt new file mode 100644 index 0000000..c2a10b8 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/model/CollectionsModels.kt @@ -0,0 +1,74 @@ +package com.pinakes.app.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CollectionHealth( + val status: String = "", + @SerialName("native_offers") val nativeOffers: Boolean = false, + @SerialName("web_url") val webUrl: String? = null, + @SerialName("manage_url") val manageUrl: String? = null, +) + +/** Library requests have no lending availability; they are distinct from personal wishes. */ +@Serializable +data class WantedBook( + val id: Int = 0, + val title: String = "", + val subtitle: String? = null, + val author: String? = null, + val publisher: String? = null, + val isbn: String? = null, + val year: Int? = null, + val description: String? = null, + @SerialName("cover_url") val coverUrl: String? = null, + @SerialName("web_url") val webUrl: String? = null, + val wanted: Boolean = true, +) + +@Serializable +data class DonationOfferRequest( + @SerialName("submission_id") val submissionId: String, + @SerialName("book_id") val bookId: Int? = null, + val title: String, + val author: String = "", + val publisher: String = "", + val isbn: String = "", + val notes: String = "", + val consent: Boolean, +) + +@Serializable +data class DonationReceipt(val id: Int = 0, val status: String = "") + +@Serializable +data class ArchiveRecord( + val id: Int = 0, + @SerialName("parent_id") val parentId: Int? = null, + val title: String = "", + @SerialName("formal_title") val formalTitle: String? = null, + @SerialName("reference_code") val referenceCode: String = "", + val level: String = "", + @SerialName("date_start") val dateStart: Int? = null, + @SerialName("date_end") val dateEnd: Int? = null, + val extent: String? = null, + val material: String? = null, + @SerialName("cover_url") val coverUrl: String? = null, + @SerialName("web_url") val webUrl: String? = null, + val fields: Map = emptyMap(), + val ancestors: List = emptyList(), + val authorities: List = emptyList(), + val documents: List = emptyList(), + val exports: Map = emptyMap(), +) { + val datesLabel: String? get() = dateStart?.let { start -> + dateEnd?.takeUnless { it == start }?.let { "$start–$it" } ?: start.toString() + } +} + +@Serializable +data class ArchiveAuthority(val id: Int = 0, val name: String = "", val type: String = "", val dates: String? = null, val role: String = "") + +@Serializable +data class CollectionDocument(val url: String = "", val label: String = "", val mime: String = "") diff --git a/app/src/main/java/com/pinakes/app/data/model/Models.kt b/app/src/main/java/com/pinakes/app/data/model/Models.kt index 54a47a3..1821be7 100644 --- a/app/src/main/java/com/pinakes/app/data/model/Models.kt +++ b/app/src/main/java/com/pinakes/app/data/model/Models.kt @@ -59,6 +59,8 @@ data class HealthFeatures( val push: Boolean = false, // Star + text book reviews (a borrower can review a title; everyone reads them). val reviews: Boolean = false, + val archives: Boolean = false, + val desiderata: Boolean = false, ) // ---------- Auth ---------- @@ -258,6 +260,13 @@ data class BookDetail( @SerialName("ebook_url") val ebookUrl: String? = null, @SerialName("ebook_format") val ebookFormat: String? = null, // "pdf" | "epub" | ... @SerialName("has_ebook") val hasEbook: Boolean = false, + @SerialName("digital_attachments") val digitalAttachments: List = emptyList(), + val citations: List = emptyList(), + @SerialName("ris_url") val risUrl: String? = null, + @SerialName("genre_path") val genrePath: List = emptyList(), + val publishers: List = emptyList(), + val edition: String? = null, + @SerialName("publication_place") val publicationPlace: String? = null, ) { val authorsLabel: String get() = authors.joinToString(", ") { it.name } val genreLabel: String? get() = genre?.name @@ -267,6 +276,24 @@ data class BookDetail( val copiesAvailable: Int get() = availability.copiesAvailable val loanableNow: Boolean get() = availability.loanableNow val available: Boolean get() = availability.loanableNow || availability.copiesAvailable > 0 + + /** Old servers still advertise one audio and one ebook; list-aware servers own the list. */ + val digitalFiles: List get() = digitalAttachments.ifEmpty { + listOfNotNull( + audioUrl?.takeIf { hasAudio && it.isNotBlank() }?.let { DigitalAttachment(it, kind = "audio") }, + ebookUrl?.takeIf { hasEbook && it.isNotBlank() }?.let { DigitalAttachment(it, kind = "ebook") }, + ) + }.filter { it.safeUrl != null }.distinctBy { it.url } +} + +@Serializable +data class DigitalAttachment(val url: String = "", val label: String = "", val kind: String = "ebook") { + val safeUrl: String? get() = url.takeIf { + val uri = runCatching { java.net.URI(it) }.getOrNull() + uri?.scheme?.lowercase() in listOf("http", "https") && !uri?.host.isNullOrBlank() + } + val format: String get() = runCatching { java.net.URI(url).path.substringAfterLast('.').uppercase() }.getOrDefault("") + val isPdf: Boolean get() = format == "PDF" } @Serializable @@ -537,3 +564,6 @@ data class MyReview( @SerialName("created_at") val createdAt: String? = null, @SerialName("updated_at") val updatedAt: String? = null, ) + +@Serializable +data class GenreRef(val id: Int = 0, val name: String = "") diff --git a/app/src/main/java/com/pinakes/app/data/model/PeriodicalsModels.kt b/app/src/main/java/com/pinakes/app/data/model/PeriodicalsModels.kt index 83ff746..136e489 100644 --- a/app/src/main/java/com/pinakes/app/data/model/PeriodicalsModels.kt +++ b/app/src/main/java/com/pinakes/app/data/model/PeriodicalsModels.kt @@ -123,6 +123,8 @@ data class PeriodicalIssueDetail( val masthead: IssueMasthead? = null, val year: IssueYear? = null, val articles: List = emptyList(), + @SerialName("catalogued_articles") val cataloguedArticles: List = emptyList(), + @SerialName("catalogued_articles_truncated") val cataloguedArticlesTruncated: Boolean = false, ) { /** The PDF action is offered ONLY for a non-blank public URL (server-authoritative). */ val canOpenPdf: Boolean get() = !pdfUrl.isNullOrBlank() @@ -154,6 +156,7 @@ data class IssueArticle( @Serializable data class PeriodicalsCapabilities( @SerialName("standalone_articles") val standaloneArticles: Boolean = false, + @SerialName("article_filters") val articleFilters: Boolean = false, ) /** Standalone contributions use the server's Italian column names, unlike issue indexes. */ @@ -161,6 +164,8 @@ data class PeriodicalsCapabilities( data class StandaloneArticle( val id: Int = 0, @SerialName("titolo") val title: String = "", + @SerialName("sottotitolo") val subtitle: String? = null, + @SerialName("cover_url") val coverUrl: String? = null, @SerialName("autori") val authors: String? = null, @SerialName("tipo_contributo") val contributionType: String? = null, @SerialName("contenitore_tipo") val containerType: String? = null, @@ -179,6 +184,25 @@ data class StandaloneArticle( @SerialName("fascicolo_id") val issueId: Int? = null, @SerialName("has_public_pdf") val hasPublicPdf: Boolean = false, @SerialName("pdf_url") val pdfUrl: String? = null, + @SerialName("has_public_resource") val hasPublicResource: Boolean = false, + @SerialName("risorsa_url") val resourceAddress: String? = null, + @SerialName("risorsa_testo") val resourceLabel: String? = null, + @SerialName("risorsa_accesso") val resourceAccess: String? = null, + @SerialName("lingua") val language: String? = null, + @SerialName("paese") val country: String? = null, + @SerialName("classificazione_schema") val classificationScheme: String? = null, + @SerialName("classificazione") val classification: String? = null, + @SerialName("nota_possesso") val holdingsNote: String? = null, + @SerialName("contenitore_curatori") val editors: String? = null, + @SerialName("contenitore_editore") val publisher: String? = null, + @SerialName("contenitore_luogo") val publicationPlace: String? = null, + val isbn: String? = null, + @SerialName("genre_path") val genrePath: List = emptyList(), + @SerialName("manage_url") val manageUrl: String? = null, + @SerialName("marcxml_url") val marcXmlUrl: String? = null, + val citations: List = emptyList(), + @SerialName("ris_url") val risUrl: String? = null, + @SerialName("author_credits") val authorCredits: List = emptyList(), ) { // Date text and page spans are citations, not ISO dates or page counts. val dateLabel: String? get() = publicationDate?.takeIf { it.isNotBlank() } @@ -188,4 +212,17 @@ data class StandaloneArticle( val uri = runCatching { java.net.URI(it) }.getOrNull() uri?.scheme?.lowercase() in listOf("https", "http") && !uri?.host.isNullOrBlank() } + // Published archive references are readable text; only HTTP(S) addresses + // become actions. Old servers omit the flag, so they expose no resource. + val publicResourceAddress: String? get() = resourceAddress?.trim()?.takeIf { hasPublicResource && it.isNotEmpty() } + val publicResourceUrl: String? get() = publicResourceAddress?.takeIf { + val uri = runCatching { java.net.URI(it) }.getOrNull() + uri?.scheme?.lowercase() in listOf("https", "http") && !uri?.host.isNullOrBlank() + } } + +@Serializable +data class ArticleCitation(val key: String = "", val label: String = "", val text: String = "", val html: String? = null) + +@Serializable +data class ArticleCredit(val id: Int? = null, val name: String = "", val role: String = "", val identifiers: List = emptyList()) diff --git a/app/src/main/java/com/pinakes/app/data/network/CollectionsApi.kt b/app/src/main/java/com/pinakes/app/data/network/CollectionsApi.kt new file mode 100644 index 0000000..174180d --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/network/CollectionsApi.kt @@ -0,0 +1,19 @@ +package com.pinakes.app.data.network + +import com.pinakes.app.data.model.* +import retrofit2.http.* + +interface CollectionsApi { + @GET("archives/health") suspend fun archivesHealth(): Envelope + @GET("desiderata/health") suspend fun desiderataHealth(): Envelope + @GET("archives") suspend fun archives( + @Query("q") query: String? = null, @Query("level") level: String? = null, + @Query("date_from") from: Int? = null, @Query("date_to") to: Int? = null, + @Query("parent_id") parentId: Int? = null, @Query("cursor") cursor: String? = null, + ): Envelope> + @GET("archives/{id}") suspend fun archive(@Path("id") id: Int): Envelope + @GET("desiderata") suspend fun wanted(@Query("q") query: String? = null, @Query("cursor") cursor: String? = null): Envelope> + @GET("desiderata/{id}") suspend fun wantedBook(@Path("id") id: Int): Envelope + @POST("desiderata/offers") suspend fun offer(@Body request: DonationOfferRequest): Envelope + @GET("desiderata/offers/{submission}") suspend fun offerStatus(@Path("submission") submission: String): Envelope +} diff --git a/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt b/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt index 0e0ea7f..bd2f82a 100644 --- a/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt +++ b/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt @@ -95,6 +95,8 @@ class NetworkModule(private val session: SessionStore, cacheDir: File? = null) { @Volatile private var cachedPeriodicalsApi: PeriodicalsApi? = null + @Volatile + private var cachedCollectionsApi: CollectionsApi? = null /** Shared Retrofit for a given base URL, rebuilt only when the instance URL changes. */ @Synchronized @@ -116,6 +118,7 @@ class NetworkModule(private val session: SessionStore, cacheDir: File? = null) { cachedApi = null cachedBookClubApi = null cachedPeriodicalsApi = null + cachedCollectionsApi = null return retrofit } @@ -150,6 +153,12 @@ class NetworkModule(private val session: SessionStore, cacheDir: File? = null) { return cachedPeriodicalsApi ?: retrofit.create(PeriodicalsApi::class.java).also { cachedPeriodicalsApi = it } } + @Synchronized + fun collectionsApi(baseUrl: String? = null): CollectionsApi { + val retrofit = retrofit(baseUrl) + return cachedCollectionsApi ?: retrofit.create(CollectionsApi::class.java).also { cachedCollectionsApi = it } + } + /** Drop the cached Retrofit so the next [api] call rebuilds against a new instance URL. */ @Synchronized fun invalidate() { @@ -158,6 +167,7 @@ class NetworkModule(private val session: SessionStore, cacheDir: File? = null) { cachedApi = null cachedBookClubApi = null cachedPeriodicalsApi = null + cachedCollectionsApi = null } /** diff --git a/app/src/main/java/com/pinakes/app/data/network/PeriodicalsApi.kt b/app/src/main/java/com/pinakes/app/data/network/PeriodicalsApi.kt index 0ea0f7f..e98106b 100644 --- a/app/src/main/java/com/pinakes/app/data/network/PeriodicalsApi.kt +++ b/app/src/main/java/com/pinakes/app/data/network/PeriodicalsApi.kt @@ -52,6 +52,14 @@ interface PeriodicalsApi { @Query("q") query: String? = null, @Query("testata_id") mastheadId: Int? = null, @Query("cursor") cursor: String? = null, + @Query("fascicolo_id") issueId: Int? = null, + @Query("genre") genreId: Int? = null, + @Query("language") language: String? = null, + @Query("author") author: String? = null, + @Query("publisher") publisher: String? = null, + @Query("container") container: String? = null, + @Query("keyword") keyword: String? = null, + @Query("author_id") authorId: Int? = null, ): Envelope> @GET("periodicals/articles/{id}") diff --git a/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt b/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt index 402cb28..aa5ae67 100644 --- a/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt +++ b/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt @@ -99,6 +99,7 @@ interface PinakesApi { suspend fun search( @Query("q") q: String? = null, @Query("author") author: String? = null, + @Query("author_id") authorId: Int? = null, @Query("publisher") publisher: String? = null, @Query("genre") genre: Int? = null, @Query("language") language: String? = null, diff --git a/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt index a78bb50..ea9fa6e 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt @@ -24,6 +24,7 @@ data class SearchFilters( val genreId: Int? = null, val language: String? = null, val availableOnly: Boolean? = null, + val authorId: Int? = null, ) /** One page of search results plus the cursor needed to fetch the next page. */ @@ -95,7 +96,8 @@ class CatalogRepository( val res = apiCall { api.search( q = filters.query?.takeIf { it.isNotBlank() }, - author = filters.author?.takeIf { it.isNotBlank() }, + authorId = filters.authorId, + author = filters.author?.takeIf { it.isNotBlank() && filters.authorId == null }, publisher = filters.publisher?.takeIf { it.isNotBlank() }, genre = filters.genreId, language = filters.language?.takeIf { it.isNotBlank() }, diff --git a/app/src/main/java/com/pinakes/app/data/repository/CollectionsRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/CollectionsRepository.kt new file mode 100644 index 0000000..c22a042 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/repository/CollectionsRepository.kt @@ -0,0 +1,39 @@ +package com.pinakes.app.data.repository + +import com.pinakes.app.data.model.* +import com.pinakes.app.data.network.* + +data class CollectionPage(val items: List, val nextCursor: String? = null, val total: Int? = null) +data class ArchiveFilters(val query: String = "", val level: String? = null, val from: Int? = null, val to: Int? = null, val parentId: Int? = null) + +/** Injectable contract lets paging, donation retries and server capability changes be tested. */ +interface CollectionsSource { + suspend fun archivesHealth(): ApiResult + suspend fun desiderataHealth(): ApiResult + suspend fun archives(filters: ArchiveFilters = ArchiveFilters(), cursor: String? = null): ApiResult> + suspend fun archive(id: Int): ApiResult + suspend fun wanted(query: String = "", cursor: String? = null): ApiResult> + suspend fun wantedBook(id: Int): ApiResult + suspend fun offer(request: DonationOfferRequest): ApiResult + suspend fun offerStatus(submission: String): ApiResult +} + +class CollectionsRepository(private val network: NetworkModule) : CollectionsSource { + override suspend fun archivesHealth() = apiCall { network.collectionsApi().archivesHealth() } + override suspend fun desiderataHealth() = apiCall { network.collectionsApi().desiderataHealth() } + override suspend fun archive(id: Int) = apiCall { network.collectionsApi().archive(id) } + override suspend fun wantedBook(id: Int) = apiCall { network.collectionsApi().wantedBook(id) } + override suspend fun offer(request: DonationOfferRequest) = apiCall { network.collectionsApi().offer(request) } + override suspend fun offerStatus(submission: String) = apiCall { network.collectionsApi().offerStatus(submission) } + override suspend fun archives(filters: ArchiveFilters, cursor: String?) = page { + network.collectionsApi().archives(filters.query.takeIf { it.isNotBlank() }, filters.level, filters.from, filters.to, filters.parentId, cursor) + } + override suspend fun wanted(query: String, cursor: String?) = page { + network.collectionsApi().wanted(query.takeIf { it.isNotBlank() }, cursor) + } + + private suspend fun page(call: suspend () -> Envelope>): ApiResult> = when (val result = apiCall(call)) { + is ApiResult.Success -> ApiResult.Success(CollectionPage(result.data, result.meta?.nextCursor, result.meta?.totalCount)) + is ApiResult.Failure -> result + } +} diff --git a/app/src/main/java/com/pinakes/app/data/repository/PeriodicalsRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/PeriodicalsRepository.kt index f07e8d8..083cc99 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/PeriodicalsRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/PeriodicalsRepository.kt @@ -110,6 +110,25 @@ class PeriodicalsRepository( is ApiResult.Failure -> result } + override suspend fun searchArticles(filters: ArticleFilters, cursor: String?): ApiResult { + val advanced = filters.authorId != null || filters.issueId != null || filters.genreId != null || filters.language != null || filters.author != null || filters.publisher != null || filters.container != null || filters.keyword != null + if (advanced) { + val capability = apiCall { network.periodicalsApi().health() } + if (capability is ApiResult.Failure) return capability + if (capability is ApiResult.Success && !capability.data.capabilities.articleFilters) return ApiResult.Failure("upgrade_required", "", 422) + } + return when (val result = apiCall { network.periodicalsApi().articles(filters.query, filters.mastheadId, cursor, + filters.issueId, filters.genreId, filters.language, filters.author, filters.publisher, filters.container, filters.keyword, filters.authorId) }) { + is ApiResult.Success -> ApiResult.Success(StandaloneArticlesPage(result.data, result.meta?.nextCursor), result.meta) + is ApiResult.Failure -> result + } + + } + override suspend fun article(id: Int): ApiResult = apiCall { network.periodicalsApi().article(id) } + + override fun articleWebUrl(id: Int): String? = session.instanceOrigin + ?.trimEnd('/')?.takeIf { id > 0 && it.isNotBlank() } + ?.let { "$it/emeroteca/articolo/$id" } } diff --git a/app/src/main/java/com/pinakes/app/data/repository/StandaloneArticlesSource.kt b/app/src/main/java/com/pinakes/app/data/repository/StandaloneArticlesSource.kt index 53224b8..4abaf34 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/StandaloneArticlesSource.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/StandaloneArticlesSource.kt @@ -8,10 +8,16 @@ data class StandaloneArticlesPage( val nextCursor: String? = null, ) +data class ArticleFilters(val query: String? = null, val mastheadId: Int? = null, val issueId: Int? = null, + val genreId: Int? = null, val language: String? = null, val author: String? = null, + val publisher: String? = null, val container: String? = null, val keyword: String? = null, val authorId: Int? = null) + /** Read-only article source, separately injectable for lifecycle/pagination tests. */ interface StandaloneArticlesSource { suspend fun standaloneArticlesSupported(): Boolean? suspend fun articles(query: String? = null, mastheadId: Int? = null, cursor: String? = null): ApiResult + suspend fun searchArticles(filters: ArticleFilters, cursor: String? = null): ApiResult = articles(filters.query, filters.mastheadId, cursor) suspend fun article(id: Int): ApiResult suspend fun confirmGone(): Boolean + fun articleWebUrl(id: Int): String? = null } diff --git a/app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt b/app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt index a22e8f8..dbec685 100644 --- a/app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt +++ b/app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt @@ -46,6 +46,8 @@ data class InstanceFeatures( * `GET /api/v1/periodicals/health` probe (2xx → on, 404 → off), hidden until confirmed. */ val periodicalsAvailable: Boolean = false, + val archivesAvailable: Boolean = false, + val desiderataAvailable: Boolean = false, ) { /** Library tab (loans + reservations) is shown only when at least one of them is enabled. */ val showLibrary: Boolean get() = loans || reservations @@ -102,6 +104,8 @@ class FeatureStore(context: Context) { registrationEnabled = health.registrationEnabled, bookClubAvailable = prefs.getBoolean(KEY_BOOK_CLUB, false), periodicalsAvailable = prefs.getBoolean(KEY_PERIODICALS, false), + archivesAvailable = f.archives && health.appAccessEnabled, + desiderataAvailable = f.desiderata && health.appAccessEnabled, ) prefs.edit() .putBoolean(KEY_KNOWN, true) @@ -115,6 +119,8 @@ class FeatureStore(context: Context) { .putBoolean(KEY_PUSH, value.push) .putBoolean(KEY_REVIEWS, value.reviews) .putBoolean(KEY_REGISTRATION_ENABLED, value.registrationEnabled) + .putBoolean("archives", value.archivesAvailable) + .putBoolean("desiderata", value.desiderataAvailable) .apply() _features.value = value } @@ -155,6 +161,8 @@ class FeatureStore(context: Context) { registrationEnabled = prefs.getBoolean(KEY_REGISTRATION_ENABLED, false), bookClubAvailable = prefs.getBoolean(KEY_BOOK_CLUB, false), periodicalsAvailable = prefs.getBoolean(KEY_PERIODICALS, false), + archivesAvailable = prefs.getBoolean("archives", false), + desiderataAvailable = prefs.getBoolean("desiderata", false), ) } diff --git a/app/src/main/java/com/pinakes/app/di/AppModule.kt b/app/src/main/java/com/pinakes/app/di/AppModule.kt index 6b76d30..b102d54 100644 --- a/app/src/main/java/com/pinakes/app/di/AppModule.kt +++ b/app/src/main/java/com/pinakes/app/di/AppModule.kt @@ -7,6 +7,8 @@ import com.pinakes.app.data.network.NetworkModule import com.pinakes.app.data.repository.AuthRepository import com.pinakes.app.data.repository.BookClubRepository import com.pinakes.app.data.repository.CatalogRepository +import com.pinakes.app.data.repository.CollectionsSource +import com.pinakes.app.data.repository.CollectionsRepository import com.pinakes.app.data.repository.LibraryRepository import com.pinakes.app.data.repository.MessagesRepository import com.pinakes.app.data.repository.NotificationsRepository @@ -66,6 +68,9 @@ object AppModule { fun periodicalsRepository(network: NetworkModule, features: FeatureStore, session: SessionStore): PeriodicalsRepository = PeriodicalsRepository(network, features, session) + @Provides @Singleton + fun collectionsRepository(network: NetworkModule): CollectionsSource = CollectionsRepository(network) + @Provides @Singleton fun authRepository( network: NetworkModule, diff --git a/app/src/main/java/com/pinakes/app/ui/components/AuthForm.kt b/app/src/main/java/com/pinakes/app/ui/components/AuthForm.kt new file mode 100644 index 0000000..2ce0aff --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/components/AuthForm.kt @@ -0,0 +1,28 @@ +package com.pinakes.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.unit.dp +import com.pinakes.app.ui.theme.LocalPinakesColors + +/** Scrollable authentication sheet, including when the keyboard or large type is in use. */ +@Composable +fun AuthForm(content: @Composable ColumnScope.() -> Unit) { + val colors = LocalPinakesColors.current + Column(Modifier.fillMaxSize().background(Brush.verticalGradient(listOf(colors.heroWash, colors.background))) + .imePadding().verticalScroll(rememberScrollState()).padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + Surface(shape = MaterialTheme.shapes.large, color = colors.surface, shadowElevation = 2.dp, + modifier = Modifier.widthIn(max = 460.dp).fillMaxWidth()) { + Column(Modifier.padding(22.dp), horizontalAlignment = Alignment.CenterHorizontally, content = content) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/components/AvailabilityChip.kt b/app/src/main/java/com/pinakes/app/ui/components/AvailabilityChip.kt index f0fd51e..bd691fa 100644 --- a/app/src/main/java/com/pinakes/app/ui/components/AvailabilityChip.kt +++ b/app/src/main/java/com/pinakes/app/ui/components/AvailabilityChip.kt @@ -1,12 +1,7 @@ package com.pinakes.app.ui.components import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme @@ -20,59 +15,34 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.pinakes.app.R -import com.pinakes.app.ui.theme.AvailableContainerDark -import com.pinakes.app.ui.theme.AvailableContainerLight -import com.pinakes.app.ui.theme.AvailableOnContainerDark -import com.pinakes.app.ui.theme.AvailableOnContainerLight -import com.pinakes.app.ui.theme.DueSoonContainerDark -import com.pinakes.app.ui.theme.DueSoonContainerLight -import com.pinakes.app.ui.theme.DueSoonOnContainerDark -import com.pinakes.app.ui.theme.DueSoonOnContainerLight +import com.pinakes.app.ui.theme.LocalPinakesColors enum class AvailabilityStatus { - Available, // available-green tint (also on-loan-on-time) - Unavailable, // uses error color role - LoanActive, // uses secondary color role - DueSoon, // amber tint (pending approval / damaged) - Overdue, // uses error color role (RED) — overdue / lost - ReservedReady, // uses tertiary color role (magenta family) — ready for pickup - Scheduled, // neutral mauve-grey (secondaryContainer) — scheduled / future reservation - Returned, // neutral grey (surfaceVariant) — returned / expired / cancelled - Digital, // uses primary color role + Available, // green dot + Unavailable, // neutral dot + LoanActive, // green dot + DueSoon, // amber dot + Overdue, // error dot + ReservedReady, // theme accent dot + Scheduled, // neutral dot + Returned, // neutral dot + Digital, // theme accent dot } -data class ChipColors(val container: Color, val onContainer: Color) - @Composable fun AvailabilityChip( status: AvailabilityStatus, label: String? = null, modifier: Modifier = Modifier, ) { - val dark = isSystemInDarkTheme() - val colorScheme = MaterialTheme.colorScheme - - val (container, onContainer) = when (status) { - AvailabilityStatus.Available -> - if (dark) ChipColors(AvailableContainerDark, AvailableOnContainerDark) - else ChipColors(AvailableContainerLight, AvailableOnContainerLight) - AvailabilityStatus.Unavailable, AvailabilityStatus.Overdue -> - ChipColors(colorScheme.errorContainer, colorScheme.onErrorContainer) - AvailabilityStatus.LoanActive -> - ChipColors(colorScheme.secondaryContainer, colorScheme.onSecondaryContainer) - AvailabilityStatus.DueSoon -> - if (dark) ChipColors(DueSoonContainerDark, DueSoonOnContainerDark) - else ChipColors(DueSoonContainerLight, DueSoonOnContainerLight) - AvailabilityStatus.ReservedReady -> - ChipColors(colorScheme.tertiaryContainer, colorScheme.onTertiaryContainer) - AvailabilityStatus.Scheduled -> - ChipColors(colorScheme.secondaryContainer, colorScheme.onSecondaryContainer) - AvailabilityStatus.Returned -> - ChipColors(colorScheme.surfaceVariant, colorScheme.onSurfaceVariant) - AvailabilityStatus.Digital -> - ChipColors(colorScheme.primaryContainer, colorScheme.onPrimaryContainer) + val colors = LocalPinakesColors.current + val dot = when (status) { + AvailabilityStatus.Available, AvailabilityStatus.LoanActive -> Color(0xFF16A34A) + AvailabilityStatus.DueSoon -> Color(0xFFF59E0B) + AvailabilityStatus.Overdue -> MaterialTheme.colorScheme.error + AvailabilityStatus.ReservedReady, AvailabilityStatus.Digital -> colors.accentText + else -> Color(0xFF9CA3AF) } - val chipLabel = label ?: when (status) { AvailabilityStatus.Available -> stringResource(R.string.availability_available) AvailabilityStatus.Unavailable -> stringResource(R.string.availability_on_loan) @@ -88,8 +58,8 @@ fun AvailabilityChip( Surface( modifier = modifier, shape = RoundedCornerShape(50), // fully rounded pill - color = container, - contentColor = onContainer, + color = colors.surface, + contentColor = colors.ink, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -99,13 +69,13 @@ fun AvailabilityChip( modifier = Modifier .size(8.dp) .clip(CircleShape) - .background(onContainer), + .background(dot), ) Spacer(modifier = Modifier.width(4.dp)) Text( text = chipLabel, style = MaterialTheme.typography.labelMedium, - color = onContainer, + color = colors.ink, ) } } diff --git a/app/src/main/java/com/pinakes/app/ui/components/BookCard.kt b/app/src/main/java/com/pinakes/app/ui/components/BookCard.kt index c7753a9..d05e702 100644 --- a/app/src/main/java/com/pinakes/app/ui/components/BookCard.kt +++ b/app/src/main/java/com/pinakes/app/ui/components/BookCard.kt @@ -1,221 +1,185 @@ package com.pinakes.app.ui.components import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.MenuBook -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember +import androidx.compose.material.icons.outlined.Album +import androidx.compose.material.icons.outlined.Movie +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import coil.compose.SubcomposeAsyncImage -import com.pinakes.app.ui.theme.Spacing +import coil.request.ImageRequest +import androidx.compose.ui.platform.LocalContext +import android.graphics.drawable.BitmapDrawable +import androidx.core.graphics.scale +import com.pinakes.app.R +import com.pinakes.app.ui.theme.CardStyle +import com.pinakes.app.ui.theme.Fraunces +import com.pinakes.app.ui.theme.LocalPinakesColors +/** A complete cover, including unusually tall books and square record sleeves. */ @Composable -fun BookCard( - title: String, - author: String, - coverUrl: String?, - status: AvailabilityStatus = AvailabilityStatus.Available, - year: String? = null, - publisher: String? = null, - onClick: () -> Unit = {}, - modifier: Modifier = Modifier, -) { - val interactionSource = remember { MutableInteractionSource() } - val pressed by interactionSource.collectIsPressedAsState() - val scale by animateFloatAsState( - targetValue = if (pressed) 0.97f else 1f, - animationSpec = spring(dampingRatio = 0.55f, stiffness = 700f), - label = "bookcard_scale", - ) - ElevatedCard( - onClick = onClick, - interactionSource = interactionSource, - modifier = modifier - .fillMaxWidth() - .scale(scale), - elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp, pressedElevation = 6.dp), - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - ), - shape = MaterialTheme.shapes.medium, - ) { - Row(modifier = Modifier.padding(Spacing.md)) { - // Cover thumbnail - BookCoverImage( - coverUrl = coverUrl, - contentDescription = title, - modifier = Modifier - .width(64.dp) - .height(96.dp) - .clip(MaterialTheme.shapes.medium), - ) - - Spacer(modifier = Modifier.width(Spacing.md)) - - // Content column - Column( - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - verticalArrangement = Arrangement.SpaceBetween, - ) { - Column { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurface, - ) - if (author.isNotBlank()) { - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = author, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - // year · publisher meta row - val meta = listOfNotNull(year, publisher).joinToString(" · ") - if (meta.isNotBlank()) { - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = meta, - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) +fun BookCover(title: String, coverUrl: String?, modifier: Modifier = Modifier, compact: Boolean = false, onTone: ((Color) -> Unit)? = null, + author: String? = null, publisher: String? = null) { + val colors = LocalPinakesColors.current + val context = LocalContext.current + val imageUrl = bookCoverImageUrl(coverUrl) + val image = remember(imageUrl, onTone != null) { ImageRequest.Builder(context).data(imageUrl).allowHardware(onTone == null).build() } + val shape = RoundedCornerShape(topStart = 2.dp, topEnd = 5.dp, bottomEnd = 5.dp, bottomStart = 2.dp) + Box(modifier.semantics { contentDescription = title } + .shadow(if (compact) 3.dp else 12.dp, shape, ambientColor = Color(0xFF320F23), spotColor = Color(0xFF320F23))) { + // Paper block, peeking from the fore-edge of the book. + Canvas(Modifier.fillMaxSize().padding(top = 3.dp, bottom = 3.dp)) { + drawRect(Color(0xFFF6F2EE)) + val step = 2.dp.toPx() + var y = 0f + while (y < size.height) { + drawLine(Color(0xFFE4DDD6), Offset(size.width - 6.dp.toPx(), y), Offset(size.width, y), 1.dp.toPx()) + y += step + } + } + Box(Modifier.fillMaxSize().padding(end = 4.dp).clip(shape).background(colors.coverBlank)) { + val blank: @Composable () -> Unit = { + BookPlaceholder(title, author, publisher, compact, onTone) + } + SubcomposeAsyncImage(model = image, contentDescription = null, modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, loading = { blank() }, error = { blank() }, + onSuccess = { success -> + if (onTone != null) (success.result.drawable as? BitmapDrawable)?.bitmap?.let { bitmap -> + val sample = bitmap.scale(12, 12) + var red = 0L; var green = 0L; var blue = 0L; var count = 0 + for (y in 0 until 12) for (x in 0 until 12) { + val pixel = sample.getPixel(x, y) + if (android.graphics.Color.alpha(pixel) > 128) { + red += android.graphics.Color.red(pixel); green += android.graphics.Color.green(pixel) + blue += android.graphics.Color.blue(pixel); count++ + } + } + if (count > 0) onTone(Color(red.toFloat() / count / 255f, green.toFloat() / count / 255f, blue.toFloat() / count / 255f)) + if (sample !== bitmap) sample.recycle() } - } - - Spacer(modifier = Modifier.height(Spacing.sm)) - - AvailabilityChip(status = status) + }) + // Spine and gloss have no interaction or accessibility semantics. + Box(Modifier.fillMaxHeight().width(if (compact) 5.dp else 14.dp).background(Brush.horizontalGradient( + listOf(Color.Black.copy(alpha = .28f), Color.White.copy(alpha = .28f), Color.Transparent)))) + Canvas(Modifier.fillMaxSize()) { + drawRect(Brush.linearGradient(listOf(Color.White.copy(alpha = .12f), Color.Transparent, Color.Black.copy(alpha = .06f)))) + drawRect(Color.White.copy(alpha = .15f), style = androidx.compose.ui.graphics.drawscope.Stroke(1.dp.toPx())) } } } } -/** Grid variant: cover-forward, title below. */ +/** Compact catalog row. Account lists keep their separate MediaRow actions. */ @Composable -fun BookCardGrid( - title: String, - author: String, - coverUrl: String?, - status: AvailabilityStatus = AvailabilityStatus.Available, - onClick: () -> Unit = {}, - modifier: Modifier = Modifier, +fun BookCard( + title: String, author: String, coverUrl: String?, status: AvailabilityStatus = AvailabilityStatus.Available, + year: String? = null, publisher: String? = null, onClick: () -> Unit = {}, modifier: Modifier = Modifier, + subtitle: String? = null, mediaType: String? = null, ) { - ElevatedCard( - onClick = onClick, - modifier = modifier, - elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp, pressedElevation = 3.dp), - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - ), - shape = MaterialTheme.shapes.medium, - ) { - Column { - BookCoverImage( - coverUrl = coverUrl, - contentDescription = title, - modifier = Modifier - .fillMaxWidth() - .height(160.dp), - ) - Column(modifier = Modifier.padding(Spacing.sm)) { - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - if (author.isNotBlank()) { - Text( - text = author, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Spacer(modifier = Modifier.height(Spacing.xs)) - AvailabilityChip(status = status) + Row(modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { + BookCover(title, coverUrl, Modifier.width(64.dp).height(96.dp), compact = true, author = author, publisher = publisher) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + BookTitle(title) + if (!subtitle.isNullOrBlank()) Text(subtitle, style = MaterialTheme.typography.bodySmall, fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + AuthorLabel(author) + val meta = listOfNotNull(year?.takeIf { it.isNotBlank() }, publisher?.takeIf { it.isNotBlank() }).joinToString(" · ") + if (meta.isNotEmpty()) Text(meta, style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AvailabilityChip(status) + MediaTypeIcon(mediaType) } } } } +/** The classic web card: book, status pill, title and metadata, with no outer card. */ @Composable -private fun BookCoverImage( - coverUrl: String?, - contentDescription: String, - modifier: Modifier = Modifier, +fun BookCardGrid( + title: String, author: String, coverUrl: String?, status: AvailabilityStatus = AvailabilityStatus.Available, + onClick: () -> Unit = {}, modifier: Modifier = Modifier, subtitle: String? = null, + publisher: String? = null, year: String? = null, mediaType: String? = null, ) { - SubcomposeAsyncImage( - model = coverUrl, - contentDescription = contentDescription, - modifier = modifier, - contentScale = ContentScale.Crop, - loading = { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.MenuBook, - contentDescription = null, - tint = MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.height(32.dp), - ) - } - }, - error = { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.MenuBook, - contentDescription = null, - tint = MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.height(32.dp), - ) + val colors = LocalPinakesColors.current + var coverTone by remember(coverUrl, colors.accent) { mutableStateOf(colors.accent) } + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + val lift by animateFloatAsState(if (pressed) -4f else 0f, + animationSpec = tween(300, easing = CubicBezierEasing(.2f, .8f, .2f, 1f)), label = "cover_lift") + Column(modifier.clickable(interactionSource = interaction, indication = null, onClick = onClick)) { + Box(Modifier.fillMaxWidth().then(if (colors.cardStyle == CardStyle.Tinted) + Modifier.background(com.pinakes.app.ui.theme.mix(coverTone, colors.surface, .16f), RoundedCornerShape(16.dp)).padding(18.dp) else Modifier)) { + Box(Modifier.fillMaxWidth().aspectRatio(2f / 3f).graphicsLayer { translationY = lift.dp.toPx() }) { + BookCover(title, coverUrl, Modifier.fillMaxSize(), onTone = if (colors.cardStyle == CardStyle.Tinted) ({ coverTone = it }) else null, + author = author, publisher = publisher) + AvailabilityChip(status, modifier = Modifier.align(Alignment.TopStart).padding(8.dp)) + Box(Modifier.align(Alignment.BottomEnd).padding(10.dp)) { MediaTypeIcon(mediaType, onCover = true) } } - }, - ) + } + Spacer(Modifier.height(14.dp)) + BookTitle(title) + if (!subtitle.isNullOrBlank()) Text(subtitle, style = MaterialTheme.typography.bodySmall, + fontStyle = FontStyle.Italic, color = colors.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) + Spacer(Modifier.height(4.dp)) + AuthorLabel(author) + val meta = listOfNotNull(year?.takeIf { it.isNotBlank() }, publisher?.takeIf { it.isNotBlank() }).joinToString(" · ") + Text(meta, style = MaterialTheme.typography.labelMedium, color = colors.muted, maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier.heightIn(min = 18.dp)) + Row(Modifier.padding(top = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { + Icon(Icons.Outlined.Visibility, null, Modifier.size(14.dp), tint = colors.accentText) + Text(stringResource(R.string.restyle_details), style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, color = colors.accentText) + } + } +} + +@Composable +private fun BookTitle(title: String) = Text(title, fontFamily = Fraunces, fontWeight = FontWeight.Medium, + fontSize = 17.sp, lineHeight = 21.sp, color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis) + +@Composable +private fun AuthorLabel(author: String) = Text(author.ifBlank { stringResource(R.string.restyle_unknown_author) }, + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + +@Composable +fun MediaTypeIcon(mediaType: String?, onCover: Boolean = false) { + val icon = when (mediaType?.lowercase()) { + "cd", "vinile", "vinyl", "audio", "audiocassetta" -> Icons.Outlined.Album + "dvd", "blu-ray", "bluray", "video", "vhs" -> Icons.Outlined.Movie + else -> return + } + Surface(shape = RoundedCornerShape(50), color = if (onCover) Color.White else MaterialTheme.colorScheme.surface) { + Icon(icon, contentDescription = mediaType, tint = if (onCover) Color(0xFF1B1720) else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(5.dp).size(18.dp)) + } } diff --git a/app/src/main/java/com/pinakes/app/ui/components/BookPlaceholder.kt b/app/src/main/java/com/pinakes/app/ui/components/BookPlaceholder.kt new file mode 100644 index 0000000..f388e08 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/components/BookPlaceholder.kt @@ -0,0 +1,82 @@ +package com.pinakes.app.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.pinakes.app.ui.theme.Fraunces +import java.util.Locale + +val BookPlaceholderPapers = listOf(Color(0xFF3C2D40), Color(0xFF2F413A), Color(0xFF2E3D4E), Color(0xFF52322B)) +val BookPlaceholderInk = Color(0xFFF6EDDE) + +/** The browser uses the same UTF-8 key, so a title keeps its binding colour. */ +fun bookPlaceholderTone(title: String): Int = title.trim().toByteArray(Charsets.UTF_8).fold(0) { tone, byte -> + (tone * 31 + (byte.toInt() and 255)) % BookPlaceholderPapers.size +} + +/** Legacy API records can still point at the server's generic placeholder. */ +fun bookCoverImageUrl(url: String?): String? = url?.trim()?.takeIf { value -> + value.isNotEmpty() && value.substringBefore('?').substringBefore('#').substringAfterLast('/') + .lowercase(Locale.ROOT) !in setOf("placeholder.jpg", "placeholder.png", "placeholder.svg") +} + +/** A typeset cloth binding, drawn without image downloads or generated bitmaps. */ +@Composable +internal fun BookPlaceholder(title: String, author: String?, publisher: String?, compact: Boolean, onTone: ((Color) -> Unit)?) { + val paper = remember(title) { BookPlaceholderPapers[bookPlaceholderTone(title)] } + LaunchedEffect(paper, onTone != null) { onTone?.invoke(paper) } + BoxWithConstraints(Modifier.fillMaxSize().background(paper).clearAndSetSemantics { }) { + val width = maxWidth.value + val small = compact || width < 112f + Canvas(Modifier.fillMaxSize()) { + val inset = size.width * .07f + drawRect(BookPlaceholderInk.copy(alpha = .22f), Offset(inset, inset), + Size(size.width - inset * 2, size.height - inset * 2), style = Stroke(.7.dp.toPx())) + for (i in 1..20) { + val x = size.width * i / 21 + drawLine(BookPlaceholderInk.copy(alpha = .025f), Offset(x, 0f), Offset(x, size.height), .5.dp.toPx()) + } + val diameter = size.width * .9f + drawArc(BookPlaceholderInk.copy(alpha = .13f), 180f, 180f, false, + Offset(size.width * .33f, size.height - diameter * .33f), Size(diameter, diameter), style = Stroke(1.dp.toPx())) + } + Column(Modifier.fillMaxSize().padding(start = (width * .12f).dp, end = (width * .10f).dp, + top = (width * if (small) .18f else .27f).dp, bottom = (width * .12f).dp)) { + if (!small && !author.isNullOrBlank()) { + Text(author, color = BookPlaceholderInk, fontSize = (width * .065f).coerceIn(9f, 18f).sp, + lineHeight = (width * .085f).coerceIn(12f, 23f).sp, letterSpacing = .45.sp, + maxLines = 2, overflow = TextOverflow.Ellipsis) + Spacer(Modifier.height((width * .055f).dp)) + } + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + Text(title, fontFamily = Fraunces, fontWeight = FontWeight.Medium, + color = BookPlaceholderInk, fontSize = (width * .12f).coerceIn(8f, 38f).sp, + lineHeight = (width * .14f).coerceIn(10f, 44f).sp, + maxLines = if (small) 4 else 6, overflow = TextOverflow.Ellipsis) + } + if (!small) { + Spacer(Modifier.height((width * .055f).dp)) + Canvas(Modifier.fillMaxWidth().height(6.dp)) { + drawLine(BookPlaceholderInk.copy(alpha = .6f), Offset.Zero, Offset(size.width * .24f, 0f), 1.dp.toPx()) + } + if (!publisher.isNullOrBlank()) Text(publisher, color = BookPlaceholderInk, + fontSize = (width * .055f).coerceIn(8f, 15f).sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/components/BookQuickFacts.kt b/app/src/main/java/com/pinakes/app/ui/components/BookQuickFacts.kt new file mode 100644 index 0000000..9426292 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/components/BookQuickFacts.kt @@ -0,0 +1,34 @@ +package com.pinakes.app.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import java.util.Locale + +@Composable +fun BookQuickFacts(facts: List>) { + if (facts.isEmpty()) return + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + facts.chunked(2).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + row.forEach { (label, value) -> + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(label.uppercase(Locale.getDefault()), style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, + maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + if (row.size == 1) Spacer(Modifier.weight(1f)) + } + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/components/Buttons.kt b/app/src/main/java/com/pinakes/app/ui/components/Buttons.kt index 68a1654..5dd0ca5 100644 --- a/app/src/main/java/com/pinakes/app/ui/components/Buttons.kt +++ b/app/src/main/java/com/pinakes/app/ui/components/Buttons.kt @@ -6,6 +6,8 @@ import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalContentColor +import com.pinakes.app.ui.theme.LocalPinakesColors import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator @@ -20,10 +22,9 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import com.pinakes.app.ui.theme.Spacing -private val StadiumShape = RoundedCornerShape(50) /** - * Primary filled button (stadium shape, 48dp min height). + * Primary filled button (12dp radius, 48dp minimum height). * * Shows a spinner and disables interaction while [loading] is true. */ @@ -35,22 +36,28 @@ fun PrimaryButton( loading: Boolean = false, enabled: Boolean = true, leadingIcon: ImageVector? = null, + dark: Boolean = false, ) { + val tokens = LocalPinakesColors.current + val fill = if (dark) tokens.dark else tokens.button + val text = if (dark) androidx.compose.ui.graphics.Color.White else tokens.buttonText Button( onClick = onClick, modifier = modifier.defaultMinSize(minHeight = 48.dp), enabled = enabled && !loading, - shape = StadiumShape, + shape = RoundedCornerShape(12.dp), contentPadding = PaddingValues(horizontal = Spacing.xl, vertical = 12.dp), colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, + containerColor = fill, + contentColor = text, + disabledContainerColor = if (loading) fill else MaterialTheme.colorScheme.surfaceVariant, + disabledContentColor = if (loading) text else MaterialTheme.colorScheme.onSurfaceVariant, ), ) { if (loading) { CircularProgressIndicator( modifier = Modifier.size(18.dp), - color = MaterialTheme.colorScheme.onPrimary, + color = LocalContentColor.current, strokeWidth = 2.dp, ) } else { @@ -80,10 +87,11 @@ fun SecondaryButton( onClick = onClick, modifier = modifier.defaultMinSize(minHeight = 48.dp), enabled = enabled, - shape = StadiumShape, + shape = RoundedCornerShape(12.dp), contentPadding = PaddingValues(horizontal = Spacing.xl, vertical = 12.dp), colors = ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.primary, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, ), ) { if (leadingIcon != null) { diff --git a/app/src/main/java/com/pinakes/app/ui/components/CatalogViewToggle.kt b/app/src/main/java/com/pinakes/app/ui/components/CatalogViewToggle.kt new file mode 100644 index 0000000..58a3db5 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/components/CatalogViewToggle.kt @@ -0,0 +1,31 @@ +package com.pinakes.app.ui.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.GridView +import androidx.compose.material.icons.automirrored.outlined.ViewList +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import com.pinakes.app.R + +@Composable +fun CatalogViewToggle(grid: Boolean, onChange: (Boolean) -> Unit) { + Surface(shape = RoundedCornerShape(50), color = MaterialTheme.colorScheme.surfaceVariant) { + Row { + listOf(true, false).forEach { value -> + IconButton(onClick = { onChange(value) }, modifier = Modifier.semantics { selected = grid == value }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = if (grid == value) MaterialTheme.colorScheme.surface else androidx.compose.ui.graphics.Color.Transparent, + contentColor = MaterialTheme.colorScheme.onSurface)) { + Icon(if (value) Icons.Outlined.GridView else Icons.AutoMirrored.Outlined.ViewList, + contentDescription = stringResource(if (value) R.string.restyle_grid else R.string.restyle_list)) + } + } + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/components/CitationButton.kt b/app/src/main/java/com/pinakes/app/ui/components/CitationButton.kt new file mode 100644 index 0000000..83b9388 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/components/CitationButton.kt @@ -0,0 +1,52 @@ +package com.pinakes.app.ui.components + +import android.widget.Toast +import android.content.ClipData +import android.content.Context +import android.content.ClipboardManager +import android.os.Build +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import com.pinakes.app.R +import com.pinakes.app.data.model.ArticleCitation +import com.pinakes.app.ui.screens.bookclub.openWeb +import com.pinakes.app.ui.theme.Spacing + +/** Server-formatted references keep the website and app's bibliographic rules identical. */ +@Composable +fun CitationButton(citations: List, risUrl: String? = null, marcXmlUrl: String? = null) { + var open by remember { mutableStateOf(false) } + var selected by remember(citations) { mutableStateOf(citations.firstOrNull()?.key) } + val context = LocalContext.current + val clipboard = remember(context) { context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager } + val copied = stringResource(R.string.citation_copied) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + if (citations.isNotEmpty()) TextButton({ open = true }) { Text(stringResource(R.string.citation_cite)) } + risUrl?.let { url -> TextButton({ openWeb(context, url) }) { Text(stringResource(R.string.citation_ris)) } } + marcXmlUrl?.let { url -> TextButton({ openWeb(context, url) }) { Text("MARCXML") } } + } + if (open) AlertDialog(onDismissRequest = { open = false }, title = { Text(stringResource(R.string.citation_cite)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(Spacing.md)) { + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + citations.forEach { citation -> FilterChip(selected == citation.key, { selected = citation.key }, { Text(citation.label) }) } + } + citations.firstOrNull { it.key == selected }?.let { citation -> + SelectionContainer { HtmlText(citation.html ?: android.text.TextUtils.htmlEncode(citation.text), style = MaterialTheme.typography.bodyLarge) } + TextButton({ clipboard.setPrimaryClip(citation.html?.let { ClipData.newHtmlText(citation.label, citation.text, it) } ?: ClipData.newPlainText(citation.label, citation.text)); if (Build.VERSION.SDK_INT < 33) Toast.makeText(context, copied, Toast.LENGTH_SHORT).show() }) { + Text(stringResource(R.string.citation_copy)) + } + } + } + }, confirmButton = { TextButton({ open = false }) { Text(stringResource(R.string.cover_viewer_close)) } }) +} diff --git a/app/src/main/java/com/pinakes/app/ui/components/DigitalFileCard.kt b/app/src/main/java/com/pinakes/app/ui/components/DigitalFileCard.kt new file mode 100644 index 0000000..9132776 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/components/DigitalFileCard.kt @@ -0,0 +1,37 @@ +package com.pinakes.app.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.pinakes.app.ui.theme.LocalPinakesColors + +@Composable +fun DigitalFileCard(type: String, name: String, kind: String, content: @Composable ColumnScope.() -> Unit) { + val colors = LocalPinakesColors.current + Surface(shape = RoundedCornerShape(16.dp), color = colors.surface, border = BorderStroke(1.dp, colors.line), + modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Surface(shape = RoundedCornerShape(6.dp), color = colors.accentSoft, modifier = Modifier.width(44.dp).height(48.dp)) { + Box(contentAlignment = androidx.compose.ui.Alignment.Center) { + Text(type, style = MaterialTheme.typography.labelSmall, color = colors.accentStrong) + } + } + Column(Modifier.weight(1f)) { + Text(name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, + maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(kind, style = MaterialTheme.typography.bodySmall, color = colors.muted) + } + } + content() + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/components/RowItems.kt b/app/src/main/java/com/pinakes/app/ui/components/RowItems.kt index 165ad8e..4f87ad5 100644 --- a/app/src/main/java/com/pinakes/app/ui/components/RowItems.kt +++ b/app/src/main/java/com/pinakes/app/ui/components/RowItems.kt @@ -58,43 +58,12 @@ fun MediaRow( .padding(Spacing.md), verticalAlignment = Alignment.CenterVertically, ) { - Box( - Modifier - .width(48.dp) - .height(72.dp) - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center, - ) { - if (coverUrl != null) { - SubcomposeAsyncImage( - model = coverUrl, - contentDescription = title, - contentScale = ContentScale.Crop, - modifier = Modifier.size(width = 48.dp, height = 72.dp), - error = { - Icon( - Icons.AutoMirrored.Outlined.MenuBook, - contentDescription = null, - tint = MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.size(20.dp), - ) - }, - ) - } else { - Icon( - Icons.AutoMirrored.Outlined.MenuBook, - contentDescription = null, - tint = MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.size(20.dp), - ) - } - } + BookCover(title, coverUrl, Modifier.width(48.dp).height(72.dp), compact = true) Spacer(Modifier.width(Spacing.md)) Column(Modifier.weight(1f)) { Text( title, - style = MaterialTheme.typography.titleSmall, + style = MaterialTheme.typography.titleSmall.copy(fontFamily = com.pinakes.app.ui.theme.Fraunces), color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis, diff --git a/app/src/main/java/com/pinakes/app/ui/components/TextFields.kt b/app/src/main/java/com/pinakes/app/ui/components/TextFields.kt index d58e25e..fa037cc 100644 --- a/app/src/main/java/com/pinakes/app/ui/components/TextFields.kt +++ b/app/src/main/java/com/pinakes/app/ui/components/TextFields.kt @@ -1,6 +1,11 @@ package com.pinakes.app.ui.components +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.unit.dp +import com.pinakes.app.ui.theme.LocalPinakesColors import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons @@ -22,8 +27,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.input.ImeAction import com.pinakes.app.R import androidx.compose.ui.text.input.KeyboardType @@ -51,8 +58,14 @@ fun PinakesTextField( keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, singleLine: Boolean = true, + enabled: Boolean = true, + readOnly: Boolean = false, ) { + val interaction = remember { MutableInteractionSource() } OutlinedTextField( + interactionSource = interaction, + enabled = enabled, + readOnly = readOnly, value = value, onValueChange = { if (it.length <= maxLength) onValueChange(it) }, modifier = modifier, @@ -74,8 +87,10 @@ fun PinakesTextField( keyboardActions = keyboardActions, colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline, - focusedLabelColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = Color.Transparent, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + focusedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, ), ) } @@ -93,11 +108,14 @@ fun SearchField( onSearch: () -> Unit = {}, ) { val placeholderText = placeholder ?: stringResource(R.string.search_field_placeholder) + val interaction = remember { MutableInteractionSource() } + val focused by interaction.collectIsFocusedAsState() TextField( + interactionSource = interaction, value = query, onValueChange = onQueryChange, - modifier = modifier, - placeholder = { Text(placeholderText, style = MaterialTheme.typography.bodyMedium) }, + modifier = modifier.then(if (focused) Modifier.border(3.dp, LocalPinakesColors.current.accentLine, FullRoundedShape) else Modifier), + placeholder = { Text(placeholderText, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) }, leadingIcon = { Icon( imageVector = Icons.Outlined.Search, @@ -121,8 +139,8 @@ fun SearchField( keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardActions = KeyboardActions(onSearch = { onSearch() }), colors = TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, focusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, ), @@ -182,7 +200,9 @@ private fun PinakesTextField( keyboardActions: KeyboardActions, visualTransformation: VisualTransformation, ) { + val interaction = remember { MutableInteractionSource() } OutlinedTextField( + interactionSource = interaction, value = value, onValueChange = onValueChange, modifier = modifier, @@ -199,8 +219,10 @@ private fun PinakesTextField( keyboardActions = keyboardActions, colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline, - focusedLabelColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = Color.Transparent, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + focusedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, ), ) } diff --git a/app/src/main/java/com/pinakes/app/ui/components/TopBar.kt b/app/src/main/java/com/pinakes/app/ui/components/TopBar.kt index 03a5be5..d6ec540 100644 --- a/app/src/main/java/com/pinakes/app/ui/components/TopBar.kt +++ b/app/src/main/java/com/pinakes/app/ui/components/TopBar.kt @@ -77,7 +77,7 @@ fun PinakesListTopBar( title = { Text( text = title, - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.headlineMedium, ) }, actions = { @@ -98,7 +98,7 @@ fun PinakesListTopBar( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, + containerColor = MaterialTheme.colorScheme.background, titleContentColor = MaterialTheme.colorScheme.onSurface, actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, ), diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/MainScaffold.kt b/app/src/main/java/com/pinakes/app/ui/navigation/MainScaffold.kt index 354c20e..c3da749 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/MainScaffold.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/MainScaffold.kt @@ -28,7 +28,7 @@ import com.pinakes.app.ui.screens.search.SearchScreen import com.pinakes.app.ui.screens.wishlist.WishlistScreen /** - * Hosts the four bottom-nav destinations. Each tab keeps its own composable; switching tabs is + * Hosts the five bottom-nav destinations. Each tab keeps its own composable; switching tabs is * a simple state change (the per-tab ViewModels are scoped to their composable instances). */ @OptIn(ExperimentalMaterial3Api::class) @@ -41,10 +41,17 @@ fun MainScaffold( onOpenMyReviews: () -> Unit, onOpenBookClub: () -> Unit, onOpenPeriodicals: () -> Unit, + onOpenArticles: () -> Unit, + onOpenDesiderata: () -> Unit, + onOpenArchives: () -> Unit, + onOpenArticle: (Int) -> Unit, + onOpenWanted: (Int) -> Unit, + onOpenArchive: (Int) -> Unit, ) { val app: AppViewModel = hiltViewModel() val features by app.features.collectAsStateWithLifecycle() + var pendingCatalogQuery by rememberSaveable { mutableStateOf(null) } var tab by rememberSaveable { mutableStateOf(PinakesTab.Home) } // If the active tab is gated off (e.g. server switched to CATALOGUE-ONLY MODE while the @@ -87,9 +94,14 @@ fun MainScaffold( when (tab) { PinakesTab.Home -> HomeScreen( onBookClick = onOpenBook, + onOpenArticles = if (features.periodicalsAvailable) onOpenArticles else null, + onOpenPeriodicals = if (features.periodicalsAvailable) onOpenPeriodicals else null, + onOpenDesiderata = if (features.desiderataAvailable) onOpenDesiderata else null, + onOpenArchives = if (features.archivesAvailable) onOpenArchives else null, onBrowseCatalog = { tab = PinakesTab.Catalog }, + onSearch = { query -> pendingCatalogQuery = query; tab = PinakesTab.Catalog }, ) - PinakesTab.Catalog -> SearchScreen(onBookClick = onOpenBook) + PinakesTab.Catalog -> SearchScreen(onBookClick = onOpenBook, onArticleClick = onOpenArticle, onWantedClick = onOpenWanted, onArchiveClick = onOpenArchive, initialQuery = pendingCatalogQuery, onQueryConsumed = { pendingCatalogQuery = null }) PinakesTab.Library -> LibraryScreen(onBookClick = onOpenBook) PinakesTab.Wishlist -> WishlistScreen(onBookClick = onOpenBook) PinakesTab.Profile -> ProfileScreen( @@ -99,6 +111,9 @@ fun MainScaffold( onOpenMyReviews = onOpenMyReviews, onOpenBookClub = onOpenBookClub, onOpenPeriodicals = onOpenPeriodicals, + onOpenArticles = onOpenArticles, + onOpenDesiderata = onOpenDesiderata, + onOpenArchives = onOpenArchives, ) } } diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt b/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt index 1d7a556..e3b64aa 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt @@ -6,6 +6,8 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -20,6 +22,7 @@ import com.pinakes.app.data.store.AuthState import com.pinakes.app.ui.common.AppViewModel import com.pinakes.app.ui.screens.bookclub.BookClubHomeScreen import com.pinakes.app.ui.screens.bookclub.ClubDetailScreen +import com.pinakes.app.ui.screens.collections.* import com.pinakes.app.ui.screens.contact.ContactScreen import com.pinakes.app.ui.screens.detail.BookDetailScreen import com.pinakes.app.ui.screens.login.ForgotPasswordScreen @@ -39,6 +42,7 @@ import com.pinakes.app.ui.screens.reviews.MyReviewsScreen * Root navigation. The high-level [AuthState] decides the start destination; within the * authenticated graph, [MainScaffold] hosts the bottom-nav tabs and nested routes. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun PinakesNavHost(navController: NavHostController = rememberNavController()) { val app: AppViewModel = hiltViewModel() @@ -105,6 +109,12 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { onOpenMyReviews = { navController.navigate(Routes.MY_REVIEWS) }, onOpenBookClub = { navController.navigate(Routes.BOOK_CLUB) }, onOpenPeriodicals = { navController.navigate(Routes.PERIODICALS) }, + onOpenArticles = { navController.navigate(Routes.standaloneArticles()) }, + onOpenDesiderata = { navController.navigate(Routes.DESIDERATA) }, + onOpenArchives = { navController.navigate(Routes.archives()) }, + onOpenArticle = { navController.navigate(Routes.standaloneArticle(it)) }, + onOpenWanted = { navController.navigate(Routes.wantedBook(it)) }, + onOpenArchive = { navController.navigate(Routes.archive(it)) }, ) } @@ -121,7 +131,7 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { enterTransition = slideIn, popExitTransition = slideOut, ) { - BookDetailScreen(onNavigateUp = { navController.popBackStack() }) + BookDetailScreen(onNavigateUp = { navController.popBackStack() }, onFindWorks = { name, id -> navController.navigate(Routes.authorWorks(name, id)) }) } composable( @@ -173,7 +183,10 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { composable( Routes.STANDALONE_ARTICLES, - arguments = listOf(navArgument(Routes.ARG_PERIODICAL_ID) { type = NavType.IntType; defaultValue = 0 }), + arguments = listOf(navArgument(Routes.ARG_PERIODICAL_ID) { type = NavType.IntType; defaultValue = 0 }, + navArgument(Routes.ARG_PERIODICAL_ISSUE_ID) { type = NavType.IntType; defaultValue = 0 }, + navArgument("container") { defaultValue = "" }, navArgument("keyword") { defaultValue = "" }, + navArgument("genreId") { type = NavType.IntType; defaultValue = 0 }, navArgument("q") { defaultValue = "" }), enterTransition = slideIn, popExitTransition = slideOut, ) { StandaloneArticlesScreen( @@ -190,9 +203,35 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { onNavigateUp = { navController.popBackStack() }, onOpenPeriodical = { id -> navController.navigate(Routes.periodicalDetail(id)) }, onOpenIssue = { id -> navController.navigate(Routes.periodicalIssue(id)) }, + onFindWorks = { name, id -> navController.navigate(Routes.authorWorks(name, id)) }, + onFindArticles = { container, keyword, genre -> navController.navigate(Routes.standaloneArticles(container = container, keyword = keyword, genreId = genre)) }, ) } + composable(Routes.AUTHOR_WORKS, arguments = listOf(navArgument("author") { defaultValue = "" }, navArgument("authorId") { type = NavType.IntType; defaultValue = 0 })) { entry -> + androidx.compose.material3.Scaffold(topBar = { com.pinakes.app.ui.components.PinakesTopBar(entry.arguments?.getString("author").orEmpty(), onNavigateUp = { navController.popBackStack() }) }) { padding -> + androidx.compose.foundation.layout.Box(androidx.compose.ui.Modifier.padding(padding)) { + com.pinakes.app.ui.screens.search.SearchScreen(onBookClick = { navController.navigate(Routes.bookDetail(it)) }, + onArticleClick = { navController.navigate(Routes.standaloneArticle(it)) }, initialAuthor = entry.arguments?.getString("author"), initialAuthorId = entry.arguments?.getInt("authorId")) + } + } + } + composable(Routes.DESIDERATA) { + DesiderataScreen({ navController.popBackStack() }, { navController.navigate(Routes.wantedBook(it)) }, { navController.navigate(Routes.donation(it)) }) + } + composable(Routes.WANTED_BOOK, arguments = listOf(navArgument(Routes.ARG_WANTED_ID) { type = NavType.IntType })) { + WantedBookScreen({ navController.popBackStack() }, { navController.navigate(Routes.donation(it)) }) + } + composable(Routes.DONATION, arguments = listOf(navArgument(Routes.ARG_WANTED_ID) { type = NavType.IntType; defaultValue = 0 })) { + DonationScreen({ navController.popBackStack() }, { if (!navController.popBackStack(Routes.DESIDERATA, false)) navController.navigate(Routes.DESIDERATA) { popUpTo(Routes.DONATION) { inclusive = true } } }) + } + composable(Routes.ARCHIVES, arguments = listOf(navArgument(Routes.ARG_ARCHIVE_PARENT) { type = NavType.IntType; defaultValue = 0 })) { + ArchivesScreen({ navController.popBackStack() }, { navController.navigate(Routes.archive(it)) }) + } + composable(Routes.ARCHIVE, arguments = listOf(navArgument(Routes.ARG_ARCHIVE_ID) { type = NavType.IntType })) { + ArchiveScreen({ navController.popBackStack() }, { navController.navigate(Routes.archive(it)) }, { navController.navigate(Routes.archives(it)) }) + } + // ---- Periodicals / Emeroteca (optional plugin) ---- composable( Routes.PERIODICALS, @@ -242,7 +281,7 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { enterTransition = slideIn, popExitTransition = slideOut, ) { - IssueDetailScreen(onNavigateUp = { navController.popBackStack() }) + IssueDetailScreen(onNavigateUp = { navController.popBackStack() }, onOpenArticle = { navController.navigate(Routes.standaloneArticle(it)) }, onFindArticles = { navController.navigate(Routes.standaloneArticles(issueId = it)) }) } } } diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt b/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt index 4a9cacd..b613e4c 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt @@ -37,8 +37,11 @@ object Routes { fun periodicalDetail(id: Int): String = "periodicals/$id" const val ARG_PERIODICAL_ID = "periodicalId" - const val STANDALONE_ARTICLES = "periodicals/articles?periodicalId={periodicalId}" - fun standaloneArticles(mastheadId: Int = 0): String = "periodicals/articles?periodicalId=$mastheadId" + const val STANDALONE_ARTICLES = "periodicals/articles?periodicalId={periodicalId}&issueId={issueId}&container={container}&keyword={keyword}&genreId={genreId}&q={q}" + fun standaloneArticles(mastheadId: Int = 0, issueId: Int = 0, container: String = "", keyword: String = "", genreId: Int = 0, query: String = ""): String = + "periodicals/articles?periodicalId=$mastheadId&issueId=$issueId&container=${Uri.encode(container)}&keyword=${Uri.encode(keyword)}&genreId=$genreId&q=${Uri.encode(query)}" + const val AUTHOR_WORKS = "works?author={author}&authorId={authorId}" + fun authorWorks(author: String, id: Int? = null) = "works?author=${Uri.encode(author)}&authorId=${id ?: 0}" const val STANDALONE_ARTICLE = "periodicals/articles/{articleId}" const val ARG_ARTICLE_ID = "articleId" fun standaloneArticle(id: Int): String = "periodicals/articles/$id" @@ -56,4 +59,16 @@ object Routes { /** Graph hosting the bottom-nav + nested authed screens. */ const val MAIN_GRAPH = "main" + const val DESIDERATA = "desiderata" + const val WANTED_BOOK = "desiderata/{wantedId}" + const val ARG_WANTED_ID = "wantedId" + fun wantedBook(id: Int) = "desiderata/$id" + const val DONATION = "donation?bookId={wantedId}" + fun donation(id: Int = 0) = "donation?bookId=$id" + const val ARCHIVES = "archives?parentId={archiveParentId}" + const val ARG_ARCHIVE_PARENT = "archiveParentId" + fun archives(parentId: Int = 0) = "archives?parentId=$parentId" + const val ARCHIVE = "archives/{archiveId}" + const val ARG_ARCHIVE_ID = "archiveId" + fun archive(id: Int) = "archives/$id" } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/bookclub/ClubDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/bookclub/ClubDetailScreen.kt index 256146a..b8380cb 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/bookclub/ClubDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/bookclub/ClubDetailScreen.kt @@ -1,5 +1,6 @@ package com.pinakes.app.ui.screens.bookclub +import com.pinakes.app.ui.theme.PublicationTitleStyle import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -278,7 +279,7 @@ private fun ClubBookCard(book: ClubBook, canParticipate: Boolean, onUpdateProgre Column(Modifier.padding(Spacing.lg)) { Row(verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { - Text(book.title, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(book.title, style = PublicationTitleStyle, color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis) if (book.authors.isNotBlank()) { Text(book.authors, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/collections/CollectionPager.kt b/app/src/main/java/com/pinakes/app/ui/screens/collections/CollectionPager.kt new file mode 100644 index 0000000..ec7525f --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/collections/CollectionPager.kt @@ -0,0 +1,66 @@ +package com.pinakes.app.ui.screens.collections + +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.CollectionPage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class CollectionListState( + val items: List = emptyList(), val nextCursor: String? = null, val total: Int? = null, + val loading: Boolean = true, val loadingMore: Boolean = false, + val error: ApiResult.Failure? = null, val moreError: ApiResult.Failure? = null, +) + +/** Independent cursors, duplicate protection and generation checks for optional collections. */ +internal class CollectionPager( + private val scope: CoroutineScope, + private val load: suspend (F, String?) -> ApiResult>, + private val id: (T) -> Int, +) { + private val mutableState = MutableStateFlow(CollectionListState()) + val state = mutableState.asStateFlow() + private var generation = 0 + private var firstJob: Job? = null + private var filters: F? = null + + fun reload(value: F, debounce: Boolean = false) { + filters = value + val request = ++generation + firstJob?.cancel() + mutableState.value = CollectionListState() + firstJob = scope.launch { + if (debounce) delay(350) + val result = load(value, null) + if (request != generation) return@launch + when (result) { + is ApiResult.Success -> mutableState.value = CollectionListState(result.data.items.distinctBy(id), result.data.nextCursor, result.data.total, loading = false) + is ApiResult.Failure -> mutableState.value = CollectionListState(loading = false, error = result) + } + } + } + + fun more() { + val current = mutableState.value + val value = filters ?: return + if (current.loading || current.loadingMore || current.nextCursor == null) return + val request = generation + mutableState.update { it.copy(loadingMore = true, moreError = null) } + scope.launch { + val result = load(value, current.nextCursor) + if (request != generation) return@launch + when (result) { + is ApiResult.Success -> mutableState.update { it.copy( + items = (it.items + result.data.items).distinctBy(id), + nextCursor = result.data.nextCursor?.takeUnless { cursor -> cursor == current.nextCursor }, + total = result.data.total ?: it.total, loadingMore = false, + ) } + is ApiResult.Failure -> mutableState.update { it.copy(loadingMore = false, moreError = result) } + } + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/collections/CollectionsScreens.kt b/app/src/main/java/com/pinakes/app/ui/screens/collections/CollectionsScreens.kt new file mode 100644 index 0000000..5a4e21c --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/collections/CollectionsScreens.kt @@ -0,0 +1,276 @@ +package com.pinakes.app.ui.screens.collections + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.selection.toggleable +import androidx.compose.ui.semantics.Role +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.pinakes.app.R +import com.pinakes.app.data.model.* +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.ui.components.* +import com.pinakes.app.ui.screens.bookclub.openWeb +import com.pinakes.app.ui.screens.detail.PdfReaderDialog +import com.pinakes.app.ui.theme.PublicationTitleStyle +import com.pinakes.app.ui.theme.Spacing + +@Composable +fun DesiderataScreen(onNavigateUp: () -> Unit, onOpenBook: (Int) -> Unit, onDonate: (Int) -> Unit, + vm: DesiderataViewModel = hiltViewModel()) { + val state by vm.state.collectAsStateWithLifecycle() + val query by vm.query.collectAsStateWithLifecycle() + val health by vm.health.collectAsStateWithLifecycle() + CollectionScaffold(stringResource(R.string.desiderata_title), onNavigateUp) { padding -> + LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(Spacing.lg), verticalArrangement = Arrangement.spacedBy(Spacing.md)) { + item { Text(stringResource(R.string.desiderata_intro), style = MaterialTheme.typography.bodyLarge) } + item { SearchField(query, vm::changeQuery, modifier = Modifier.fillMaxWidth(), placeholder = stringResource(R.string.collection_search), onSearch = vm::refresh) } + item { PrimaryButton(stringResource(R.string.desiderata_free_offer), { onDonate(0) }, Modifier.fillMaxWidth()) } + item { CollectionLinks(health) } + item { ListStatus(state, vm::refresh, R.string.desiderata_empty) } + items(state.items, key = { it.id }) { book -> + CollectionRow(book.title, listOfNotNull(book.author, book.publisher).joinToString(" · "), book.coverUrl, + stringResource(R.string.desiderata_wanted), { onOpenBook(book.id) }) + } + item { MoreButton(state, vm::more) } + } + } +} + +@Composable +fun WantedBookScreen(onNavigateUp: () -> Unit, onDonate: (Int) -> Unit, vm: WantedBookViewModel = hiltViewModel()) { + val state by vm.state.collectAsStateWithLifecycle() + val context = LocalContext.current + CollectionScaffold(stringResource(R.string.desiderata_title), onNavigateUp) { padding -> + DetailResult(state, padding, vm::refresh) { book -> + CollectionCover(book.title, book.coverUrl, book.author, book.publisher) + Text(stringResource(R.string.desiderata_wanted), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + Text(book.title, style = PublicationTitleStyle) + book.subtitle?.let { Text(it, style = MaterialTheme.typography.titleMedium) } + Metadata(stringResource(R.string.donation_author), book.author) + Metadata(stringResource(R.string.book_meta_publisher), book.publisher) + Metadata(stringResource(R.string.book_meta_year), book.year?.toString()) + Metadata("ISBN", book.isbn) + book.description?.let { HtmlText(it) } + PrimaryButton(stringResource(R.string.desiderata_offer), { onDonate(book.id) }, Modifier.fillMaxWidth()) + book.webUrl?.let { url -> SecondaryButton(stringResource(R.string.collection_web), { openWeb(context, url) }, Modifier.fillMaxWidth()) } + } + } +} + +@Composable +fun ArchivesScreen(onNavigateUp: () -> Unit, onOpenArchive: (Int) -> Unit, vm: ArchivesViewModel = hiltViewModel()) { + val state by vm.state.collectAsStateWithLifecycle() + val query by vm.query.collectAsStateWithLifecycle() + val level by vm.level.collectAsStateWithLifecycle() + val from by vm.from.collectAsStateWithLifecycle() + val to by vm.to.collectAsStateWithLifecycle() + val invalid by vm.invalidDates.collectAsStateWithLifecycle() + val health by vm.health.collectAsStateWithLifecycle() + CollectionScaffold(stringResource(R.string.archives_title), onNavigateUp) { padding -> + LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(Spacing.lg), verticalArrangement = Arrangement.spacedBy(Spacing.md)) { + item { Text(stringResource(R.string.archives_intro), style = MaterialTheme.typography.bodyLarge) } + item { SearchField(query, vm::changeQuery, modifier = Modifier.fillMaxWidth(), placeholder = stringResource(R.string.collection_search), onSearch = vm::refresh) } + item { Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + listOf("fonds", "series", "file", "item").forEach { value -> FilterChip(level == value, { vm.changeLevel(value) }, { Text(archiveLevel(value)) }) } + } } + item { Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + PinakesTextField(from, vm::changeFrom, stringResource(R.string.archives_from), Modifier.weight(1f), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), maxLength = 6) + PinakesTextField(to, vm::changeTo, stringResource(R.string.archives_to), Modifier.weight(1f), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), maxLength = 6) + } } + if (invalid) item { Text(stringResource(R.string.archives_dates_error), color = MaterialTheme.colorScheme.error) } + item { Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + SecondaryButton(stringResource(R.string.archives_apply), vm::refresh, Modifier.weight(1f)) + PinakesTextButton(stringResource(R.string.collection_clear), vm::clear, Modifier.weight(1f)) + } } + item { CollectionLinks(health) } + item { ListStatus(state, vm::refresh, R.string.collection_empty) } + items(state.items, key = { it.id }) { record -> CollectionRow(record.title, + listOfNotNull(record.referenceCode.takeIf(String::isNotBlank), record.datesLabel, record.extent).joinToString(" · "), + record.coverUrl, archiveLevel(record.level), { onOpenArchive(record.id) }) } + item { MoreButton(state, vm::more) } + } + } +} + +@Composable +fun ArchiveScreen(onNavigateUp: () -> Unit, onOpenArchive: (Int) -> Unit, onOpenChildren: (Int) -> Unit, + vm: ArchiveViewModel = hiltViewModel()) { + val state by vm.state.collectAsStateWithLifecycle() + val context = LocalContext.current + var pdf by remember { mutableStateOf(null) } + pdf?.let { PdfReaderDialog(pdfUrl = it, onDismiss = { pdf = null }) } + CollectionScaffold(stringResource(R.string.archives_title), onNavigateUp) { padding -> + DetailResult(state, padding, vm::refresh) { record -> + record.ancestors.forEach { ancestor -> TextButton({ onOpenArchive(ancestor.id) }) { Text(ancestor.title) } } + CollectionCover(record.title, record.coverUrl) + Text(record.title, style = PublicationTitleStyle) + record.formalTitle?.takeIf { it != record.title }?.let { Text(it, style = MaterialTheme.typography.titleMedium) } + Metadata(stringResource(R.string.archives_reference), record.referenceCode) + Metadata(stringResource(R.string.archives_level), archiveLevel(record.level)) + Metadata(stringResource(R.string.archives_dates), record.datesLabel) + Metadata(stringResource(R.string.archives_extent), record.extent) + Metadata(stringResource(R.string.archives_material), record.material) + record.fields.filterKeys { it != "extent" || record.extent.isNullOrBlank() }.forEach { (key, value) -> Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + Text(stringResource(archiveFieldLabel(key)), style = MaterialTheme.typography.labelLarge) + HtmlText(value) + } } + if (record.authorities.isNotEmpty()) { + Text(stringResource(R.string.archives_authorities), style = MaterialTheme.typography.titleMedium) + record.authorities.forEach { authority -> Metadata(authority.name, listOfNotNull(authority.dates, authority.role.takeIf(String::isNotBlank)).joinToString(" · ")) } + } + if (record.documents.isNotEmpty()) Text(stringResource(R.string.archives_documents), style = MaterialTheme.typography.titleMedium) + record.documents.forEach { document -> SecondaryButton(document.label.ifBlank { stringResource(R.string.archives_documents) }, { + if (document.mime.equals("application/pdf", true)) pdf = document.url else openWeb(context, document.url) + }, Modifier.fillMaxWidth()) } + PrimaryButton(stringResource(R.string.archives_children), { onOpenChildren(record.id) }, Modifier.fillMaxWidth()) + record.webUrl?.let { url -> TextButton({ openWeb(context, url) }) { Text(stringResource(R.string.collection_web)) } } + if (record.exports.isNotEmpty()) Text(stringResource(R.string.archives_exports), style = MaterialTheme.typography.titleMedium) + record.exports.forEach { (label, url) -> TextButton({ openWeb(context, url) }) { Text(label) } } + } + } +} + +@Composable +fun DonationScreen(onNavigateUp: () -> Unit, onDone: () -> Unit, vm: DonationViewModel = hiltViewModel()) { + val state by vm.state.collectAsStateWithLifecycle() + CollectionScaffold(stringResource(R.string.donation_title), onNavigateUp) { padding -> + DonationContent(state, padding, vm::edit, vm::consent, vm::submit, vm::loadBook, vm::proposeFreely, onDone) + } +} + +@Composable +internal fun DonationContent(state: DonationState, padding: PaddingValues = PaddingValues(), onEdit: (String, String) -> Unit, + onConsent: (Boolean) -> Unit, onSubmit: () -> Unit, onReload: () -> Unit, + onFree: () -> Unit, onDone: () -> Unit) { + LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(Spacing.lg), verticalArrangement = Arrangement.spacedBy(Spacing.md)) { + if (state.submitted) { + item { Text(stringResource(R.string.donation_success), style = MaterialTheme.typography.headlineSmall) } + item { Text(stringResource(R.string.donation_success_body)) } + item { PrimaryButton(stringResource(R.string.donation_done), onDone, Modifier.fillMaxWidth()) } + } else { + item { Text(stringResource(R.string.donation_contact), style = MaterialTheme.typography.bodyLarge) } + if (state.loadingBook) item { CircularProgressIndicator() } + item { PinakesTextField(state.title, { onEdit("title", it) }, stringResource(R.string.donation_book_title), Modifier.fillMaxWidth(), maxLength = 255, + enabled = !state.sending && !state.uncertain && !state.loadingBook, readOnly = state.bookId != null) } + item { PinakesTextField(state.author, { onEdit("author", it) }, stringResource(R.string.donation_author), Modifier.fillMaxWidth(), maxLength = 255, enabled = !state.sending && !state.uncertain && !state.loadingBook) } + item { PinakesTextField(state.publisher, { onEdit("publisher", it) }, stringResource(R.string.book_meta_publisher), Modifier.fillMaxWidth(), maxLength = 255, enabled = !state.sending && !state.uncertain && !state.loadingBook) } + item { PinakesTextField(state.isbn, { onEdit("isbn", it) }, "ISBN", Modifier.fillMaxWidth(), maxLength = 20, enabled = !state.sending && !state.uncertain && !state.loadingBook) } + item { PinakesTextField(state.notes, { onEdit("notes", it) }, stringResource(R.string.donation_notes), Modifier.fillMaxWidth(), maxLength = 2000, singleLine = false, enabled = !state.sending && !state.uncertain && !state.loadingBook) } + item { Row(Modifier.fillMaxWidth().clickable(enabled = !state.sending && !state.uncertain && !state.loadingBook) { onConsent(!state.consent) }, verticalAlignment = Alignment.CenterVertically) { + Checkbox(state.consent, null, enabled = !state.sending && !state.uncertain && !state.loadingBook) + Text(stringResource(R.string.donation_consent), modifier = Modifier.padding(start = Spacing.sm)) + } } + if (state.uncertain) item { Text(stringResource(R.string.donation_uncertain), color = MaterialTheme.colorScheme.onSurfaceVariant) } + state.error?.let { error -> item { Text(error.message.ifBlank { stringResource(R.string.standalone_articles_error) }, color = MaterialTheme.colorScheme.error) } } + if (state.bookId != null && state.error?.code in listOf("no_longer_wanted", "not_found") && !state.uncertain) item { + SecondaryButton(stringResource(R.string.desiderata_free_offer), onFree, Modifier.fillMaxWidth()) + } + if (state.title.isBlank() && state.bookId != null && state.error != null) item { SecondaryButton(stringResource(R.string.action_retry), onReload) } + item { PrimaryButton(stringResource(if (state.uncertain) R.string.action_retry else R.string.donation_send), onSubmit, Modifier.fillMaxWidth(), loading = state.sending, enabled = state.canSubmit) } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CollectionScaffold(title: String, onNavigateUp: () -> Unit, content: @Composable (PaddingValues) -> Unit) { + Scaffold(topBar = { PinakesTopBar(title, onNavigateUp = onNavigateUp) }, content = content) +} + +@Composable +private fun CollectionRow(title: String, metadata: String, cover: String?, badge: String, onClick: () -> Unit) { + Column { + Row(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = Spacing.sm), horizontalArrangement = Arrangement.spacedBy(Spacing.md)) { + BookCover(title, cover, Modifier.width(72.dp).height(108.dp), compact = true) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + Text(badge, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 3, overflow = TextOverflow.Ellipsis) + Text(metadata, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } +} + +@Composable +private fun CollectionCover(title: String, cover: String?, author: String? = null, publisher: String? = null) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { BookCover(title, cover, Modifier.width(160.dp).height(240.dp), author = author, publisher = publisher) } +} + +@Composable +private fun Metadata(label: String, value: String?) { + if (!value.isNullOrBlank()) Column { + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.bodyLarge) + } +} + +@Composable +private fun CollectionLinks(health: CollectionHealth?) { + val context = LocalContext.current + health?.let { + it.webUrl?.let { url -> TextButton({ openWeb(context, url) }) { Text(stringResource(R.string.collection_web)) } } + it.manageUrl?.let { url -> TextButton({ openWeb(context, url) }) { Text(stringResource(R.string.collection_manage)) } } + } +} + +@Composable +private fun ListStatus(state: CollectionListState, retry: () -> Unit, emptyLabel: Int) { + when { + state.loading -> LoadingState(label = stringResource(R.string.collection_loading)) + state.error != null -> ErrorState(state.error.message.ifBlank { stringResource(R.string.standalone_articles_error) }, onRetry = retry) + state.items.isEmpty() -> Text(stringResource(emptyLabel), style = MaterialTheme.typography.bodyLarge) + } +} + +@Composable +private fun MoreButton(state: CollectionListState, more: () -> Unit) { + if (state.nextCursor != null) { + state.moreError?.let { Text(it.message, color = MaterialTheme.colorScheme.error) } + PrimaryButton(stringResource(if (state.moreError != null) R.string.action_retry else R.string.collection_more), more, Modifier.fillMaxWidth(), loading = state.loadingMore) + } +} + +@Composable +private fun DetailResult(state: ApiResult?, padding: PaddingValues, retry: () -> Unit, content: @Composable ColumnScope.(T) -> Unit) { + when (state) { + null -> Box(Modifier.fillMaxSize().padding(padding)) { LoadingState(stringResource(R.string.collection_loading)) } + is ApiResult.Failure -> Box(Modifier.fillMaxSize().padding(padding)) { ErrorState(state.message, onRetry = retry) } + is ApiResult.Success -> LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(Spacing.lg)) { + item { Column(verticalArrangement = Arrangement.spacedBy(Spacing.md)) { content(state.data) } } + } + } +} + +@Composable +private fun archiveLevel(level: String): String = stringResource(when (level) { + "fonds" -> R.string.archives_fonds; "series" -> R.string.archives_series; "file" -> R.string.archives_files; else -> R.string.archives_items +}) + +private fun archiveFieldLabel(key: String): Int = when (key) { + "scope_content" -> R.string.archives_description; "archival_history" -> R.string.archives_history + "access_conditions" -> R.string.archives_access; "reproduction_rules" -> R.string.archives_reproduction + "arrangement_system" -> R.string.archives_arrangement; "finding_aids" -> R.string.archives_finding_aids + "originals_location" -> R.string.archives_originals; "copies_location" -> R.string.archives_copies + "related_units" -> R.string.archives_related; "institution_code" -> R.string.archives_institution + "local_classification" -> R.string.archives_classification; "collection_name" -> R.string.archives_collection + "photographer" -> R.string.archives_photographer; "language_codes" -> R.string.book_meta_language + "dimensions" -> R.string.archives_dimensions; "color_mode" -> R.string.archives_color + "publisher" -> R.string.book_meta_publisher; "predominant_dates" -> R.string.archives_predominant_dates; "date_gaps" -> R.string.archives_date_gaps + "extent" -> R.string.archives_extent; else -> R.string.archives_description +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/collections/CollectionsViewModels.kt b/app/src/main/java/com/pinakes/app/ui/screens/collections/CollectionsViewModels.kt new file mode 100644 index 0000000..78b86a4 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/collections/CollectionsViewModels.kt @@ -0,0 +1,87 @@ +package com.pinakes.app.ui.screens.collections + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pinakes.app.data.model.* +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.* +import com.pinakes.app.ui.navigation.Routes +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class DesiderataViewModel @Inject constructor(private val source: CollectionsSource, private val saved: SavedStateHandle) : ViewModel() { + private val pager = CollectionPager(viewModelScope, source::wanted, WantedBook::id) + val state = pager.state + val query = saved.getStateFlow("query", "") + private val healthState = MutableStateFlow(null) + val health = healthState.asStateFlow() + init { refresh() } + fun changeQuery(value: String) { + saved["query"] = value + // Reset immediately so an old response cannot remain under a new query. + pager.reload(value, debounce = true) + } + fun refresh() { + pager.reload(query.value) + viewModelScope.launch { (source.desiderataHealth() as? ApiResult.Success)?.let { healthState.value = it.data } } + } + fun more() = pager.more() +} + +@HiltViewModel +class ArchivesViewModel @Inject constructor(private val source: CollectionsSource, private val saved: SavedStateHandle) : ViewModel() { + private val pager = CollectionPager(viewModelScope, source::archives, ArchiveRecord::id) + val state = pager.state + val query = saved.getStateFlow("query", "") + val level = saved.getStateFlow("level", "") + val from = saved.getStateFlow("from", "") + val to = saved.getStateFlow("to", "") + val parentId = saved.get(Routes.ARG_ARCHIVE_PARENT)?.takeIf { it > 0 } + private val healthState = MutableStateFlow(null) + val health = healthState.asStateFlow() + val invalidDates = MutableStateFlow(false) + init { refresh() } + fun changeQuery(value: String) { saved["query"] = value; reload(debounce = true) } + fun changeLevel(value: String) { saved["level"] = if (level.value == value) "" else value; refresh() } + fun changeFrom(value: String) { saved["from"] = value } + fun changeTo(value: String) { saved["to"] = value } + fun clear() { saved["query"] = ""; saved["level"] = ""; saved["from"] = ""; saved["to"] = ""; refresh() } + fun refresh() { + reload() + viewModelScope.launch { (source.archivesHealth() as? ApiResult.Success)?.let { healthState.value = it.data } } + } + private fun reload(debounce: Boolean = false) { + val start = from.value.toIntOrNull(); val end = to.value.toIntOrNull() + val invalid = (from.value.isNotBlank() && (start == null || start !in -32768..32767)) || + (to.value.isNotBlank() && (end == null || end !in -32768..32767)) || (start != null && end != null && start > end) + invalidDates.value = invalid + if (invalid) return + pager.reload(ArchiveFilters(query.value, level.value.takeIf { it.isNotBlank() }, start, end, parentId), debounce) + } + fun more() = pager.more() +} + +@HiltViewModel +class WantedBookViewModel @Inject constructor(private val source: CollectionsSource, saved: SavedStateHandle) : ViewModel() { + private val id = saved.get(Routes.ARG_WANTED_ID) ?: 0 + private val mutableState = MutableStateFlow?>(null) + val state = mutableState.asStateFlow() + private var generation = 0 + init { refresh() } + fun refresh() { val request = ++generation; mutableState.value = null; viewModelScope.launch { val result = source.wantedBook(id); if (request == generation) mutableState.value = result } } +} + +@HiltViewModel +class ArchiveViewModel @Inject constructor(private val source: CollectionsSource, saved: SavedStateHandle) : ViewModel() { + private val id = saved.get(Routes.ARG_ARCHIVE_ID) ?: 0 + private val mutableState = MutableStateFlow?>(null) + val state = mutableState.asStateFlow() + private var generation = 0 + init { refresh() } + fun refresh() { val request = ++generation; mutableState.value = null; viewModelScope.launch { val result = source.archive(id); if (request == generation) mutableState.value = result } } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/collections/DonationViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/collections/DonationViewModel.kt new file mode 100644 index 0000000..5bc6433 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/collections/DonationViewModel.kt @@ -0,0 +1,102 @@ +package com.pinakes.app.ui.screens.collections + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pinakes.app.data.model.DonationOfferRequest +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.CollectionsSource +import com.pinakes.app.ui.navigation.Routes +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.util.UUID +import javax.inject.Inject + +data class DonationState( + val bookId: Int? = null, val title: String = "", val author: String = "", val publisher: String = "", + val isbn: String = "", val notes: String = "", val consent: Boolean = false, + val loadingBook: Boolean = false, val sending: Boolean = false, val submitted: Boolean = false, + val uncertain: Boolean = false, + val error: ApiResult.Failure? = null, +) { + val canSubmit get() = title.isNotBlank() && consent && !sending && !loadingBook && !submitted +} + +@HiltViewModel +class DonationViewModel @Inject constructor(private val source: CollectionsSource, private val saved: SavedStateHandle) : ViewModel() { + private var submissionId: String = saved.get("submissionId") ?: UUID.randomUUID().toString().also { saved["submissionId"] = it } + private val initialId = saved.get(Routes.ARG_WANTED_ID)?.takeIf { it > 0 } + private val mutableState = MutableStateFlow(DonationState( + bookId = (saved.get("draftBookId") ?: initialId)?.takeIf { it > 0 }, + title = saved["title"] ?: "", author = saved["author"] ?: "", publisher = saved["publisher"] ?: "", + isbn = saved["isbn"] ?: "", notes = saved["notes"] ?: "", consent = saved["consent"] ?: false, + submitted = saved["submitted"] ?: false, + uncertain = saved["uncertain"] ?: false, + )) + val state = mutableState.asStateFlow() + init { if (mutableState.value.bookId != null && mutableState.value.title.isBlank()) loadBook() } + + fun loadBook() { + val id = mutableState.value.bookId ?: return + mutableState.update { it.copy(loadingBook = true, error = null) } + viewModelScope.launch { + when (val result = source.wantedBook(id)) { + is ApiResult.Success -> { + val book = result.data + saved["title"] = book.title; saved["author"] = book.author.orEmpty(); saved["publisher"] = book.publisher.orEmpty(); saved["isbn"] = book.isbn.orEmpty() + mutableState.update { it.copy(title = book.title, author = book.author.orEmpty(), publisher = book.publisher.orEmpty(), isbn = book.isbn.orEmpty(), loadingBook = false) } + } + is ApiResult.Failure -> mutableState.update { it.copy(loadingBook = false, error = result) } + } + } + } + + fun edit(field: String, value: String) { + if (mutableState.value.sending || mutableState.value.submitted || mutableState.value.uncertain || mutableState.value.loadingBook) return + // Editing is allowed only once an earlier send is known to have failed. + saved[field] = value + mutableState.update { when (field) { + "title" -> it.copy(title = value, error = null) + "author" -> it.copy(author = value, error = null) + "publisher" -> it.copy(publisher = value, error = null) + "isbn" -> it.copy(isbn = value, error = null) + "notes" -> it.copy(notes = value, error = null) + else -> it + } } + } + fun consent(value: Boolean) { if (!mutableState.value.sending && !mutableState.value.uncertain) { saved["consent"] = value; mutableState.update { it.copy(consent = value) } } } + + fun proposeFreely() { + if (mutableState.value.sending || mutableState.value.uncertain) return + saved["draftBookId"] = 0 + submissionId = UUID.randomUUID().toString(); saved["submissionId"] = submissionId + mutableState.update { it.copy(bookId = null, error = null) } + } + + fun submit() { + val current = mutableState.value + if (!current.canSubmit) return + saved["uncertain"] = true + mutableState.update { it.copy(sending = true, error = null) } + val request = DonationOfferRequest(submissionId, current.bookId, current.title.trim(), current.author.trim(), current.publisher.trim(), current.isbn.trim(), current.notes.trim(), current.consent) + viewModelScope.launch { + if (current.uncertain) { + when (val status = source.offerStatus(submissionId)) { + is ApiResult.Success -> { saved["submitted"] = true; saved["uncertain"] = false; mutableState.update { it.copy(sending = false, submitted = true, uncertain = false) }; return@launch } + is ApiResult.Failure -> if (status.httpStatus != 404) { mutableState.update { it.copy(sending = false, error = status) }; return@launch } + } + } + when (val result = source.offer(request)) { + is ApiResult.Success -> { saved["submitted"] = true; saved["uncertain"] = false; mutableState.update { it.copy(sending = false, submitted = true, uncertain = false) } } + is ApiResult.Failure -> { + val uncertain = result.httpStatus == 0 || result.httpStatus >= 500 + saved["uncertain"] = uncertain + mutableState.update { it.copy(sending = false, error = result, uncertain = uncertain) } + } + } + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt index 12c70c6..201765b 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt @@ -5,6 +5,17 @@ import android.content.Intent import android.net.Uri import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.* +import androidx.compose.ui.graphics.Brush +import androidx.compose.foundation.shape.CircleShape +import com.pinakes.app.ui.components.BookQuickFacts +import com.pinakes.app.ui.components.BookCover +import com.pinakes.app.ui.components.bookCoverImageUrl +import com.pinakes.app.ui.components.DigitalFileCard +import com.pinakes.app.ui.components.MediaTypeIcon +import com.pinakes.app.ui.theme.LocalPinakesColors +import com.pinakes.app.ui.theme.Fraunces import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -25,6 +36,7 @@ import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material.icons.outlined.HourglassEmpty import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.LocalOffer import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog @@ -74,6 +86,7 @@ import com.pinakes.app.ui.components.AvailabilityStatus import com.pinakes.app.ui.components.ErrorState import com.pinakes.app.ui.components.HtmlText import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.CitationButton import com.pinakes.app.ui.components.MetadataRow import com.pinakes.app.ui.components.PinakesTopBar import com.pinakes.app.ui.components.PrimaryButton @@ -92,6 +105,7 @@ import kotlinx.coroutines.launch @Composable fun BookDetailScreen( onNavigateUp: () -> Unit, + onFindWorks: (String, Int?) -> Unit = { _, _ -> }, ) { val app: AppViewModel = hiltViewModel() val features by app.features.collectAsStateWithLifecycle() @@ -168,6 +182,7 @@ fun BookDetailScreen( showReviews = features.showReviews, onReserve = vm::openLoanSheet, onToggleWishlist = vm::toggleWishlist, + onFindWorks = onFindWorks, onShowMessage = { msg -> scope.launch { snackbarHost.showSnackbar(msg) } }, ) } @@ -306,8 +321,9 @@ private fun FallbackDatePicker( DatePicker(state = datePickerState, title = null, headline = null, showModeToggle = false) } +@OptIn(ExperimentalLayoutApi::class) @Composable -private fun DetailContent( +internal fun DetailContent( book: BookDetail, wishlisted: Boolean, reserveBusy: Boolean, @@ -317,100 +333,85 @@ private fun DetailContent( onReserve: () -> Unit, onToggleWishlist: () -> Unit, onShowMessage: (String) -> Unit, + onFindWorks: (String, Int?) -> Unit = { _, _ -> }, ) { val context = LocalContext.current var showCover by remember { mutableStateOf(false) } - var showPdf by remember { mutableStateOf(false) } + var selectedPdf by remember(book.id) { mutableStateOf(null) } + var selectedAudio by remember(book.id) { mutableStateOf(book.audioUrl?.takeIf { book.hasAudio }) } Column( Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) + .background(Brush.verticalGradient(listOf(LocalPinakesColors.current.heroWash, LocalPinakesColors.current.background), endY = 1200f)) .padding(Spacing.lg), ) { - // Header: cover + title block - Row { - Box( - Modifier - .width(120.dp) - .height(180.dp) - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant) - .then( - if (book.coverUrl != null) - Modifier.clickable( - onClickLabel = stringResource(R.string.cd_cover_zoom), - ) { showCover = true } - else Modifier - ), - contentAlignment = Alignment.Center, - ) { - if (book.coverUrl != null) { - SubcomposeAsyncImage( - model = book.coverUrl, - contentDescription = book.title, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - error = { - Icon( - Icons.AutoMirrored.Outlined.MenuBook, - contentDescription = null, - tint = MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.size(40.dp), - ) - }, - ) - } else { - Icon( - Icons.AutoMirrored.Outlined.MenuBook, - contentDescription = null, - tint = MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.size(40.dp), - ) + val colors = LocalPinakesColors.current + BookCover(book.title, book.coverUrl, Modifier.widthIn(max = 340.dp).fillMaxWidth().aspectRatio(2f / 3f) + .align(Alignment.CenterHorizontally).then(if (bookCoverImageUrl(book.coverUrl) != null) + Modifier.clickable(onClickLabel = stringResource(R.string.cd_cover_zoom)) { showCover = true } else Modifier), + author = book.authorsLabel, publisher = book.publisher) + Spacer(Modifier.height(32.dp)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth()) { + Surface(shape = RoundedCornerShape(50), color = colors.surface, border = BorderStroke(1.dp, colors.line)) { + Row(Modifier.padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { + MediaTypeIcon(book.mediaType) + Text(book.mediaType?.takeIf { it.isNotBlank() } ?: stringResource(R.string.title_book), style = MaterialTheme.typography.labelMedium) } } - Spacer(Modifier.width(Spacing.lg)) - Column(Modifier.weight(1f)) { - Text( - book.title, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - ) - if (!book.subtitle.isNullOrBlank()) { - Spacer(Modifier.height(Spacing.xs)) - Text(book.subtitle!!, style = MaterialTheme.typography.titleSmall, fontStyle = FontStyle.Italic, color = MaterialTheme.colorScheme.onSurface) - } - if (book.authorsLabel.isNotBlank()) { - Spacer(Modifier.height(Spacing.sm)) - Text( - book.authorsLabel, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - ) - } - Spacer(Modifier.height(Spacing.md)) - // Colour-code WHY a title isn't free: green available, red on-loan, - // amber reserved/scheduled. Falls back to the binary available flag. - val availableLabel = stringResource(R.string.book_copies_available_of, book.copiesAvailable, book.copiesTotal) - val (availStatus, availLabel) = when (book.availability.state) { - "available" -> AvailabilityStatus.Available to availableLabel - "on_loan" -> AvailabilityStatus.Unavailable to stringResource(R.string.book_on_loan) - "reserved" -> AvailabilityStatus.DueSoon to stringResource(R.string.availability_reserved) - "unavailable" -> AvailabilityStatus.Unavailable to stringResource(R.string.availability_unavailable) - else -> if (book.available) AvailabilityStatus.Available to availableLabel - else AvailabilityStatus.Unavailable to stringResource(R.string.book_on_loan) - } - AvailabilityChip(status = availStatus, label = availLabel) - book.genreLabel?.takeIf { it.isNotBlank() }?.let { genre -> - Spacer(Modifier.height(Spacing.sm)) - GenreChip(genre) + book.publisher?.takeIf { it.isNotBlank() }?.let { Text(it, color = colors.accentText, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 6.dp)) } + book.year?.let { Text(if (book.publisher.isNullOrBlank()) "$it" else "· $it", color = colors.muted, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 6.dp)) } + } + Spacer(Modifier.height(16.dp)) + Text(book.title, style = MaterialTheme.typography.headlineLarge, color = colors.ink) + if (!book.subtitle.isNullOrBlank()) { + Spacer(Modifier.height(8.dp)) + Text(book.subtitle!!, style = MaterialTheme.typography.titleLarge, fontStyle = FontStyle.Italic, color = colors.muted) + } + Spacer(Modifier.height(16.dp)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (book.authors.isEmpty()) Text(stringResource(R.string.restyle_unknown_author), style = MaterialTheme.typography.bodySmall, color = colors.muted) + book.authors.forEach { author -> + Surface(shape = RoundedCornerShape(50), color = colors.surface, border = BorderStroke(1.dp, colors.line)) { + Row(Modifier.clickable { onFindWorks(author.name, author.id) }.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { + val initials = author.name.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }.take(2).map { it.first() }.joinToString("") + Box(Modifier.size(24.dp).background(colors.accentSoft, CircleShape), contentAlignment = Alignment.Center) { + Text(initials, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = colors.accentStrong) + } + Spacer(Modifier.width(8.dp)) + Text(author.name + (author.role?.takeIf { it.isNotBlank() && it != "autore" && it != "author" }?.let { " · $it" } ?: ""), + style = MaterialTheme.typography.bodyMedium) + } } } } - - Spacer(Modifier.height(Spacing.lg)) + CitationButton(book.citations, book.risUrl) + val genrePath = book.genrePath.takeIf { it.isNotEmpty() }?.joinToString(" › ") { it.name } ?: book.genre?.let { genre -> + listOfNotNull(genre.grandparent, genre.parent, genre.name, genre.subgenre) + .filter { it.isNotBlank() }.distinct().joinToString(" › ") + } + genrePath?.takeIf { it.isNotBlank() }?.let { genre -> + Spacer(Modifier.height(16.dp)) + GenrePath(genre) + } + Spacer(Modifier.height(24.dp)) + Surface(shape = MaterialTheme.shapes.large, color = colors.surface, border = BorderStroke(1.dp, colors.line), + shadowElevation = 2.dp, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(22.dp)) { + val availableLabel = stringResource(R.string.book_copies_available_of, book.copiesAvailable, book.copiesTotal) + val (availStatus, availLabel) = when (book.availability.state) { + "available" -> AvailabilityStatus.Available to stringResource(R.string.availability_available) + "on_loan" -> AvailabilityStatus.Unavailable to stringResource(R.string.book_on_loan) + "reserved" -> AvailabilityStatus.DueSoon to stringResource(R.string.availability_reserved) + "unavailable" -> AvailabilityStatus.Unavailable to stringResource(R.string.availability_unavailable) + else -> if (book.available) AvailabilityStatus.Available to stringResource(R.string.availability_available) + else AvailabilityStatus.Unavailable to stringResource(R.string.book_on_loan) + } + AvailabilityChip(availStatus, availLabel) + Text(availableLabel, style = MaterialTheme.typography.bodyMedium, color = colors.muted) + Spacer(Modifier.height(16.dp)) // Personal-status banner: makes the user's own relationship to this book explicit, // so a generic "available" chip isn't the only signal. The wishlist-only variant is @@ -427,7 +428,7 @@ private fun DetailContent( if (ph.hasRead) add(stringResource(R.string.book_history_previously_read) to AvailabilityStatus.Available) } if (chips.isNotEmpty()) { - Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.sm), verticalArrangement = Arrangement.spacedBy(8.dp)) { chips.forEach { (label, status) -> AvailabilityChip(status = status, label = label) } } Spacer(Modifier.height(Spacing.lg)) @@ -443,13 +444,13 @@ private fun DetailContent( // The label adapts: "Request loan" when a copy is free now, "Reserve" when it isn't. val showLoanButton = canBorrow if (showLoanButton || showWishlist) { - Row(horizontalArrangement = Arrangement.spacedBy(Spacing.md)) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { if (showLoanButton) { PrimaryButton( label = if (book.available) stringResource(R.string.book_request_loan) else stringResource(R.string.book_reserve), onClick = onReserve, loading = reserveBusy, - modifier = Modifier.weight(1f), + modifier = Modifier.fillMaxWidth(), ) } if (showWishlist) { @@ -457,52 +458,53 @@ private fun DetailContent( label = if (wishlisted) stringResource(R.string.book_wishlisted) else stringResource(R.string.book_wishlist), onClick = onToggleWishlist, leadingIcon = if (wishlisted) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, - modifier = Modifier.weight(1f), + modifier = Modifier.fillMaxWidth(), ) } } } - // Audiobook player (when the API reports an audiobook for this title). - if (book.hasAudio && !book.audioUrl.isNullOrBlank()) { - Spacer(Modifier.height(Spacing.lg)) - SectionTitle(stringResource(R.string.book_section_audiobook)) - Spacer(Modifier.height(Spacing.sm)) - AudioPlayer(audioUrl = book.audioUrl!!) } - - // E-book "Read" action: in-app PDF reader, or ACTION_VIEW for other formats (epub, …). - if (book.hasEbook && !book.ebookUrl.isNullOrBlank()) { - Spacer(Modifier.height(Spacing.lg)) - SectionTitle(stringResource(R.string.book_section_ebook)) - Spacer(Modifier.height(Spacing.sm)) - val isPdf = book.ebookFormat?.equals("pdf", ignoreCase = true) == true || - book.ebookUrl!!.substringBefore('?').endsWith(".pdf", ignoreCase = true) - PrimaryButton( - label = if (isPdf) stringResource(R.string.book_read) else stringResource(R.string.ebook_open_external), - onClick = { - if (isPdf) { - showPdf = true - } else { - runCatching { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(book.ebookUrl))) - }.onFailure { - if (it is ActivityNotFoundException) { /* no viewer available; nothing to do */ } - } - } - }, - modifier = Modifier.fillMaxWidth(), - ) } - - Spacer(Modifier.height(Spacing.lg)) + Spacer(Modifier.height(24.dp)) + BookQuickFacts(listOfNotNull( + book.year?.let { stringResource(R.string.book_meta_year) to it.toString() }, + book.pages?.let { stringResource(R.string.book_meta_pages) to it.toString() }, + book.format?.takeIf { it.isNotBlank() }?.let { stringResource(R.string.book_meta_format) to it }, + book.isbn13?.takeIf { it.isNotBlank() }?.let { stringResource(R.string.book_meta_isbn13) to it }, + )) + Spacer(Modifier.height(48.dp)) + + val digitalFiles = book.digitalFiles + if (digitalFiles.isNotEmpty()) { + SectionTitle(stringResource(R.string.restyle_digital)) + Spacer(Modifier.height(16.dp)) + } + digitalFiles.forEach { file -> + val audio = file.kind == "audio" + DigitalFileCard(type = file.format.ifBlank { if (audio) "Audio" else "eBook" }, + name = file.label.ifBlank { digitalFilename(file.url, book.title) }, + kind = stringResource(if (audio) R.string.book_section_audiobook else if (file.kind == "supplement") R.string.book_section_related_documents else R.string.book_section_ebook)) { + if (audio && selectedAudio == file.url) { + androidx.compose.runtime.key(file.url) { AudioPlayer(audioUrl = file.url) } + } else PrimaryButton(label = stringResource(if (audio) R.string.collection_play else if (file.isPdf) R.string.book_read else R.string.ebook_open_external), + onClick = { + if (audio) selectedAudio = file.url + else if (file.isPdf) selectedPdf = file.url + else runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(file.url))) } + .onFailure { onShowMessage(context.getString(R.string.ebook_error)) } + }, modifier = Modifier.fillMaxWidth(), dark = true) + } + Spacer(Modifier.height(16.dp)) + } + Spacer(Modifier.height(32.dp)) if (!book.description.isNullOrBlank()) { SectionTitle(stringResource(R.string.book_section_about)) Spacer(Modifier.height(Spacing.sm)) HtmlText( html = book.description!!, - style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 26.sp), + style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 28.sp), color = MaterialTheme.colorScheme.onSurface, ) Spacer(Modifier.height(Spacing.xl)) @@ -520,10 +522,14 @@ private fun DetailContent( Column(Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.xs)) { // Blank guards: the API can send an empty string (not null) for a // missing field — don't render a labelled row with no value. - book.publisher?.takeIf { it.isNotBlank() }?.let { MetadataRow(stringResource(R.string.book_meta_publisher), it) } + (book.publishers.takeIf { it.isNotEmpty() }?.joinToString("; ") { it.name } ?: book.publisher)?.takeIf { it.isNotBlank() }?.let { MetadataRow(stringResource(R.string.book_meta_publisher), it) } + book.edition?.takeIf { it.isNotBlank() }?.let { MetadataRow(stringResource(R.string.book_meta_edition), it) } + book.publicationPlace?.takeIf { it.isNotBlank() }?.let { MetadataRow(stringResource(R.string.book_meta_publication_place), it) } book.year?.let { MetadataRow(stringResource(R.string.book_meta_year), it.toString()) } book.language?.takeIf { it.isNotBlank() }?.let { MetadataRow(stringResource(R.string.book_meta_language), it) } book.pages?.let { MetadataRow(stringResource(R.string.book_meta_pages), it.toString()) } + book.format?.takeIf { it.isNotBlank() }?.let { MetadataRow(stringResource(R.string.book_meta_format), it) } + book.series?.takeIf { it.isNotBlank() }?.let { MetadataRow(stringResource(R.string.book_meta_series), it) } book.isbn13?.takeIf { it.isNotBlank() }?.let { MetadataRow(stringResource(R.string.book_meta_isbn13), it) } book.isbn10?.takeIf { it.isNotBlank() }?.let { MetadataRow(stringResource(R.string.book_meta_isbn10), it) } book.ean?.takeIf { it.isNotBlank() }?.let { MetadataRow(stringResource(R.string.book_meta_ean), it) } @@ -548,7 +554,7 @@ private fun DetailContent( } // Full-screen zoomable cover overlay. - if (showCover && book.coverUrl != null) { + if (showCover && bookCoverImageUrl(book.coverUrl) != null) { ZoomableCoverDialog( imageUrl = book.coverUrl!!, contentDescription = book.title, @@ -557,10 +563,10 @@ private fun DetailContent( } // In-app PDF reader overlay. - if (showPdf && !book.ebookUrl.isNullOrBlank()) { + selectedPdf?.let { pdf -> PdfReaderDialog( - pdfUrl = book.ebookUrl!!, - onDismiss = { showPdf = false }, + pdfUrl = pdf, + onDismiss = { selectedPdf = null }, ) } } @@ -641,18 +647,15 @@ private data class BannerSpec( val icon: androidx.compose.ui.graphics.vector.ImageVector, ) -/** Magenta-tinted genre chip (primaryContainer / onPrimaryContainer). Shown when present. */ @Composable -private fun GenreChip(label: String) { - Surface( - shape = MaterialTheme.shapes.small, - color = MaterialTheme.colorScheme.primaryContainer, - ) { +private fun GenrePath(label: String) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Outlined.LocalOffer, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(16.dp)) Text( text = label, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.padding(horizontal = Spacing.md, vertical = Spacing.xs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -662,12 +665,14 @@ private fun GenreChip(label: String) { private fun SectionTitle(text: String) { Text( text = text, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onSurface, ) } +private fun digitalFilename(url: String, fallback: String): String = + Uri.parse(url).lastPathSegment?.takeIf { it.isNotBlank() } ?: fallback + private val displayDateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault()) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanCalendar.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanCalendar.kt index 3a3d0b1..d97352e 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanCalendar.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanCalendar.kt @@ -124,7 +124,7 @@ fun LoanCalendar( onSelect: (isoDate: String) -> Unit, modifier: Modifier = Modifier, ) { - val dark = isSystemInDarkTheme() + val dark = com.pinakes.app.ui.theme.LocalPinakesColors.current.darkMode val today = remember { LocalDate.now() } val model = remember(calendar) { CalendarModel(calendar) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt index 6e40c14..5cf1d62 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt @@ -2,6 +2,32 @@ package com.pinakes.app.ui.screens.home import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.* +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import com.pinakes.app.ui.screens.search.BookSort +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import com.pinakes.app.data.model.BookSummary +import com.pinakes.app.ui.components.BookCover +import com.pinakes.app.ui.components.BookCardGrid +import com.pinakes.app.ui.components.SearchField +import com.pinakes.app.ui.theme.HeroStyle +import com.pinakes.app.ui.theme.LocalPinakesColors +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -16,6 +42,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.MenuBook +import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.AutoStories import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -47,6 +74,11 @@ import com.pinakes.app.ui.theme.Spacing fun HomeScreen( onBookClick: (Int) -> Unit, onBrowseCatalog: () -> Unit, + onSearch: (String) -> Unit = { onBrowseCatalog() }, + onOpenPeriodicals: (() -> Unit)? = null, + onOpenArticles: (() -> Unit)? = null, + onOpenDesiderata: (() -> Unit)? = null, + onOpenArchives: (() -> Unit)? = null, ) { // Feature flags come from the Hilt-provided AppViewModel; the screen ViewModel is also // created by Hilt via hiltViewModel() instead of a hand-written ViewModelProvider.Factory. @@ -54,8 +86,25 @@ fun HomeScreen( val features by app.features.collectAsStateWithLifecycle() val vm: HomeViewModel = hiltViewModel() val state by vm.state.collectAsStateWithLifecycle() - val catalogueMode = features.catalogueMode + HomeContent(state, features.catalogueMode, onBookClick, onBrowseCatalog, onSearch, vm::retry, onOpenPeriodicals, onOpenArticles, onOpenDesiderata, onOpenArchives, vm::setSort) +} +/** Query state belongs above the phase transition, so loading cannot clear a draft. */ +@Composable +internal fun HomeContent( + state: HomeUiState, + catalogueMode: Boolean, + onBookClick: (Int) -> Unit, + onBrowseCatalog: () -> Unit, + onSearch: (String) -> Unit, + onRetry: () -> Unit, + onOpenPeriodicals: (() -> Unit)? = null, + onOpenArticles: (() -> Unit)? = null, + onOpenDesiderata: (() -> Unit)? = null, + onOpenArchives: (() -> Unit)? = null, + onSort: (BookSort) -> Unit = {}, +) { + var heroQuery by rememberSaveable { mutableStateOf("") } Crossfade( targetState = when { state.loading -> HomePhase.Loading @@ -67,8 +116,8 @@ fun HomeScreen( label = "home_phase", ) { phase -> when (phase) { - HomePhase.Loading -> Column(Modifier.fillMaxSize()) { - HomeHeader(libraryName = state.libraryName, catalogueMode = catalogueMode) + HomePhase.Loading -> Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + HomeHeader(libraryName = state.libraryName, catalogueMode = catalogueMode, books = state.available, onBookClick = onBookClick, onSearch = onSearch, query = heroQuery, onQueryChange = { heroQuery = it }) Column(Modifier.padding(horizontal = Spacing.lg)) { SectionHeader(showSeeAll = false, catalogueMode = catalogueMode, onSeeAll = {}) Spacer(Modifier.height(Spacing.md)) @@ -80,19 +129,21 @@ fun HomeScreen( } HomePhase.Error -> ErrorState( - message = state.error ?: stringResource(R.string.home_error), - onRetry = vm::retry, + message = state.error?.takeIf(String::isNotBlank) ?: stringResource(R.string.home_error), + onRetry = onRetry, ) - HomePhase.Empty -> Column(Modifier.fillMaxSize()) { - HomeHeader(libraryName = state.libraryName, catalogueMode = catalogueMode) - EmptyState( - title = stringResource(R.string.home_empty_title), - subtitle = stringResource(R.string.home_empty_subtitle), - icon = Icons.AutoMirrored.Outlined.MenuBook, - actionLabel = stringResource(R.string.home_browse_catalog), - onAction = onBrowseCatalog, - ) + HomePhase.Empty -> LazyColumn(Modifier.fillMaxSize()) { + item { HomeHeader(libraryName = state.libraryName, catalogueMode = catalogueMode, books = state.available, + onBookClick = onBookClick, onSearch = onSearch, query = heroQuery, onQueryChange = { heroQuery = it }) } + item { CollectionDestinations(onOpenPeriodicals, onOpenDesiderata, onOpenArchives, onOpenArticles) } + item { + EmptyState( + title = stringResource(R.string.home_empty_title), subtitle = stringResource(R.string.home_empty_subtitle), + icon = Icons.AutoMirrored.Outlined.MenuBook, actionLabel = stringResource(R.string.home_browse_catalog), + onAction = onBrowseCatalog, + ) + } } HomePhase.Content -> LazyColumn( @@ -100,23 +151,22 @@ fun HomeScreen( contentPadding = PaddingValues(bottom = Spacing.xxl), verticalArrangement = Arrangement.spacedBy(Spacing.md), ) { - item { HomeHeader(libraryName = state.libraryName, catalogueMode = catalogueMode) } + item { HomeHeader(libraryName = state.libraryName, catalogueMode = catalogueMode, books = state.available, onBookClick = onBookClick, onSearch = onSearch, query = heroQuery, onQueryChange = { heroQuery = it }) } + item { CollectionDestinations(onOpenPeriodicals, onOpenDesiderata, onOpenArchives, onOpenArticles) } item { Box(Modifier.padding(horizontal = Spacing.lg)) { - SectionHeader(showSeeAll = true, catalogueMode = catalogueMode, onSeeAll = onBrowseCatalog) + SectionHeader(showSeeAll = true, catalogueMode = catalogueMode, onSeeAll = onBrowseCatalog, sort = state.sort, onSort = onSort) } } - items(state.available, key = { it.id }) { book -> - Box(Modifier.padding(horizontal = Spacing.lg)) { - BookCard( - title = book.title, - author = book.authorsLabel, - coverUrl = book.coverUrl, - status = book.availabilityStatus(), - year = book.year?.toString(), - publisher = book.publisher, - onClick = { onBookClick(book.id) }, - ) + items(state.available.chunked(2), key = { row -> row.joinToString("-") { it.id.toString() } }) { row -> + Row(Modifier.padding(horizontal = Spacing.lg, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(14.dp)) { + row.forEach { book -> + BookCardGrid(title = book.title, author = book.authorsLabel, coverUrl = book.coverUrl, + status = book.availabilityStatus(), subtitle = book.subtitle, publisher = book.publisher, + year = book.year?.toString(), mediaType = book.mediaType, + onClick = { onBookClick(book.id) }, modifier = Modifier.weight(1f)) + } + if (row.size == 1) Spacer(Modifier.weight(1f)) } } item { @@ -126,6 +176,7 @@ fun HomeScreen( onClick = onBrowseCatalog, modifier = Modifier.fillMaxWidth(), leadingIcon = Icons.Outlined.AutoStories, + dark = true, ) } } @@ -136,49 +187,57 @@ fun HomeScreen( private enum class HomePhase { Loading, Error, Empty, Content } -/** Minimal plain-surface header: a quiet greeting, the library name in magenta, a subtitle. */ +/** The library identity and the real shelf covers, never a decorative stock photo. */ @Composable -private fun HomeHeader(libraryName: String?, catalogueMode: Boolean) { - Column( - Modifier - .fillMaxWidth() - .padding(horizontal = Spacing.lg) - .padding(top = Spacing.xl, bottom = Spacing.md), - ) { - Text( - text = stringResource(R.string.home_greeting), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(Spacing.xxs)) - Text( - text = libraryName ?: stringResource(R.string.app_name), - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.height(Spacing.xs)) - Text( - // In CATALOGUE-ONLY MODE the catalog is read-only, so avoid "borrow" wording. - text = if (catalogueMode) stringResource(R.string.home_subtitle_catalogue) - else stringResource(R.string.home_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - // Subtle one-time-style caption that this is a browse-only library. Minimal, no banner. +private fun HomeHeader(libraryName: String?, catalogueMode: Boolean, books: List, + onBookClick: (Int) -> Unit, onSearch: (String) -> Unit, query: String, onQueryChange: (String) -> Unit) { + val colors = LocalPinakesColors.current + val centered = colors.heroStyle == HeroStyle.Centered + Column(Modifier.fillMaxWidth().background(Brush.verticalGradient(listOf(colors.accentSofter, colors.background))) + .padding(horizontal = Spacing.lg, vertical = Spacing.xxl), + horizontalAlignment = if (centered) Alignment.CenterHorizontally else Alignment.Start) { + Text(stringResource(R.string.home_greeting), style = MaterialTheme.typography.labelMedium, color = colors.muted) + Spacer(Modifier.height(12.dp)) + val name = libraryName ?: stringResource(R.string.app_name) + val lastSpace = name.lastIndexOf(' ') + Text(buildAnnotatedString { + if (lastSpace >= 0) append(name.substring(0, lastSpace + 1)) + withStyle(SpanStyle(color = colors.accentText, fontStyle = FontStyle.Italic)) { append(name.substring(lastSpace + 1)) } + }, style = MaterialTheme.typography.displaySmall, + textAlign = if (centered) androidx.compose.ui.text.style.TextAlign.Center else androidx.compose.ui.text.style.TextAlign.Start) + Spacer(Modifier.height(12.dp)) + Text(if (catalogueMode) stringResource(R.string.home_subtitle_catalogue) else stringResource(R.string.home_subtitle), + style = MaterialTheme.typography.bodyMedium, color = colors.muted) if (catalogueMode) { - Spacer(Modifier.height(Spacing.xs)) - Text( - text = stringResource(R.string.home_browse_only_note), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) + Spacer(Modifier.height(8.dp)) + Text(stringResource(R.string.home_browse_only_note), style = MaterialTheme.typography.labelMedium, color = colors.accentText) + } + Spacer(Modifier.height(24.dp)) + SearchField(query, onQueryChange, Modifier.fillMaxWidth(), + placeholder = stringResource(R.string.restyle_search_catalog), onSearch = { onSearch(query) }) + Spacer(Modifier.height(8.dp)) + PrimaryButton(stringResource(R.string.cd_search), { onSearch(query) }, modifier = Modifier.fillMaxWidth(), + leadingIcon = Icons.Outlined.Search) + if (!centered) { + val fan = books.filter { com.pinakes.app.ui.components.bookCoverImageUrl(it.coverUrl) != null }.take(4) + if (fan.isNotEmpty()) { + Box(Modifier.fillMaxWidth().height(210.dp).padding(top = 28.dp), contentAlignment = Alignment.Center) { + fan.forEachIndexed { index, book -> + val position = index - (fan.size - 1) / 2f + BookCover(book.title, book.coverUrl, Modifier.width(96.dp).height(144.dp) + .offset(x = (position * 58).dp, y = (kotlin.math.abs(position) * 7).dp) + .graphicsLayer { rotationZ = position * 8 } + .clickable { onBookClick(book.id) }, author = book.authorsLabel, publisher = book.publisher) + } + } + } } } } @Composable -private fun SectionHeader(showSeeAll: Boolean, catalogueMode: Boolean, onSeeAll: () -> Unit) { +private fun SectionHeader(showSeeAll: Boolean, catalogueMode: Boolean, onSeeAll: () -> Unit, sort: BookSort = BookSort.NEWEST, onSort: (BookSort) -> Unit = {}) { + var choosingSort by remember { mutableStateOf(false) } Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -187,12 +246,19 @@ private fun SectionHeader(showSeeAll: Boolean, catalogueMode: Boolean, onSeeAll: Text( // In CATALOGUE-ONLY MODE the shelf isn't about borrowing — it's the newest // titles added to the library, so label it "Recently added" to match. - text = if (catalogueMode) stringResource(R.string.home_section_recent) + text = if (sort != BookSort.NEWEST) stringResource(sort.labelRes) else if (catalogueMode) stringResource(R.string.home_section_recent) else stringResource(R.string.home_section_available), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface, ) + Box { + androidx.compose.material3.TextButton({ choosingSort = true }) { Text(stringResource(R.string.sort_label, stringResource(sort.labelRes))) } + DropdownMenu(choosingSort, { choosingSort = false }) { + listOf(BookSort.NEWEST, BookSort.TITLE_ASC, BookSort.AUTHOR_ASC).forEach { value -> + DropdownMenuItem(text = { Text(stringResource(value.labelRes)) }, onClick = { choosingSort = false; onSort(value) }) + } + } + } Text( text = if (catalogueMode) stringResource(R.string.home_section_recent_subtitle) else stringResource(R.string.home_section_available_subtitle), @@ -211,3 +277,14 @@ private fun SectionHeader(showSeeAll: Boolean, catalogueMode: Boolean, onSeeAll: } } } + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun CollectionDestinations(periodicals: (() -> Unit)?, desiderata: (() -> Unit)?, archives: (() -> Unit)?, articles: (() -> Unit)?) { + FlowRow(Modifier.fillMaxWidth().padding(horizontal = Spacing.lg), horizontalArrangement = Arrangement.spacedBy(Spacing.sm), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + articles?.let { androidx.compose.material3.TextButton(it) { Text(stringResource(R.string.standalone_articles_title)) } } + periodicals?.let { androidx.compose.material3.TextButton(it) { Text(stringResource(R.string.periodicals_title)) } } + desiderata?.let { androidx.compose.material3.TextButton(it) { Text(stringResource(R.string.desiderata_title)) } } + archives?.let { androidx.compose.material3.TextButton(it) { Text(stringResource(R.string.archives_title)) } } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt index 07cfaa4..dbed6a7 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt @@ -1,5 +1,7 @@ package com.pinakes.app.ui.screens.home +import androidx.lifecycle.SavedStateHandle +import com.pinakes.app.ui.screens.search.BookSort import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.pinakes.app.data.model.BookSummary @@ -28,6 +30,7 @@ data class HomeUiState( val available: List = emptyList(), val loading: Boolean = true, val error: String? = null, + val sort: BookSort = BookSort.NEWEST, ) { val isEmpty: Boolean get() = !loading && error == null && available.isEmpty() } @@ -37,10 +40,11 @@ class HomeViewModel @Inject constructor( private val catalog: CatalogRepository, private val session: SessionStore, private val features: FeatureStore, + private val saved: SavedStateHandle, ) : ViewModel() { private val _state = MutableStateFlow( - HomeUiState(libraryName = session.libraryName?.takeIf { it.isNotBlank() }) + HomeUiState(libraryName = session.libraryName?.takeIf { it.isNotBlank() }, sort = saved.get("sort")?.let { name -> BookSort.entries.firstOrNull { it.name == name } } ?: BookSort.NEWEST) ) val state: StateFlow = _state.asStateFlow() @@ -50,6 +54,7 @@ class HomeViewModel @Inject constructor( * the cache only holds the first unfiltered page, a subset of the real answer. */ private var hasFreshShelf = false + private var generation = 0 init { observeCache() @@ -92,7 +97,7 @@ class HomeViewModel @Inject constructor( catalog.observeCachedCatalog(), catalogueMode(), ) { books, catalogueMode -> books to catalogueMode }.collectLatest { (books, catalogueMode) -> - if (hasFreshShelf) return@collectLatest + if (hasFreshShelf || _state.value.sort != BookSort.NEWEST) return@collectLatest val shelfBooks = if (catalogueMode) books else books.filter { it.available } @@ -120,6 +125,8 @@ class HomeViewModel @Inject constructor( * an error when there is nothing cached to fall back on. */ fun refresh() { + val request = ++generation + val sort = _state.value.sort viewModelScope.launch { // Catalogue-only mode labels the shelf "Recently added / latest additions", so // query the newest titles unfiltered (no availability filter) to make the label @@ -128,9 +135,11 @@ class HomeViewModel @Inject constructor( val shelfFilters = if (features.features.value.catalogueMode) SearchFilters() else SearchFilters(availableOnly = true) - val shelf = async { catalog.search(shelfFilters, limit = SHELF_LIMIT) } + val shelf = async { catalog.search(shelfFilters, limit = SHELF_LIMIT, sort = sort.apiValue) } launch { catalog.refreshCatalog() } - when (val res = shelf.await()) { + val res = shelf.await() + if (request != generation) return@launch + when (res) { is ApiResult.Success -> { hasFreshShelf = true _state.update { @@ -142,7 +151,7 @@ class HomeViewModel @Inject constructor( _state.update { it.copy( loading = false, - error = if (hasCache) null else res.message.takeIf { m -> m.isNotBlank() }, + error = if (hasCache && _state.value.available.isNotEmpty()) null else res.message, ) } } @@ -150,6 +159,14 @@ class HomeViewModel @Inject constructor( } } + fun setSort(sort: BookSort) { + if (sort !in listOf(BookSort.NEWEST, BookSort.TITLE_ASC, BookSort.AUTHOR_ASC) || sort == _state.value.sort) return + saved["sort"] = sort.name + hasFreshShelf = true + _state.update { it.copy(sort = sort, loading = true, available = emptyList(), error = null) } + refresh() + } + fun retry() = refresh() private companion object { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/login/ForgotPasswordScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/login/ForgotPasswordScreen.kt index 5368c8d..fac759a 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/login/ForgotPasswordScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/login/ForgotPasswordScreen.kt @@ -89,71 +89,63 @@ fun ForgotPasswordScreen(onBackToLogin: () -> Unit) { val form = Modifier.fillMaxWidth().widthIn(max = 420.dp) val errorMessage = state.error ?: state.errorRes?.let { stringResource(it) } - Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = Spacing.xl), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text( - text = stringResource(R.string.forgot_password_title), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - ) - Spacer(Modifier.height(Spacing.sm)) - Text( - text = stringResource(R.string.forgot_password_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = form, - ) + com.pinakes.app.ui.components.AuthForm { + Text( + text = stringResource(R.string.forgot_password_title), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(Spacing.sm)) + Text( + text = stringResource(R.string.forgot_password_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = form, + ) - Spacer(Modifier.height(Spacing.xxl)) + Spacer(Modifier.height(Spacing.xxl)) - if (state.sent) { - Surface(shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.primaryContainer, modifier = form) { - Text( - text = stringResource(R.string.forgot_password_sent), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.padding(Spacing.md), - ) - } - Spacer(Modifier.height(Spacing.xl)) - PrimaryButton( - label = stringResource(R.string.auth_back_to_login), - onClick = onBackToLogin, - modifier = form, - ) - } else { - PinakesTextField( - value = state.email, - onValueChange = vm::onEmailChange, - label = stringResource(R.string.login_email_label), - modifier = form, - isError = errorMessage != null, - errorText = errorMessage.orEmpty(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { vm.submit() }), - ) - Spacer(Modifier.height(Spacing.xl)) - PrimaryButton( - label = stringResource(R.string.forgot_password_action), - onClick = vm::submit, - modifier = form, - loading = state.loading, - ) - Spacer(Modifier.height(Spacing.sm)) - PinakesTextButton( - label = stringResource(R.string.auth_back_to_login), - onClick = onBackToLogin, + if (state.sent) { + Surface(shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.primaryContainer, modifier = form) { + Text( + text = stringResource(R.string.forgot_password_sent), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(Spacing.md), ) } + Spacer(Modifier.height(Spacing.xl)) + PrimaryButton( + label = stringResource(R.string.auth_back_to_login), + onClick = onBackToLogin, + modifier = form, + ) + } else { + PinakesTextField( + value = state.email, + onValueChange = vm::onEmailChange, + label = stringResource(R.string.login_email_label), + modifier = form, + isError = errorMessage != null, + errorText = errorMessage.orEmpty(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { vm.submit() }), + ) + Spacer(Modifier.height(Spacing.xl)) + PrimaryButton( + label = stringResource(R.string.forgot_password_action), + onClick = vm::submit, + modifier = form, + loading = state.loading, + ) + Spacer(Modifier.height(Spacing.sm)) + PinakesTextButton( + label = stringResource(R.string.auth_back_to_login), + onClick = onBackToLogin, + ) } } + } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/login/LoginScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/login/LoginScreen.kt index 5d9dfdd..7397267 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/login/LoginScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/login/LoginScreen.kt @@ -37,11 +37,7 @@ import com.pinakes.app.ui.components.PinakesTextField import com.pinakes.app.ui.components.PrimaryButton import com.pinakes.app.ui.theme.Spacing -/** - * Minimal login: a vertically centered column on a plain surface. Bare magenta logo, a small - * title, two clearly readable fields, a solid magenta sign-in button, and a quiet "use a - * different library" text button. No gradient, no colored panel. (DESIGN.md.) - */ +/** Sign-in form on the shared 2026 wash and surface, preserving all account links. */ @Composable fun LoginScreen( onLoggedIn: () -> Unit, @@ -58,112 +54,103 @@ fun LoginScreen( if (state.errorArg != null) stringResource(res, state.errorArg!!) else stringResource(res) } - Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = Spacing.xl), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - val form = Modifier.fillMaxWidth().widthIn(max = 420.dp) + com.pinakes.app.ui.components.AuthForm { + val form = Modifier.fillMaxWidth().widthIn(max = 420.dp) - Spacer(Modifier.height(Spacing.xxxl)) + Spacer(Modifier.height(Spacing.xxxl)) - // Bare logo — no circle, ring, or card. - Image( - painter = painterResource(R.drawable.brand_logo), - contentDescription = stringResource(R.string.app_name), - modifier = Modifier.height(48.dp), - ) + // Bare logo — no circle, ring, or card. + Image( + painter = painterResource(R.drawable.brand_logo), + contentDescription = stringResource(R.string.app_name), + modifier = Modifier.height(48.dp), + ) - Spacer(Modifier.height(Spacing.xxl)) + Spacer(Modifier.height(Spacing.xxl)) + Text( + text = state.libraryName.ifBlank { stringResource(R.string.login_welcome_back) }, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + if (state.instanceOrigin.isNotBlank()) { + Spacer(Modifier.height(Spacing.xs)) Text( - text = state.libraryName.ifBlank { stringResource(R.string.login_welcome_back) }, - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, + text = state.instanceOrigin, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) - if (state.instanceOrigin.isNotBlank()) { - Spacer(Modifier.height(Spacing.xs)) - Text( - text = state.instanceOrigin, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - } + } - Spacer(Modifier.height(Spacing.xxl)) + Spacer(Modifier.height(Spacing.xxl)) - PinakesTextField( - value = state.email, - onValueChange = vm::onEmailChange, - label = stringResource(R.string.login_email_label), - modifier = form, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next, - ), - ) + PinakesTextField( + value = state.email, + onValueChange = vm::onEmailChange, + label = stringResource(R.string.login_email_label), + modifier = form, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + ), + ) + Spacer(Modifier.height(Spacing.md)) + PasswordField( + value = state.password, + onValueChange = vm::onPasswordChange, + modifier = form, + isError = errorMessage != null, + imeAction = ImeAction.Done, + keyboardActions = KeyboardActions(onDone = { vm.login(onLoggedIn) }), + ) + + if (errorMessage != null) { Spacer(Modifier.height(Spacing.md)) - PasswordField( - value = state.password, - onValueChange = vm::onPasswordChange, + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.errorContainer, modifier = form, - isError = errorMessage != null, - imeAction = ImeAction.Done, - keyboardActions = KeyboardActions(onDone = { vm.login(onLoggedIn) }), - ) - - if (errorMessage != null) { - Spacer(Modifier.height(Spacing.md)) - Surface( - shape = MaterialTheme.shapes.small, - color = MaterialTheme.colorScheme.errorContainer, - modifier = form, - ) { - Text( - text = errorMessage, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer, - modifier = Modifier.padding(Spacing.md), - ) - } + ) { + Text( + text = errorMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(Spacing.md), + ) } + } - Spacer(Modifier.height(Spacing.xl)) - PrimaryButton( - label = stringResource(R.string.login_action_sign_in), - onClick = { vm.login(onLoggedIn) }, - modifier = form, - loading = state.loading, - ) - Spacer(Modifier.height(Spacing.sm)) - Box(modifier = form, contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(Modifier.height(Spacing.xl)) + PrimaryButton( + label = stringResource(R.string.login_action_sign_in), + onClick = { vm.login(onLoggedIn) }, + modifier = form, + loading = state.loading, + ) + Spacer(Modifier.height(Spacing.sm)) + Box(modifier = form, contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + PinakesTextButton( + label = stringResource(R.string.login_forgot_password), + onClick = onForgotPassword, + ) + if (features.registrationEnabled) { PinakesTextButton( - label = stringResource(R.string.login_forgot_password), - onClick = onForgotPassword, + label = stringResource(R.string.login_create_account), + onClick = onRegister, ) - if (features.registrationEnabled) { - PinakesTextButton( - label = stringResource(R.string.login_create_account), - onClick = onRegister, - ) - } } } - Spacer(Modifier.height(Spacing.sm)) - Box(modifier = form, contentAlignment = Alignment.Center) { - PinakesTextButton( - label = stringResource(R.string.login_use_different_library), - onClick = { vm.changeLibrary(onChangeLibrary) }, - ) - } - Spacer(Modifier.height(Spacing.xxxl)) } - } + Spacer(Modifier.height(Spacing.sm)) + Box(modifier = form, contentAlignment = Alignment.Center) { + PinakesTextButton( + label = stringResource(R.string.login_use_different_library), + onClick = { vm.changeLibrary(onChangeLibrary) }, + ) + } + Spacer(Modifier.height(Spacing.xxxl)) +} } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/login/RegisterScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/login/RegisterScreen.kt index 276f559..0b65c44 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/login/RegisterScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/login/RegisterScreen.kt @@ -225,181 +225,173 @@ fun RegisterScreen(onBackToLogin: () -> Unit) { val form = Modifier.fillMaxWidth().widthIn(max = 420.dp) val errorMessage = state.error ?: state.errorRes?.let { stringResource(it) } - Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = Spacing.xl), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text( - text = stringResource(R.string.register_title), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - ) - Spacer(Modifier.height(Spacing.sm)) - Text( - text = stringResource(R.string.register_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = form, - ) + com.pinakes.app.ui.components.AuthForm { + Text( + text = stringResource(R.string.register_title), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(Spacing.sm)) + Text( + text = stringResource(R.string.register_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = form, + ) - Spacer(Modifier.height(Spacing.xxl)) + Spacer(Modifier.height(Spacing.xxl)) - if (!features.registrationEnabled) { - Surface(shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = form) { - Text( - text = stringResource(R.string.register_disabled), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(Spacing.md), - ) - } - Spacer(Modifier.height(Spacing.xl)) - PrimaryButton(label = stringResource(R.string.auth_back_to_login), onClick = onBackToLogin, modifier = form) - return@Column + if (!features.registrationEnabled) { + Surface(shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = form) { + Text( + text = stringResource(R.string.register_disabled), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(Spacing.md), + ) } + Spacer(Modifier.height(Spacing.xl)) + PrimaryButton(label = stringResource(R.string.auth_back_to_login), onClick = onBackToLogin, modifier = form) + return@AuthForm + } - if (state.sent) { - Surface(shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.primaryContainer, modifier = form) { - Text( - text = stringResource(R.string.register_sent), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.padding(Spacing.md), - ) - } - Spacer(Modifier.height(Spacing.xl)) - PrimaryButton(label = stringResource(R.string.auth_back_to_login), onClick = onBackToLogin, modifier = form) - return@Column + if (state.sent) { + Surface(shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.primaryContainer, modifier = form) { + Text( + text = stringResource(R.string.register_sent), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(Spacing.md), + ) } + Spacer(Modifier.height(Spacing.xl)) + PrimaryButton(label = stringResource(R.string.auth_back_to_login), onClick = onBackToLogin, modifier = form) + return@AuthForm + } - PinakesTextField( - value = state.nome, - onValueChange = vm::onNomeChange, - label = requiredLabel(stringResource(R.string.profile_first_name), true), - modifier = form, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - ) - Spacer(Modifier.height(Spacing.md)) - PinakesTextField( - value = state.cognome, - onValueChange = vm::onCognomeChange, - label = requiredLabel(stringResource(R.string.profile_last_name), state.builtinRequired("cognome")), - modifier = form, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - ) - Spacer(Modifier.height(Spacing.md)) - PinakesTextField( - value = state.email, - onValueChange = vm::onEmailChange, - label = requiredLabel(stringResource(R.string.login_email_label), true), - modifier = form, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next), - ) - Spacer(Modifier.height(Spacing.md)) - PinakesTextField( - value = state.telefono, - onValueChange = vm::onTelefonoChange, - label = requiredLabel(stringResource(R.string.register_phone), state.builtinRequired("telefono")), - modifier = form, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone, imeAction = ImeAction.Next), - ) + PinakesTextField( + value = state.nome, + onValueChange = vm::onNomeChange, + label = requiredLabel(stringResource(R.string.profile_first_name), true), + modifier = form, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + ) + Spacer(Modifier.height(Spacing.md)) + PinakesTextField( + value = state.cognome, + onValueChange = vm::onCognomeChange, + label = requiredLabel(stringResource(R.string.profile_last_name), state.builtinRequired("cognome")), + modifier = form, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + ) + Spacer(Modifier.height(Spacing.md)) + PinakesTextField( + value = state.email, + onValueChange = vm::onEmailChange, + label = requiredLabel(stringResource(R.string.login_email_label), true), + modifier = form, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next), + ) + Spacer(Modifier.height(Spacing.md)) + PinakesTextField( + value = state.telefono, + onValueChange = vm::onTelefonoChange, + label = requiredLabel(stringResource(R.string.register_phone), state.builtinRequired("telefono")), + modifier = form, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone, imeAction = ImeAction.Next), + ) + Spacer(Modifier.height(Spacing.md)) + PinakesTextField( + value = state.indirizzo, + onValueChange = vm::onIndirizzoChange, + label = requiredLabel(stringResource(R.string.register_address), state.builtinRequired("indirizzo")), + modifier = form, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + ) + // Schema fetch failed: registration still works with the built-in + // defaults, but tell the user extra fields may be missing and let + // them retry rather than silently omitting required custom fields. + if (state.schemaFailed) { Spacer(Modifier.height(Spacing.md)) - PinakesTextField( - value = state.indirizzo, - onValueChange = vm::onIndirizzoChange, - label = requiredLabel(stringResource(R.string.register_address), state.builtinRequired("indirizzo")), - modifier = form, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - ) - // Schema fetch failed: registration still works with the built-in - // defaults, but tell the user extra fields may be missing and let - // them retry rather than silently omitting required custom fields. - if (state.schemaFailed) { - Spacer(Modifier.height(Spacing.md)) - Surface(shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = form) { - Row( - modifier = Modifier.padding(Spacing.md), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.register_schema_failed), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - PinakesTextButton(label = stringResource(R.string.action_retry), onClick = vm::loadSchema) - } + Surface(shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = form) { + Row( + modifier = Modifier.padding(Spacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.register_schema_failed), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + PinakesTextButton(label = stringResource(R.string.action_retry), onClick = vm::loadSchema) } } - // Instance-defined custom fields, rendered by type. - state.customFields.forEach { def -> - Spacer(Modifier.height(Spacing.md)) - CustomFieldInput( - def = def, - value = state.customValues[def.id].orEmpty(), - onValueChange = { vm.onCustomFieldChange(def.id, it) }, - modifier = form, - ) - } + } + // Instance-defined custom fields, rendered by type. + state.customFields.forEach { def -> Spacer(Modifier.height(Spacing.md)) - PasswordField( - value = state.password, - onValueChange = vm::onPasswordChange, + CustomFieldInput( + def = def, + value = state.customValues[def.id].orEmpty(), + onValueChange = { vm.onCustomFieldChange(def.id, it) }, modifier = form, - imeAction = ImeAction.Next, ) - Spacer(Modifier.height(Spacing.md)) - PasswordField( - value = state.passwordConfirm, - onValueChange = vm::onPasswordConfirmChange, - label = stringResource(R.string.register_confirm_password), - modifier = form, - imeAction = ImeAction.Done, - keyboardActions = KeyboardActions(onDone = { vm.submit() }), + } + Spacer(Modifier.height(Spacing.md)) + PasswordField( + value = state.password, + onValueChange = vm::onPasswordChange, + modifier = form, + imeAction = ImeAction.Next, + ) + Spacer(Modifier.height(Spacing.md)) + PasswordField( + value = state.passwordConfirm, + onValueChange = vm::onPasswordConfirmChange, + label = stringResource(R.string.register_confirm_password), + modifier = form, + imeAction = ImeAction.Done, + keyboardActions = KeyboardActions(onDone = { vm.submit() }), + ) + Spacer(Modifier.height(Spacing.md)) + Row(modifier = form, verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = state.privacyAccepted, onCheckedChange = vm::onPrivacyChange) + Text( + text = stringResource(R.string.register_privacy), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) + } + + if (errorMessage != null) { Spacer(Modifier.height(Spacing.md)) - Row(modifier = form, verticalAlignment = Alignment.CenterVertically) { - Checkbox(checked = state.privacyAccepted, onCheckedChange = vm::onPrivacyChange) + Surface(shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.errorContainer, modifier = form) { Text( - text = stringResource(R.string.register_privacy), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = errorMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(Spacing.md), ) } - - if (errorMessage != null) { - Spacer(Modifier.height(Spacing.md)) - Surface(shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.errorContainer, modifier = form) { - Text( - text = errorMessage, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer, - modifier = Modifier.padding(Spacing.md), - ) - } - } - - Spacer(Modifier.height(Spacing.xl)) - PrimaryButton( - label = stringResource(R.string.register_action), - onClick = vm::submit, - modifier = form, - // Spinner + disabled while the account request is in flight OR - // the schema is still loading — the submit() guard also blocks - // the latter, this just reflects it visually. - loading = state.loading || state.schemaLoading, - ) - Spacer(Modifier.height(Spacing.sm)) - PinakesTextButton(label = stringResource(R.string.auth_back_to_login), onClick = onBackToLogin) } + + Spacer(Modifier.height(Spacing.xl)) + PrimaryButton( + label = stringResource(R.string.register_action), + onClick = vm::submit, + modifier = form, + // Spinner + disabled while the account request is in flight OR + // the schema is still loading — the submit() guard also blocks + // the latter, this just reflects it visually. + loading = state.loading || state.schemaLoading, + ) + Spacer(Modifier.height(Spacing.sm)) + PinakesTextButton(label = stringResource(R.string.auth_back_to_login), onClick = onBackToLogin) } + } /** Appends a " *" marker to a field label when the instance requires it. */ diff --git a/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingScreen.kt index 5b9e3b0..eddafdc 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingScreen.kt @@ -47,132 +47,121 @@ import com.pinakes.app.ui.theme.AvailableOnContainerDark import com.pinakes.app.ui.theme.AvailableOnContainerLight import com.pinakes.app.ui.theme.Spacing -/** - * Minimal onboarding: centered column on a plain surface, bare logo, a readable URL field, and - * the discovery result as a clean card. Solid magenta Continue. No gradient panel. (DESIGN.md.) - */ +/** Library discovery on the shared 2026 authentication surface. */ @Composable fun OnboardingScreen(onContinue: () -> Unit) { val vm: OnboardingViewModel = hiltViewModel() val state by vm.state.collectAsStateWithLifecycle() - Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = Spacing.xl), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - val form = Modifier.fillMaxWidth().widthIn(max = 420.dp) + com.pinakes.app.ui.components.AuthForm { + val form = Modifier.fillMaxWidth().widthIn(max = 420.dp) - Spacer(Modifier.height(Spacing.xxxl)) + Spacer(Modifier.height(Spacing.xxxl)) - // Bare logo — no circle, ring, or card. - Image( - painter = painterResource(R.drawable.brand_logo), - contentDescription = stringResource(R.string.app_name), - modifier = Modifier.height(52.dp), - ) + // Bare logo — no circle, ring, or card. + Image( + painter = painterResource(R.drawable.brand_logo), + contentDescription = stringResource(R.string.app_name), + modifier = Modifier.height(52.dp), + ) - Spacer(Modifier.height(Spacing.xl)) + Spacer(Modifier.height(Spacing.xl)) - Text( - text = stringResource(R.string.onboarding_title), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - ) - Spacer(Modifier.height(Spacing.sm)) - Text( - text = stringResource(R.string.onboarding_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) + Text( + text = stringResource(R.string.onboarding_title), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(Spacing.sm)) + Text( + text = stringResource(R.string.onboarding_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) - Spacer(Modifier.height(Spacing.xxl)) + Spacer(Modifier.height(Spacing.xxl)) - val errorMessage = state.error ?: state.errorRes?.let { stringResource(it) } - UrlField( - value = state.url, - onValueChange = vm::onUrlChange, - label = stringResource(R.string.onboarding_url_label), - modifier = form, - isError = errorMessage != null, - errorText = errorMessage.orEmpty(), - onDone = vm::discover, - ) + val errorMessage = state.error ?: state.errorRes?.let { stringResource(it) } + UrlField( + value = state.url, + onValueChange = vm::onUrlChange, + label = stringResource(R.string.onboarding_url_label), + modifier = form, + isError = errorMessage != null, + errorText = errorMessage.orEmpty(), + onDone = vm::discover, + ) - Spacer(Modifier.height(Spacing.lg)) + Spacer(Modifier.height(Spacing.lg)) - if (state.discovery == null) { - Row( - // Toggle from the whole row (not just the switch): a screen reader - // announces the label together with the on/off state, and the larger - // touch target is easier to hit. onCheckedChange = null makes the Switch - // a passive indicator of the row's toggle state. - modifier = form.toggleable( - value = state.allowInsecureHttp, - role = Role.Switch, - onValueChange = vm::onAllowInsecureChange, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f)) { - Text( - stringResource(R.string.onboarding_allow_http_label), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - stringResource(R.string.onboarding_allow_http_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Spacer(Modifier.width(Spacing.md)) - Switch( - checked = state.allowInsecureHttp, - onCheckedChange = null, + if (state.discovery == null) { + Row( + // Toggle from the whole row (not just the switch): a screen reader + // announces the label together with the on/off state, and the larger + // touch target is easier to hit. onCheckedChange = null makes the Switch + // a passive indicator of the row's toggle state. + modifier = form.toggleable( + value = state.allowInsecureHttp, + role = Role.Switch, + onValueChange = vm::onAllowInsecureChange, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + stringResource(R.string.onboarding_allow_http_label), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + stringResource(R.string.onboarding_allow_http_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - - Spacer(Modifier.height(Spacing.lg)) - - PrimaryButton( - label = stringResource(R.string.action_discover_library), - onClick = vm::discover, - modifier = form, - loading = state.checking, + Spacer(Modifier.width(Spacing.md)) + Switch( + checked = state.allowInsecureHttp, + onCheckedChange = null, ) } - state.discovery?.let { d -> + Spacer(Modifier.height(Spacing.lg)) + + PrimaryButton( + label = stringResource(R.string.action_discover_library), + onClick = vm::discover, + modifier = form, + loading = state.checking, + ) + } + + state.discovery?.let { d -> + Spacer(Modifier.height(Spacing.sm)) + DiscoveryCard(d, modifier = form) + Spacer(Modifier.height(Spacing.lg)) + val canContinue = d.health.appAccessEnabled && d.transportAllowed + PrimaryButton( + label = stringResource(R.string.action_continue), + onClick = { if (vm.confirm()) onContinue() }, + modifier = form, + enabled = canContinue, + ) + if (!d.transportAllowed) { Spacer(Modifier.height(Spacing.sm)) - DiscoveryCard(d, modifier = form) - Spacer(Modifier.height(Spacing.lg)) - val canContinue = d.health.appAccessEnabled && d.transportAllowed - PrimaryButton( - label = stringResource(R.string.action_continue), - onClick = { if (vm.confirm()) onContinue() }, - modifier = form, - enabled = canContinue, + Text( + stringResource(R.string.onboarding_http_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, ) - if (!d.transportAllowed) { - Spacer(Modifier.height(Spacing.sm)) - Text( - stringResource(R.string.onboarding_http_warning), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center, - ) - } } - Spacer(Modifier.height(Spacing.xxxl)) } + Spacer(Modifier.height(Spacing.xxxl)) } + } @Composable @@ -185,16 +174,16 @@ private fun DiscoveryCard(d: HealthDiscovery, modifier: Modifier = Modifier) { Column(Modifier.padding(Spacing.lg)) { Row(verticalAlignment = Alignment.CenterVertically) { Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.primaryContainer, + shape = if (d.health.logo != null) androidx.compose.ui.graphics.RectangleShape else MaterialTheme.shapes.small, + color = if (d.health.logo != null) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.primaryContainer, modifier = Modifier.size(48.dp), ) { if (d.health.logo != null) { SubcomposeAsyncImage( model = d.health.logo, contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.clip(CircleShape), + contentScale = ContentScale.Fit, + modifier = Modifier, ) } else { Column( @@ -256,7 +245,7 @@ private fun StatusLine(ok: Boolean, okText: String, warnText: String) { ) { // "secure / app-access OK" is a positive status → availability-green token (the only // extra semantic colour allowed). Warnings use the error role. - val okTint = if (isSystemInDarkTheme()) AvailableOnContainerDark else AvailableOnContainerLight + val okTint = if (com.pinakes.app.ui.theme.LocalPinakesColors.current.darkMode) AvailableOnContainerDark else AvailableOnContainerLight val tint = if (ok) okTint else MaterialTheme.colorScheme.error Icon( imageVector = if (ok) Icons.Outlined.CheckCircle else Icons.Outlined.Warning, diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueDetailScreen.kt index a47d243..fce09e5 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueDetailScreen.kt @@ -1,5 +1,6 @@ package com.pinakes.app.ui.screens.periodicals +import com.pinakes.app.ui.theme.PublicationTitleStyle import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -18,6 +19,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface +import androidx.compose.material3.TextButton import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable @@ -52,7 +54,7 @@ import com.pinakes.app.ui.theme.Spacing */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun IssueDetailScreen(onNavigateUp: () -> Unit) { +fun IssueDetailScreen(onNavigateUp: () -> Unit, onOpenArticle: (Int) -> Unit = {}, onFindArticles: (Int) -> Unit = {}) { val vm: IssueDetailViewModel = hiltViewModel() val state by vm.state.collectAsStateWithLifecycle() val context = LocalContext.current @@ -97,10 +99,18 @@ fun IssueDetailScreen(onNavigateUp: () -> Unit) { ) } } + item { TextButton({ onFindArticles(issue.id) }) { Text(stringResource(if (issue.cataloguedArticlesTruncated) R.string.collection_more else R.string.article_issue_search)) } } + if (issue.cataloguedArticles.isNotEmpty()) { + item { Text(stringResource(R.string.article_catalogued), style = MaterialTheme.typography.titleMedium) } + items(issue.cataloguedArticles.size) { index -> + val article = issue.cataloguedArticles[index] + StandaloneArticleRow(article) { onOpenArticle(article.id) } + } + } if (issue.articles.isNotEmpty()) { item { Text( - stringResource(R.string.periodicals_articles_section), + stringResource(R.string.article_toc), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(top = Spacing.sm), @@ -135,7 +145,7 @@ private fun IssueHeader(issue: PeriodicalIssueDetail) { Spacer(Modifier.height(Spacing.md)) Text( issueHeading(issue.number, issue.title), - style = MaterialTheme.typography.titleMedium, + style = PublicationTitleStyle, color = MaterialTheme.colorScheme.onSurface, ) val yearLine = issue.year?.let { y -> @@ -191,7 +201,7 @@ private fun ArticleRow(article: IssueArticle) { Column(Modifier.weight(1f)) { Text( article.title, - style = MaterialTheme.typography.bodyLarge, + style = PublicationTitleStyle, color = MaterialTheme.colorScheme.onSurface, ) article.authors?.takeIf { it.isNotBlank() }?.let { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListScreen.kt index c0f43e6..0d8914e 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListScreen.kt @@ -1,5 +1,6 @@ package com.pinakes.app.ui.screens.periodicals +import com.pinakes.app.ui.theme.PublicationTitleStyle import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -177,7 +178,7 @@ private fun IssueRow(issue: PeriodicalIssue, onClick: () -> Unit) { Column(Modifier.weight(1f)) { Text( issueHeading(issue.number, issue.title), - style = MaterialTheme.typography.titleSmall, + style = PublicationTitleStyle, color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis, diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalDetailScreen.kt index dc98497..7ad046c 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalDetailScreen.kt @@ -129,7 +129,7 @@ private fun PeriodicalHeader(detail: PeriodicalDetail) { Column(Modifier.weight(1f)) { Text( detail.title, - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) detail.subtitle?.takeIf { it.isNotBlank() }?.let { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsScreen.kt index 3ac90fd..7cd6dfc 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsScreen.kt @@ -1,5 +1,6 @@ package com.pinakes.app.ui.screens.periodicals +import com.pinakes.app.ui.theme.PublicationTitleStyle import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement @@ -193,7 +194,7 @@ private fun PeriodicalCard(periodical: PeriodicalSummary, onClick: () -> Unit) { Column(Modifier.weight(1f)) { Text( periodical.title, - style = MaterialTheme.typography.titleSmall, + style = PublicationTitleStyle, color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis, @@ -244,7 +245,7 @@ fun PeriodicalLogo( model = url, contentDescription = contentDescription, modifier = modifier, - contentScale = ContentScale.Crop, + contentScale = ContentScale.Fit, loading = { PeriodicalLogoPlaceholder() }, error = { PeriodicalLogoPlaceholder() }, ) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUi.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUi.kt index 19d03a6..6c85a90 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUi.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUi.kt @@ -21,6 +21,7 @@ val PERIODICAL_TYPES = listOf("rivista", "giornale", "magazine", "bollettino", " /** Localized label for a masthead type. Unknown values fall back to the generic "rivista". */ @StringRes fun periodicalTypeLabelRes(type: String): Int = when (type) { + "antologia" -> R.string.article_anthology "giornale" -> R.string.periodicals_type_giornale "magazine" -> R.string.periodicals_type_magazine "bollettino" -> R.string.periodicals_type_bollettino diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticleScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticleScreen.kt index 532f1cf..5a0e773 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticleScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticleScreen.kt @@ -7,9 +7,11 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.OpenInNew import androidx.compose.material3.* import androidx.compose.material.icons.outlined.Refresh -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.dp import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel @@ -28,6 +30,8 @@ fun StandaloneArticleScreen( onNavigateUp: () -> Unit, onOpenPeriodical: (Int) -> Unit, onOpenIssue: (Int) -> Unit, + onFindWorks: (String, Int?) -> Unit = { _, _ -> }, + onFindArticles: (String, String, Int) -> Unit = { _, _, _ -> }, vm: StandaloneArticleViewModel = hiltViewModel(), ) { val state by vm.state.collectAsStateWithLifecycle() @@ -48,6 +52,9 @@ fun StandaloneArticleScreen( article = content.data, onOpenPdf = { content.data.publicPdfUrl?.let { openWeb(context, it) } }, onOpenPeriodical = onOpenPeriodical, onOpenIssue = onOpenIssue, + onOpenResource = { content.data.publicResourceUrl?.let { openWeb(context, it) } }, + onFindWorks = onFindWorks, onFindArticles = onFindArticles, + onOpenWeb = vm.webUrl?.let { url -> { openWeb(context, url) } }, ) } } @@ -60,22 +67,67 @@ internal fun StandaloneArticleContent( onOpenPdf: () -> Unit, onOpenPeriodical: (Int) -> Unit, onOpenIssue: (Int) -> Unit, + onOpenResource: () -> Unit = {}, + onOpenWeb: (() -> Unit)? = null, + onFindWorks: (String, Int?) -> Unit = { _, _ -> }, + onFindArticles: (String, String, Int) -> Unit = { _, _, _ -> }, ) { + val context = LocalContext.current LazyColumn(contentPadding = PaddingValues(Spacing.lg), verticalArrangement = Arrangement.spacedBy(Spacing.lg)) { + item { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + BookCover(article.title, article.coverUrl, Modifier.widthIn(max = 160.dp).fillMaxWidth().aspectRatio(2f / 3f)) + } + } item { SelectionContainer { Column(verticalArrangement = Arrangement.spacedBy(Spacing.md)) { Text(article.title, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface) - article.authors?.takeIf { it.isNotBlank() }?.let { - Text(it, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + article.subtitle?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + val names = article.authorCredits.map { it.name }.ifEmpty { article.authors.orEmpty().split(';').map(String::trim).filter(String::isNotBlank) } + names.forEach { name -> TextButton({ onFindWorks(name, article.authorCredits.firstOrNull { it.name == name }?.id) }) { Text(name) } } + article.authorCredits.forEach { credit -> + credit.identifiers.forEach { identifier -> Text(identifier.toString(), style = MaterialTheme.typography.bodySmall) } + } + } + } + } + item { CitationButton(article.citations, article.risUrl, article.marcXmlUrl) } + article.manageUrl?.let { url -> item { TextButton({ openWeb(context, url) }) { Text(stringResource(R.string.collection_manage)) } } } + if (article.canOpenPdf) item { + PrimaryButton(label = stringResource(R.string.periodicals_open_pdf), onClick = onOpenPdf, + modifier = Modifier.fillMaxWidth(), leadingIcon = Icons.AutoMirrored.Outlined.OpenInNew) + } + article.publicResourceAddress?.let { address -> + item { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + if (article.publicResourceUrl != null) { + TextButton(onClick = onOpenResource, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.AutoMirrored.Outlined.OpenInNew, contentDescription = null) + Spacer(Modifier.width(Spacing.sm)) + Text(article.resourceLabel?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.standalone_article_online_resource)) + } + } else { + SelectionContainer { ArticleMetadata(R.string.standalone_article_resource_reference, address) } + } + article.resourceAccess?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } } + onOpenWeb?.let { open -> + item { TextButton(onClick = open, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.standalone_article_open_web)) + } } + } item { SelectionContainer { Column(verticalArrangement = Arrangement.spacedBy(Spacing.md)) { - ArticleMetadata(R.string.standalone_article_publication, article.containerTitle) + article.containerTitle?.let { title -> TextButton({ onFindArticles(title, "", 0) }) { Text(title) } } article.containerType?.takeIf { it.isNotBlank() }?.let { ArticleMetadata(R.string.standalone_article_publication_type, stringResource(periodicalTypeLabelRes(it))) } @@ -85,18 +137,28 @@ internal fun StandaloneArticleContent( ArticleMetadata(R.string.standalone_article_pages_label, article.pages) ArticleMetadata(R.string.standalone_article_issn, article.issn) ArticleMetadata(R.string.standalone_article_doi, article.doi) - ArticleMetadata(R.string.standalone_article_keywords, article.keywords) + article.keywords.orEmpty().split(';', ',').map(String::trim).filter(String::isNotBlank).forEach { keyword -> + TextButton({ onFindArticles("", keyword, 0) }) { Text(keyword) } + } + article.genrePath.forEach { genre -> TextButton({ onFindArticles("", "", genre.id) }) { Text(genre.name) } } + ArticleMetadata(R.string.book_meta_language, article.language) + ArticleMetadata(R.string.article_country, article.country) + ArticleMetadata(R.string.article_classification, listOfNotNull(article.classificationScheme, article.classification).joinToString(" · ")) + ArticleMetadata(R.string.article_holdings, article.holdingsNote) + ArticleMetadata(R.string.article_editors, article.editors) + ArticleMetadata(R.string.book_meta_publisher, article.publisher) + ArticleMetadata(R.string.book_meta_publication_place, article.publicationPlace) + ArticleMetadata(R.string.book_meta_isbn13, article.isbn) + ArticleMetadata(R.string.book_meta_format, stringResource(when (article.medium) { + "cartaceo" -> R.string.article_print; "digitale" -> R.string.article_digital; "entrambi" -> R.string.article_both; else -> R.string.article_format_unknown + })) } } } article.description?.takeIf { it.isNotBlank() }?.let { description -> - item { SelectionContainer { Text(description, style = MaterialTheme.typography.bodyLarge, + item { SelectionContainer { HtmlText(description, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface) } } } - if (article.canOpenPdf) item { - PrimaryButton(label = stringResource(R.string.periodicals_open_pdf), onClick = onOpenPdf, - modifier = Modifier.fillMaxWidth(), leadingIcon = Icons.AutoMirrored.Outlined.OpenInNew) - } article.mastheadId?.takeIf { it > 0 }?.let { id -> item { TextButton(onClick = { onOpenPeriodical(id) }) { Text(stringResource(R.string.standalone_article_open_masthead)) } } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticleViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticleViewModel.kt index fb7530f..96c36bd 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticleViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticleViewModel.kt @@ -23,6 +23,7 @@ class StandaloneArticleViewModel @Inject constructor( private val id = savedStateHandle.get(Routes.ARG_ARTICLE_ID) ?: 0 private val mutableState = MutableStateFlow>(UiState.Loading) val state = mutableState.asStateFlow() + val webUrl: String? get() = source.articleWebUrl(id) private var generation = 0 init { refresh() } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticlesScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticlesScreen.kt index 5ecc364..25f8fae 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticlesScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticlesScreen.kt @@ -1,5 +1,6 @@ package com.pinakes.app.ui.screens.periodicals +import com.pinakes.app.ui.theme.PublicationTitleStyle import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -43,6 +44,7 @@ fun StandaloneArticlesScreen( placeholder = stringResource(R.string.standalone_articles_search), modifier = Modifier.fillMaxWidth().padding(Spacing.lg), ) + vm.activeFilters.forEach { Text(it, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = Spacing.lg)) } when { state.loading -> LoadingState(label = stringResource(R.string.standalone_articles_loading)) state.unavailable -> EmptyState( @@ -83,25 +85,34 @@ fun StandaloneArticlesScreen( } @Composable -private fun StandaloneArticleRow(article: StandaloneArticle, onClick: () -> Unit) { - Column( +internal fun StandaloneArticleRow(article: StandaloneArticle, onClick: () -> Unit) { + Row( Modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onClick).padding(vertical = Spacing.lg), - verticalArrangement = Arrangement.spacedBy(Spacing.xs), + horizontalArrangement = Arrangement.spacedBy(Spacing.md), ) { - Text(article.title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface) - article.authors?.takeIf { it.isNotBlank() }?.let { - Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface) - } - article.containerTitle?.takeIf { it.isNotBlank() }?.let { - Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + BookCover(article.title, article.coverUrl, Modifier.width(64.dp).height(96.dp), compact = true, author = article.authors, publisher = article.containerTitle) + Column( + Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(Spacing.xs), + ) { + Text(article.title, style = PublicationTitleStyle, color = MaterialTheme.colorScheme.onSurface) + article.subtitle?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + article.authors?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface) + } + article.containerTitle?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + val citation = listOfNotNull( + article.dateLabel, + article.volume?.takeIf { it.isNotBlank() }?.let { stringResource(R.string.standalone_article_volume, it) }, + article.number?.takeIf { it.isNotBlank() }?.let { stringResource(R.string.standalone_article_number, it) }, + article.pages?.takeIf { it.isNotBlank() }?.let { stringResource(R.string.standalone_article_pages, it) }, + ).joinToString(" · ") + if (citation.isNotBlank()) Text(citation, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) } - val citation = listOfNotNull( - article.dateLabel, - article.volume?.takeIf { it.isNotBlank() }?.let { stringResource(R.string.standalone_article_volume, it) }, - article.number?.takeIf { it.isNotBlank() }?.let { stringResource(R.string.standalone_article_number, it) }, - article.pages?.takeIf { it.isNotBlank() }?.let { stringResource(R.string.standalone_article_pages, it) }, - ).joinToString(" · ") - if (citation.isNotBlank()) Text(citation, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticlesViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticlesViewModel.kt index 8cfcfdc..875c29e 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticlesViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/StandaloneArticlesViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.pinakes.app.data.model.StandaloneArticle import com.pinakes.app.data.network.ApiResult import com.pinakes.app.data.repository.StandaloneArticlesSource +import com.pinakes.app.data.repository.ArticleFilters import com.pinakes.app.ui.navigation.Routes import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -33,7 +34,12 @@ class StandaloneArticlesViewModel @Inject constructor( savedStateHandle: SavedStateHandle, ) : ViewModel() { private val mastheadId = savedStateHandle.get(Routes.ARG_PERIODICAL_ID)?.takeIf { it > 0 } - private val mutableState = MutableStateFlow(StandaloneArticlesUiState()) + private val issueId = savedStateHandle.get(Routes.ARG_PERIODICAL_ISSUE_ID)?.takeIf { it > 0 } + private val container = savedStateHandle.get("container")?.takeIf { it.isNotBlank() } + private val keyword = savedStateHandle.get("keyword")?.takeIf { it.isNotBlank() } + private val genreId = savedStateHandle.get("genreId")?.takeIf { it > 0 } + val activeFilters = listOfNotNull(container, keyword) + private val mutableState = MutableStateFlow(StandaloneArticlesUiState(query = savedStateHandle.get("q").orEmpty())) val state = mutableState.asStateFlow() private var supported: Boolean? = null private var generation = 0 @@ -70,7 +76,7 @@ class StandaloneArticlesViewModel @Inject constructor( mutableState.update { it.copy(loading = false, unavailable = true) } return@launch } - val result = source.articles(query, mastheadId) + val result = source.searchArticles(ArticleFilters(query, mastheadId, issueId, genreId, container = container, keyword = keyword)) if (request != generation) return@launch when (result) { is ApiResult.Success -> mutableState.update { @@ -92,7 +98,7 @@ class StandaloneArticlesViewModel @Inject constructor( val request = generation mutableState.update { it.copy(loadingMore = true, moreError = false) } viewModelScope.launch { - val result = source.articles(current.query.takeIf { it.isNotBlank() }, mastheadId, current.nextCursor) + val result = source.searchArticles(ArticleFilters(current.query.takeIf { it.isNotBlank() }, mastheadId, issueId, genreId, container = container, keyword = keyword), current.nextCursor) if (request != generation) return@launch when (result) { is ApiResult.Success -> mutableState.update { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt index ec232da..79c6ddc 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material.icons.outlined.Groups import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.AutoStories import androidx.compose.material.icons.outlined.Newspaper import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.PhoneAndroid @@ -93,6 +94,9 @@ fun ProfileScreen( onOpenMyReviews: () -> Unit, onOpenBookClub: () -> Unit, onOpenPeriodicals: () -> Unit, + onOpenArticles: () -> Unit, + onOpenDesiderata: () -> Unit, + onOpenArchives: () -> Unit, ) { val app: AppViewModel = hiltViewModel() val vm: ProfileViewModel = hiltViewModel() @@ -123,6 +127,11 @@ fun ProfileScreen( onOpenMyReviews = onOpenMyReviews, onOpenBookClub = onOpenBookClub, onOpenPeriodicals = onOpenPeriodicals, + onOpenArticles = onOpenArticles, + onOpenDesiderata = onOpenDesiderata, + onOpenArchives = onOpenArchives, + showDesiderata = features.desiderataAvailable, + showArchives = features.archivesAvailable, showNotifications = features.notifications, showContact = features.messages, showReviews = features.showReviews, @@ -192,11 +201,16 @@ private fun ProfileContent( onOpenMyReviews: () -> Unit, onOpenBookClub: () -> Unit, onOpenPeriodicals: () -> Unit, + onOpenArticles: () -> Unit, + onOpenDesiderata: () -> Unit, + onOpenArchives: () -> Unit, showNotifications: Boolean, showContact: Boolean, showReviews: Boolean, showBookClub: Boolean, showPeriodicals: Boolean, + showDesiderata: Boolean, + showArchives: Boolean, ) { Column( Modifier @@ -267,9 +281,12 @@ private fun ProfileContent( if (showBookClub) { ActionRow(Icons.Outlined.Groups, stringResource(R.string.profile_action_book_club), onClick = onOpenBookClub) } + if (showPeriodicals) ActionRow(Icons.Outlined.Newspaper, stringResource(R.string.standalone_articles_title), onClick = onOpenArticles) if (showPeriodicals) { ActionRow(Icons.Outlined.Newspaper, stringResource(R.string.profile_action_periodicals), onClick = onOpenPeriodicals) } + if (showDesiderata) ActionRow(Icons.Outlined.AutoStories, stringResource(R.string.desiderata_title), onClick = onOpenDesiderata) + if (showArchives) ActionRow(Icons.Outlined.AutoStories, stringResource(R.string.archives_title), onClick = onOpenArchives) if (showNotifications) { ActionRow(Icons.Outlined.Notifications, stringResource(R.string.profile_action_notifications), onClick = onOpenNotifications) } @@ -540,6 +557,7 @@ private fun EditProfileDialog( AlertDialog( onDismissRequest = onDismiss, shape = MaterialTheme.shapes.large, + containerColor = MaterialTheme.colorScheme.surface, title = { Text(stringResource(R.string.profile_edit_title), style = MaterialTheme.typography.titleMedium) }, text = { Column( @@ -619,8 +637,8 @@ private fun GenderField(value: String, onValueChange: (String) -> Unit) { Surface( onClick = { dialogOpen = true }, - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surfaceContainerLow, + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth(), ) { Row(Modifier.padding(Spacing.lg), verticalAlignment = Alignment.CenterVertically) { @@ -683,8 +701,8 @@ private fun DateField(value: String, label: String, onValueChange: (String) -> U var pickerOpen by remember { mutableStateOf(false) } Surface( onClick = { pickerOpen = true }, - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surfaceContainerLow, + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth(), ) { Row(Modifier.padding(Spacing.lg), verticalAlignment = Alignment.CenterVertically) { @@ -750,6 +768,7 @@ private fun ChangePasswordDialog( AlertDialog( onDismissRequest = onDismiss, shape = MaterialTheme.shapes.large, + containerColor = MaterialTheme.colorScheme.surface, title = { Text(stringResource(R.string.profile_change_password_title), style = MaterialTheme.typography.titleMedium) }, text = { Column(verticalArrangement = Arrangement.spacedBy(Spacing.md)) { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/reviews/MyReviewsScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/reviews/MyReviewsScreen.kt index bce9283..fc4cfa6 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/reviews/MyReviewsScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/reviews/MyReviewsScreen.kt @@ -1,5 +1,6 @@ package com.pinakes.app.ui.screens.reviews +import com.pinakes.app.ui.theme.PublicationTitleStyle import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -171,7 +172,7 @@ private fun MyReviewRow( Column(Modifier.weight(1f)) { Text( review.bookTitle, - style = MaterialTheme.typography.titleSmall, + style = PublicationTitleStyle, color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis, diff --git a/app/src/main/java/com/pinakes/app/ui/screens/search/CollectionSearchViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/search/CollectionSearchViewModel.kt new file mode 100644 index 0000000..7573a02 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/search/CollectionSearchViewModel.kt @@ -0,0 +1,55 @@ +package com.pinakes.app.ui.screens.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pinakes.app.data.model.* +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.* +import com.pinakes.app.data.store.InstanceFeatures +import com.pinakes.app.ui.screens.collections.CollectionPager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Every collection owns its cursor: loading more books cannot discard article/archive matches. */ +@HiltViewModel +class CollectionSearchViewModel @Inject constructor(private val collections: CollectionsSource, private val articles: StandaloneArticlesSource) : ViewModel() { + private val articlePager = CollectionPager(viewModelScope, { filters, cursor -> + when (val result = articles.searchArticles(filters, cursor)) { + is ApiResult.Success -> ApiResult.Success(CollectionPage(result.data.items, result.data.nextCursor)) + is ApiResult.Failure -> result + } + }, StandaloneArticle::id) + private val wantedPager = CollectionPager(viewModelScope, collections::wanted, WantedBook::id) + private val archivePager = CollectionPager(viewModelScope, collections::archives, ArchiveRecord::id) + val articleState = articlePager.state + val wantedState = wantedPager.state + val archiveState = archivePager.state + val showArticles = MutableStateFlow(false) + val showWanted = MutableStateFlow(false) + val showArchives = MutableStateFlow(false) + private var generation = 0 + + fun search(filters: SearchFilters, features: InstanceFeatures) { + val request = ++generation + showArticles.value = false; showWanted.value = false; showArchives.value = false + val query = filters.query.orEmpty() + // Inventory-only facets cannot truthfully be applied to wanted books or archival units. + val hasArticleFacet = !filters.author.isNullOrBlank() || filters.authorId != null || !filters.publisher.isNullOrBlank() || filters.genreId != null || !filters.language.isNullOrBlank() + val onlyQuery = !hasArticleFacet && filters.availableOnly != true + if (query.isNotBlank() && onlyQuery && features.desiderataAvailable) { showWanted.value = true; wantedPager.reload(query) } + if (query.isNotBlank() && onlyQuery && features.archivesAvailable) { showArchives.value = true; archivePager.reload(ArchiveFilters(query)) } + if (features.periodicalsAvailable && filters.availableOnly != true && (query.isNotBlank() || hasArticleFacet)) viewModelScope.launch { + val supported = articles.standaloneArticlesSupported() + if (request != generation || supported != true) return@launch + showArticles.value = true + articlePager.reload(ArticleFilters(query.takeIf(String::isNotBlank), genreId = filters.genreId, language = filters.language, + author = filters.author?.takeIf { filters.authorId == null }, authorId = filters.authorId, publisher = filters.publisher)) + } + } + fun clear() { generation++; showArticles.value = false; showWanted.value = false; showArchives.value = false } + fun moreArticles() = articlePager.more() + fun moreWanted() = wantedPager.more() + fun moreArchives() = archivePager.more() +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchFilterSheet.kt b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchFilterSheet.kt index 8f2570c..b6de3f3 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchFilterSheet.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchFilterSheet.kt @@ -60,7 +60,7 @@ fun SearchFilterSheet( ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + containerColor = MaterialTheme.colorScheme.background, ) { Column( modifier = Modifier @@ -75,7 +75,7 @@ fun SearchFilterSheet( ) { Text( text = stringResource(R.string.filters_title), - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f), ) @@ -105,7 +105,7 @@ fun SearchFilterSheet( colors = chipColors, ) - Spacer(Modifier.height(Spacing.lg)) + Spacer(Modifier.height(28.dp)) // --- Genre (cascade from /catalog/genres) --- // The backend `genre` filter matches at ANY level, so whatever node the user @@ -156,7 +156,7 @@ fun SearchFilterSheet( } } } - Spacer(Modifier.height(Spacing.lg)) + Spacer(Modifier.height(28.dp)) } // --- Author --- @@ -184,7 +184,7 @@ fun SearchFilterSheet( keyboardActions = KeyboardActions(onDone = { onApply() }), ) - Spacer(Modifier.height(Spacing.lg)) + Spacer(Modifier.height(28.dp)) // --- Language --- SectionLabel(stringResource(R.string.filters_section_language)) @@ -223,33 +223,24 @@ fun SearchFilterSheet( modifier = Modifier.fillMaxWidth(), ) - Spacer(Modifier.height(Spacing.lg)) + Spacer(Modifier.height(28.dp)) } } } -/** Guard against a pathological or cyclic genre tree overflowing the stack. */ -private const val MAX_GENRE_DEPTH = 8 - -/** - * Depth-first search for [id] in the genre tree, returning the root→node path (inclusive) - * or an empty list if not found. Used to reveal the selected branch's sub-category rows. - * - * Bounded by [MAX_GENRE_DEPTH] and a visited-id set so a cyclic or unexpectedly deep tree - * (e.g. from a malformed server payload) can't cause unbounded recursion / a StackOverflow. - */ -private fun genrePath(nodes: List, id: Int): List { - fun walk(level: List, depth: Int, visited: MutableSet): List { - if (depth > MAX_GENRE_DEPTH) return emptyList() - for (node in level) { - if (!visited.add(node.id)) continue // repeat id (cycle) → don't descend again - if (node.id == id) return listOf(node) - val sub = walk(node.children, depth + 1, visited) - if (sub.isNotEmpty()) return listOf(node) + sub - } - return emptyList() +/** Iterative traversal supports every genre depth without risking stack overflow. */ +internal fun genrePath(nodes: List, id: Int): List { + val pending = ArrayDeque>>() + nodes.asReversed().forEach { pending.addLast(it to emptyList()) } + val seen = mutableSetOf() + while (pending.isNotEmpty()) { + val (node, parents) = pending.removeLast() + if (!seen.add(node.id)) continue + val path = parents + node + if (node.id == id) return path + node.children.asReversed().forEach { pending.addLast(it to path) } } - return walk(nodes, 0, mutableSetOf()) + return emptyList() } @Composable diff --git a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchScreen.kt index d89eade..82a78a0 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchScreen.kt @@ -2,6 +2,9 @@ package com.pinakes.app.ui.screens.search import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.TextButton import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -34,6 +37,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -45,9 +49,14 @@ import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.components.BookCover +import com.pinakes.app.ui.screens.periodicals.StandaloneArticleRow import com.pinakes.app.R import com.pinakes.app.data.model.BookSummary import com.pinakes.app.ui.components.AvailabilityStatus +import com.pinakes.app.ui.components.BookCardGrid +import com.pinakes.app.ui.components.CatalogViewToggle import com.pinakes.app.ui.components.BookCard import com.pinakes.app.ui.components.BookCardSkeleton import com.pinakes.app.ui.components.EmptyState @@ -57,9 +66,30 @@ import com.pinakes.app.ui.theme.Spacing @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SearchScreen(onBookClick: (Int) -> Unit) { +fun SearchScreen(onBookClick: (Int) -> Unit, initialQuery: String? = null, onQueryConsumed: () -> Unit = {}, + onArticleClick: (Int) -> Unit = {}, onWantedClick: (Int) -> Unit = {}, onArchiveClick: (Int) -> Unit = {}, initialAuthor: String? = null, initialAuthorId: Int? = null) { val vm: SearchViewModel = hiltViewModel() val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val features by app.features.collectAsStateWithLifecycle() + val collectionVm: CollectionSearchViewModel = hiltViewModel() + val articles by collectionVm.articleState.collectAsStateWithLifecycle() + val wanted by collectionVm.wantedState.collectAsStateWithLifecycle() + val archives by collectionVm.archiveState.collectAsStateWithLifecycle() + val showArticles by collectionVm.showArticles.collectAsStateWithLifecycle() + val showWanted by collectionVm.showWanted.collectAsStateWithLifecycle() + val showArchives by collectionVm.showArchives.collectAsStateWithLifecycle() + androidx.compose.runtime.LaunchedEffect(state.searchRevision, features, state.query) { + if (state.query.trim() != state.appliedFilters.query.orEmpty().trim()) collectionVm.clear() + else collectionVm.search(state.appliedFilters, features) + } + androidx.compose.runtime.LaunchedEffect(initialAuthor) { initialAuthor?.let { vm.setAuthorIdentity(it, initialAuthorId?.takeIf { id -> id > 0 }); vm.applyFilters() } } + val otherResults = (showArticles && (articles.items.isNotEmpty() || articles.error != null)) || (showWanted && (wanted.items.isNotEmpty() || wanted.error != null)) || (showArchives && (archives.items.isNotEmpty() || archives.error != null)) + val otherLoading = (showArticles && articles.loading) || (showWanted && wanted.loading) || (showArchives && archives.loading) + var gridView by rememberSaveable { mutableStateOf(true) } + androidx.compose.runtime.LaunchedEffect(initialQuery) { + if (initialQuery != null) { vm.onQueryChange(initialQuery); vm.submitSearch(); onQueryConsumed() } + } val listState = rememberLazyListState() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -130,16 +160,18 @@ fun SearchScreen(onBookClick: (Int) -> Unit) { } } } - Spacer(Modifier.height(Spacing.sm)) + Text(stringResource(R.string.sort_label, stringResource(state.sort.labelRes)), + style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = Spacing.sm)) } Box(Modifier.fillMaxSize()) { // Crossfade between the high-level UI states for a smooth load→loaded swap. val phase = when { - state.loading && state.items.isEmpty() -> SearchPhase.Loading - state.error != null && state.items.isEmpty() -> SearchPhase.Error - state.isInitial -> SearchPhase.Initial - state.items.isEmpty() -> SearchPhase.Empty + state.loading && state.items.isEmpty() && !otherResults -> SearchPhase.Loading + state.error != null && state.items.isEmpty() && !otherResults -> SearchPhase.Error + state.isInitial && !otherResults && !otherLoading -> SearchPhase.Initial + state.items.isEmpty() && !otherResults && !otherLoading -> SearchPhase.Empty else -> SearchPhase.Results } Crossfade( @@ -188,7 +220,7 @@ fun SearchScreen(onBookClick: (Int) -> Unit) { horizontal = Spacing.lg, vertical = Spacing.sm, ), - verticalArrangement = Arrangement.spacedBy(Spacing.md), + verticalArrangement = Arrangement.spacedBy(if (gridView) 28.dp else Spacing.md), ) { item { val browsingAll = state.query.isBlank() && !state.hasActiveFilters @@ -215,27 +247,47 @@ fun SearchScreen(onBookClick: (Int) -> Unit) { color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f, fill = false), ) - Text( - text = stringResource( - R.string.sort_label, - stringResource(state.sort.labelRes), - ), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + CatalogViewToggle(gridView, onChange = { gridView = it }) } } - items(state.items, key = { it.id }) { book -> - BookCard( - title = book.title, - author = book.authorsLabel, - coverUrl = book.coverUrl, - status = book.availabilityStatus(), - year = book.year?.toString(), - publisher = book.publisher, - onClick = { onBookClick(book.id) }, - modifier = Modifier.animateItem(), - ) + if (showArticles) { + item { Text(stringResource(R.string.standalone_articles_title), style = MaterialTheme.typography.titleMedium) } + if (articles.loading) item { CircularProgressIndicator(Modifier.size(20.dp)) } + articles.error?.let { error -> item { Text(error.message.ifBlank { stringResource(if (error.code == "upgrade_required") R.string.collection_upgrade_required else R.string.standalone_articles_error) }, color = MaterialTheme.colorScheme.error) } } + items(articles.items, key = { "article-${it.id}" }) { article -> StandaloneArticleRow(article) { onArticleClick(article.id) } } + if (articles.nextCursor != null) item { TextButton(collectionVm::moreArticles, enabled = !articles.loadingMore) { Text(stringResource(R.string.collection_more)) } } + } + if (showWanted) { + item { Text(stringResource(R.string.desiderata_title), style = MaterialTheme.typography.titleMedium) } + wanted.error?.let { error -> item { Text(error.message.ifBlank { stringResource(if (error.code == "upgrade_required") R.string.collection_upgrade_required else R.string.standalone_articles_error) }, color = MaterialTheme.colorScheme.error) } } + items(wanted.items, key = { "wanted-${it.id}" }) { book -> SearchCollectionRow(book.title, book.author.orEmpty(), book.coverUrl, stringResource(R.string.desiderata_wanted)) { onWantedClick(book.id) } } + if (wanted.nextCursor != null) item { TextButton(collectionVm::moreWanted, enabled = !wanted.loadingMore) { Text(stringResource(R.string.collection_more)) } } + } + if (showArchives) { + item { Text(stringResource(R.string.archives_title), style = MaterialTheme.typography.titleMedium) } + archives.error?.let { error -> item { Text(error.message.ifBlank { stringResource(if (error.code == "upgrade_required") R.string.collection_upgrade_required else R.string.standalone_articles_error) }, color = MaterialTheme.colorScheme.error) } } + items(archives.items, key = { "archive-${it.id}" }) { record -> SearchCollectionRow(record.title, record.referenceCode, record.coverUrl, record.datesLabel.orEmpty()) { onArchiveClick(record.id) } } + if (archives.nextCursor != null) item { TextButton(collectionVm::moreArchives, enabled = !archives.loadingMore) { Text(stringResource(R.string.collection_more)) } } + } + if (gridView) { + items(state.items.chunked(2), key = { row -> row.joinToString("-") { it.id.toString() } }) { row -> + Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + row.forEach { book -> + BookCardGrid(title = book.title, author = book.authorsLabel, coverUrl = book.coverUrl, + status = book.availabilityStatus(), subtitle = book.subtitle, publisher = book.publisher, + year = book.year?.toString(), mediaType = book.mediaType, + onClick = { onBookClick(book.id) }, modifier = Modifier.weight(1f)) + } + if (row.size == 1) Spacer(Modifier.weight(1f)) + } + } + } else { + items(state.items, key = { it.id }) { book -> + BookCard(title = book.title, author = book.authorsLabel, coverUrl = book.coverUrl, + status = book.availabilityStatus(), year = book.year?.toString(), publisher = book.publisher, + subtitle = book.subtitle, mediaType = book.mediaType, + onClick = { onBookClick(book.id) }, modifier = Modifier.animateItem()) + } } if (state.loadingMore) { item { @@ -312,3 +364,15 @@ private enum class SearchPhase { Loading, Error, Initial, Empty, Results } fun BookSummary.availabilityStatus(): AvailabilityStatus = if (available) AvailabilityStatus.Available else AvailabilityStatus.Unavailable + +@Composable +private fun SearchCollectionRow(title: String, metadata: String, cover: String?, label: String, onClick: () -> Unit) { + Row(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = Spacing.sm), horizontalArrangement = Arrangement.spacedBy(Spacing.md)) { + BookCover(title, cover, Modifier.width(64.dp).height(96.dp), compact = true) + Column(Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + Text(title, style = MaterialTheme.typography.titleMedium) + Text(metadata, style = MaterialTheme.typography.bodyMedium) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchViewModel.kt index 48891ec..c667a78 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchViewModel.kt @@ -28,6 +28,7 @@ enum class BookSort(val apiValue: String, @StringRes val labelRes: Int) { RELEVANCE("relevance", R.string.sort_relevance), NEWEST("newest", R.string.sort_newest), OLDEST("oldest", R.string.sort_oldest), + AUTHOR_ASC("author_asc", R.string.sort_author_asc), TITLE_ASC("title_asc", R.string.sort_title_asc), TITLE_DESC("title_desc", R.string.sort_title_desc), } @@ -41,6 +42,7 @@ data class SearchUiState( val availableOnly: Boolean = false, val selectedGenreId: Int? = null, val author: String = "", + val authorId: Int? = null, val publisher: String = "", val language: String? = null, val sort: BookSort = BookSort.NEWEST, @@ -53,6 +55,8 @@ data class SearchUiState( val loadingMore: Boolean = false, // pagination val error: String? = null, val filtersOpen: Boolean = false, + val appliedFilters: SearchFilters = SearchFilters(), + val searchRevision: Int = 0, ) { val hasMore: Boolean get() = nextCursor != null @@ -135,7 +139,8 @@ class SearchViewModel @Inject constructor(private val catalog: CatalogRepository } fun onQueryChange(value: String) { - _state.update { it.withQuery(value) } + searchGeneration++ + _state.update { it.withQuery(value).copy(items = emptyList(), nextCursor = null, loadingMore = false, loading = true) } // Debounced auto-search as the user types. searchJob?.cancel() searchJob = viewModelScope.launch { @@ -153,7 +158,8 @@ class SearchViewModel @Inject constructor(private val catalog: CatalogRepository // --- Draft setters for the filter sheet (no search until "Applica") --- - fun setAuthorDraft(value: String) = _state.update { it.copy(author = value) } + fun setAuthorDraft(value: String) = _state.update { it.copy(author = value, authorId = null) } + fun setAuthorIdentity(name: String, id: Int?) = _state.update { it.copy(author = name, authorId = id) } fun setPublisherDraft(value: String) = _state.update { it.copy(publisher = value) } @@ -183,7 +189,7 @@ class SearchViewModel @Inject constructor(private val catalog: CatalogRepository it.copy( availableOnly = false, selectedGenreId = null, - author = "", + author = "", authorId = null, publisher = "", language = null, ) @@ -196,6 +202,7 @@ class SearchViewModel @Inject constructor(private val catalog: CatalogRepository private fun filters(): SearchFilters = _state.value.let { SearchFilters( query = it.query.takeIf { q -> q.isNotBlank() }, + authorId = it.authorId, author = it.author.takeIf { a -> a.isNotBlank() }, publisher = it.publisher.takeIf { p -> p.isNotBlank() }, genreId = it.selectedGenreId, @@ -210,7 +217,7 @@ class SearchViewModel @Inject constructor(private val catalog: CatalogRepository _state.update { it.copy(loadingMore = true) } val generation = searchGeneration viewModelScope.launch { - val res = catalog.search(filters(), cursor = s.nextCursor, sort = s.sort.apiValue) + val res = catalog.search(s.appliedFilters, cursor = s.nextCursor, sort = s.sort.apiValue) // A sort/filter reset superseded this page mid-flight: drop it so we neither append // stale-sort items nor overwrite the fresh cursor. The reset already cleared loadingMore. if (generation != searchGeneration) return@launch @@ -231,8 +238,10 @@ class SearchViewModel @Inject constructor(private val catalog: CatalogRepository private fun runSearch(reset: Boolean) { if (reset) searchGeneration++ val generation = searchGeneration + val searchFilters = filters() _state.update { it.copy( + appliedFilters = searchFilters, searchRevision = generation, loading = true, error = null, items = if (reset) emptyList() else it.items, @@ -244,7 +253,7 @@ class SearchViewModel @Inject constructor(private val catalog: CatalogRepository } val sort = _state.value.sort.apiValue viewModelScope.launch { - val res = catalog.search(filters(), sort = sort) + val res = catalog.search(searchFilters, sort = sort) // Discard if a newer reset (query/sort/filter change) has since superseded this run. if (generation != searchGeneration) return@launch when (res) { diff --git a/app/src/main/java/com/pinakes/app/ui/theme/Color.kt b/app/src/main/java/com/pinakes/app/ui/theme/Color.kt index 5787cc6..6f0fb10 100644 --- a/app/src/main/java/com/pinakes/app/ui/theme/Color.kt +++ b/app/src/main/java/com/pinakes/app/ui/theme/Color.kt @@ -2,130 +2,127 @@ package com.pinakes.app.ui.theme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color +import kotlin.math.pow + +/** Server-ready palette. Discovery does not expose theme settings yet. */ +data class ThemePalette( + val primary: Color = Color(0xFFD70161), + val secondary: Color = Color(0xFF111827), + val button: Color = Color(0xFFD70262), + val buttonText: Color = Color.White, + val cardStyle: CardStyle = CardStyle.Classic, + val heroStyle: HeroStyle = HeroStyle.Covers, +) -// --------------------------------------------------------------------------- -// Brand tokens — the single accent. Magenta is the ONLY colour with meaning. -// No second brand hue (no indigo/violet). See DESIGN.md. -// --------------------------------------------------------------------------- - -/** Pinakes signature magenta — primary / accent. */ -val BrandMagenta = Color(0xFFD70161) -/** Deeper magenta for pressed / emphasis. */ -val BrandMagentaDeep = Color(0xFFB0235A) - -// --------------------------------------------------------------------------- -// Availability tints — the only extra semantic colours (clean, low-chroma). -// --------------------------------------------------------------------------- - -// Available — green -val AvailableContainerLight = Color(0xFFC7EBD1) +enum class CardStyle { Classic, Tinted } +enum class HeroStyle { Covers, Centered } + +/** CSS color-mix(in srgb): [share] is the proportion of [a]. */ +fun mix(a: Color, b: Color, share: Float): Color { + val t = share.coerceIn(0f, 1f) + return Color(a.red * t + b.red * (1f - t), a.green * t + b.green * (1f - t), + a.blue * t + b.blue * (1f - t), a.alpha * t + b.alpha * (1f - t)) +} + +fun contrastRatio(a: Color, b: Color): Double { + fun luminance(c: Color): Double { + fun linear(v: Float): Double = if (v <= .04045f) v / 12.92 else ((v + .055) / 1.055).pow(2.4) + return .2126 * linear(c.red) + .7152 * linear(c.green) + .0722 * linear(c.blue) + } + val x = luminance(a); val y = luminance(b) + return (maxOf(x, y) + .05) / (minOf(x, y) + .05) +} + +/** Move a theme colour only as far as needed, as on the current web frontend. */ +fun readableColor(color: Color, background: Color, minimum: Double = 4.5): Color { + if (contrastRatio(color, background) >= minimum) return color + val target = if (contrastRatio(Color.Black, background) > contrastRatio(Color.White, background)) Color.Black else Color.White + var low = 0f; var high = 1f + repeat(20) { + val amount = (low + high) / 2f + if (contrastRatio(mix(target, color, amount), background) >= minimum) high = amount else low = amount + } + return mix(target, color, high) +} + +class PinakesColors(palette: ThemePalette, val darkMode: Boolean) { + val accent = palette.primary + val background = if (darkMode) Color(0xFF141315) else Color(0xFFFBFAF9) + val surface = if (darkMode) Color(0xFF201F21) else Color.White + val ink = if (darkMode) Color(0xFFE6E1E3) else Color(0xFF1B1720) + val muted = if (darkMode) Color(0xFFC9C5CA) else Color(0xFF6B6470) + val soft = if (darkMode) Color(0xFF2B292C) else Color(0xFFF1EDEF) + val line = if (darkMode) Color(0xFF48464A) else Color(0xFFECE8EA) + val line2 = if (darkMode) Color(0xFF928F94) else Color(0xFFE2DCE0) + val accentText = readableColor(if (darkMode) mix(accent, Color.White, .55f) else accent, background) + val accentSoft = mix(accent, surface, if (darkMode) .18f else .09f) + val accentStrong = readableColor(mix(accentText, if (darkMode) Color.White else Color.Black, .78f), accentSoft) + val accentSofter = mix(accent, background, .05f) + val accentLine = mix(accent, surface, .16f) + val heroWash = if (darkMode) mix(accent, background, .10f) else mix(accent, Color(0xFFF7F1F3), .06f) + val dark = readableColor( + if (darkMode) readableColor(palette.secondary, surface, minimum = 3.0) else palette.secondary, + Color.White, + ) + val buttonText = palette.buttonText + val button = readableColor(palette.button, buttonText) + val coverBlank = Color(0xFF2A2230) + val cardStyle = palette.cardStyle + val heroStyle = palette.heroStyle +} + +val LocalPinakesColors = staticCompositionLocalOf { PinakesColors(ThemePalette(), false) } + +fun lightColors(palette: ThemePalette) = PinakesColors(palette, false).let { c -> + lightColorScheme( + primary = c.accentText, onPrimary = Color.White, + primaryContainer = c.accentSoft, onPrimaryContainer = c.accentStrong, + secondary = c.dark, onSecondary = Color.White, + secondaryContainer = c.soft, onSecondaryContainer = c.ink, + tertiary = c.accentText, onTertiary = Color.White, + tertiaryContainer = c.accentSoft, onTertiaryContainer = c.accentStrong, + background = c.background, onBackground = c.ink, + surface = c.surface, onSurface = c.ink, onSurfaceVariant = c.muted, + surfaceVariant = c.soft, surfaceTint = Color.Transparent, + surfaceContainerLowest = c.surface, surfaceContainerLow = c.surface, + surfaceContainer = c.soft, surfaceContainerHigh = Color(0xFFF4F0F2), surfaceContainerHighest = c.soft, + surfaceDim = c.soft, surfaceBright = c.background, + outline = c.line2, outlineVariant = c.line, + inverseSurface = c.dark, inverseOnSurface = Color.White, + inversePrimary = mix(c.accent, Color.White, .55f), + error = Color(0xFFB42318), onError = Color.White, + errorContainer = Color(0xFFFDECEC), onErrorContainer = Color(0xFF8A1C13), + ) +} + +fun darkColors(palette: ThemePalette) = PinakesColors(palette, true).let { c -> + darkColorScheme( + primary = c.accentText, onPrimary = Color(0xFF141315), + primaryContainer = c.accentSoft, onPrimaryContainer = c.accentStrong, + secondary = c.muted, onSecondary = c.background, + secondaryContainer = c.soft, onSecondaryContainer = c.ink, + tertiary = c.accentText, onTertiary = c.background, + tertiaryContainer = c.accentSoft, onTertiaryContainer = c.accentStrong, + background = c.background, onBackground = c.ink, + surface = c.surface, onSurface = c.ink, onSurfaceVariant = c.muted, + surfaceVariant = c.soft, surfaceTint = Color.Transparent, + surfaceContainerLowest = c.background, surfaceContainerLow = c.surface, + surfaceContainer = c.surface, surfaceContainerHigh = c.soft, surfaceContainerHighest = Color(0xFF363437), + outline = c.line2, outlineVariant = c.line, + ) +} + +val LightColors = lightColors(ThemePalette()) +val DarkColors = darkColors(ThemePalette()) + +// Calendar semantics retain their own paired colours in both app modes. +val AvailableContainerLight = Color(0xFFDCFCE7) val AvailableOnContainerLight = Color(0xFF0B5733) -val AvailableContainerDark = Color(0xFF14442B) -val AvailableOnContainerDark = Color(0xFFA9E6C0) - -// Limited / due-soon — amber -val DueSoonContainerLight = Color(0xFFFBE2B3) +val AvailableContainerDark = Color(0xFF14442B) +val AvailableOnContainerDark = Color(0xFFA9E6C0) +val DueSoonContainerLight = Color(0xFFFBE2B3) val DueSoonOnContainerLight = Color(0xFF6B4E00) -val DueSoonContainerDark = Color(0xFF4A3500) -val DueSoonOnContainerDark = Color(0xFFF0C36B) - -// --------------------------------------------------------------------------- -// Light scheme (default). Cool-neutral surfaces, magenta accent. No brown. -// --------------------------------------------------------------------------- -val LightColors = lightColorScheme( - primary = Color(0xFFD70161), - onPrimary = Color(0xFFFFFFFF), - primaryContainer = Color(0xFFFFD9E5), - onPrimaryContainer = Color(0xFF3E001C), - - secondary = Color(0xFF6C5C62), - onSecondary = Color(0xFFFFFFFF), - secondaryContainer = Color(0xFFF3E6EB), - onSecondaryContainer = Color(0xFF25141B), - - tertiary = Color(0xFFB0235A), - onTertiary = Color(0xFFFFFFFF), - tertiaryContainer = Color(0xFFFFD9E3), - onTertiaryContainer = Color(0xFF3E001B), - - error = Color(0xFFBA1A1A), - onError = Color(0xFFFFFFFF), - errorContainer = Color(0xFFFFDAD6), - onErrorContainer = Color(0xFF410002), - - background = Color(0xFFFCFAFB), - onBackground = Color(0xFF1C1B1C), - surface = Color(0xFFFFFFFF), - onSurface = Color(0xFF1C1B1C), - surfaceVariant = Color(0xFFECE9EB), - onSurfaceVariant = Color(0xFF5A585B), - surfaceTint = Color(0xFFD70161), - - surfaceDim = Color(0xFFE0DDDF), - surfaceBright = Color(0xFFFCFAFB), - surfaceContainerLowest = Color(0xFFFFFFFF), - surfaceContainerLow = Color(0xFFF8F5F7), - surfaceContainer = Color(0xFFF3F0F2), - surfaceContainerHigh = Color(0xFFEDEAEC), - surfaceContainerHighest = Color(0xFFE7E4E6), - - outline = Color(0xFFC7C4C7), - outlineVariant = Color(0xFFE6E2E5), - - inverseSurface = Color(0xFF313031), - inverseOnSurface = Color(0xFFF3EFF1), - inversePrimary = Color(0xFFFFB1C8), - - scrim = Color(0xFF000000), -) - -// --------------------------------------------------------------------------- -// Dark scheme (opt-in). Cool-neutral, never brown. -// --------------------------------------------------------------------------- -val DarkColors = darkColorScheme( - primary = Color(0xFFFFB1C8), - onPrimary = Color(0xFF5E1133), - primaryContainer = Color(0xFF83254A), - onPrimaryContainer = Color(0xFFFFD9E5), - - secondary = Color(0xFFD7BFC6), - onSecondary = Color(0xFF3B2930), - secondaryContainer = Color(0xFF534149), - onSecondaryContainer = Color(0xFFF3E6EB), - - tertiary = Color(0xFFFFB1C5), - onTertiary = Color(0xFF5E112B), - tertiaryContainer = Color(0xFF7E2942), - onTertiaryContainer = Color(0xFFFFD9E1), - - error = Color(0xFFFFB4AB), - onError = Color(0xFF690005), - errorContainer = Color(0xFF93000A), - onErrorContainer = Color(0xFFFFDAD6), - - background = Color(0xFF141315), - onBackground = Color(0xFFE6E1E3), - surface = Color(0xFF141315), - onSurface = Color(0xFFE6E1E3), - surfaceVariant = Color(0xFF48464A), - onSurfaceVariant = Color(0xFFC9C5CA), - surfaceTint = Color(0xFFFFB1C8), - - surfaceDim = Color(0xFF141315), - surfaceBright = Color(0xFF3A383B), - surfaceContainerLowest = Color(0xFF0F0E10), - surfaceContainerLow = Color(0xFF1C1B1D), - surfaceContainer = Color(0xFF201F21), - surfaceContainerHigh = Color(0xFF2B292C), - surfaceContainerHighest = Color(0xFF363437), - - outline = Color(0xFF928F94), - outlineVariant = Color(0xFF48464A), - - inverseSurface = Color(0xFFE6E1E3), - inverseOnSurface = Color(0xFF313031), - inversePrimary = Color(0xFFD70161), - - scrim = Color(0xFF000000), -) +val DueSoonContainerDark = Color(0xFF4A3500) +val DueSoonOnContainerDark = Color(0xFFF0C36B) diff --git a/app/src/main/java/com/pinakes/app/ui/theme/Shape.kt b/app/src/main/java/com/pinakes/app/ui/theme/Shape.kt index 2e74f4b..13b2f9c 100644 --- a/app/src/main/java/com/pinakes/app/ui/theme/Shape.kt +++ b/app/src/main/java/com/pinakes/app/ui/theme/Shape.kt @@ -6,8 +6,8 @@ import androidx.compose.ui.unit.dp val PinakesShapes = Shapes( extraSmall = RoundedCornerShape(4.dp), - small = RoundedCornerShape(8.dp), + small = RoundedCornerShape(10.dp), medium = RoundedCornerShape(12.dp), - large = RoundedCornerShape(16.dp), - extraLarge = RoundedCornerShape(28.dp), + large = RoundedCornerShape(20.dp), + extraLarge = RoundedCornerShape(24.dp), ) diff --git a/app/src/main/java/com/pinakes/app/ui/theme/Spacing.kt b/app/src/main/java/com/pinakes/app/ui/theme/Spacing.kt index 8f8bd24..218cb53 100644 --- a/app/src/main/java/com/pinakes/app/ui/theme/Spacing.kt +++ b/app/src/main/java/com/pinakes/app/ui/theme/Spacing.kt @@ -22,4 +22,4 @@ object Spacing { val ScreenPadding = Spacing.lg // 16.dp — screen horizontal edges val CardPadding = Spacing.lg // 16.dp — card inner padding val ListItemGap = Spacing.md // 12.dp — vertical gap between list items -val SectionGap = Spacing.xl // 24.dp — gap between major sections +val SectionGap = Spacing.xxxl // 48.dp — gap between major sections diff --git a/app/src/main/java/com/pinakes/app/ui/theme/Theme.kt b/app/src/main/java/com/pinakes/app/ui/theme/Theme.kt index 890bfc0..88b323b 100644 --- a/app/src/main/java/com/pinakes/app/ui/theme/Theme.kt +++ b/app/src/main/java/com/pinakes/app/ui/theme/Theme.kt @@ -5,6 +5,8 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat @@ -14,6 +16,7 @@ enum class ThemeMode { LIGHT, DARK, SYSTEM } @Composable fun PinakesTheme( mode: ThemeMode = ThemeMode.LIGHT, + palette: ThemePalette = ThemePalette(), content: @Composable () -> Unit, ) { val dark = when (mode) { @@ -36,10 +39,13 @@ fun PinakesTheme( } } - MaterialTheme( - colorScheme = if (dark) DarkColors else LightColors, - typography = PinakesTypography, - shapes = PinakesShapes, - content = content, - ) + val colors = remember(palette, dark) { PinakesColors(palette, dark) } + CompositionLocalProvider(LocalPinakesColors provides colors) { + MaterialTheme( + colorScheme = remember(palette, dark) { if (dark) darkColors(palette) else lightColors(palette) }, + typography = PinakesTypography, + shapes = PinakesShapes, + content = content, + ) + } } diff --git a/app/src/main/java/com/pinakes/app/ui/theme/Type.kt b/app/src/main/java/com/pinakes/app/ui/theme/Type.kt index 05943e3..4aa4b38 100644 --- a/app/src/main/java/com/pinakes/app/ui/theme/Type.kt +++ b/app/src/main/java/com/pinakes/app/ui/theme/Type.kt @@ -4,38 +4,36 @@ import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp import com.pinakes.app.R -val Inter = FontFamily( - Font(R.font.inter_regular, FontWeight.Normal), - Font(R.font.inter_medium, FontWeight.Medium), - Font(R.font.inter_semibold, FontWeight.SemiBold), - Font(R.font.inter_bold, FontWeight.Bold), +val Geist = FontFamily( + Font(R.font.geist_regular, FontWeight.Normal), + Font(R.font.geist_medium, FontWeight.Medium), + Font(R.font.geist_semibold, FontWeight.SemiBold), + Font(R.font.geist_bold, FontWeight.Bold), ) +val Fraunces = FontFamily( + Font(R.font.fraunces_medium, FontWeight.Medium), + Font(R.font.fraunces_italic_medium, FontWeight.Medium, FontStyle.Italic), +) +val PinakesFontFamily = Geist -// Use Inter; falls back to system SansSerif if the font resources are missing. -val PinakesFontFamily = Inter - -val PinakesTypography = Typography( - displayLarge = TextStyle(fontFamily = PinakesFontFamily, fontWeight = FontWeight.Bold, fontSize = 57.sp, lineHeight = 64.sp, letterSpacing = (-0.25).sp), - displayMedium = TextStyle(fontFamily = PinakesFontFamily, fontWeight = FontWeight.Bold, fontSize = 45.sp, lineHeight = 52.sp, letterSpacing = 0.sp), - displaySmall = TextStyle(fontFamily = PinakesFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 36.sp, lineHeight = 44.sp, letterSpacing = 0.sp), - - headlineLarge = TextStyle(fontFamily = PinakesFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 32.sp, lineHeight = 40.sp, letterSpacing = 0.sp), - headlineMedium = TextStyle(fontFamily = PinakesFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 28.sp, lineHeight = 36.sp, letterSpacing = 0.sp), - headlineSmall = TextStyle(fontFamily = PinakesFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 32.sp, letterSpacing = 0.sp), - - titleLarge = TextStyle(fontFamily = PinakesFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.sp), - titleMedium = TextStyle(fontFamily = PinakesFontFamily, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp), - titleSmall = TextStyle(fontFamily = PinakesFontFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp), +private fun heading(size: Int, line: Int) = TextStyle(fontFamily = Fraunces, + fontWeight = FontWeight.Medium, fontSize = size.sp, lineHeight = line.sp, letterSpacing = (-size * .02).sp) +private fun ui(size: Int, line: Int, weight: FontWeight = FontWeight.Normal) = TextStyle(fontFamily = Geist, + fontWeight = weight, fontSize = size.sp, lineHeight = line.sp, letterSpacing = 0.sp) - bodyLarge = TextStyle(fontFamily = PinakesFontFamily, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.5.sp), - bodyMedium = TextStyle(fontFamily = PinakesFontFamily, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.25.sp), - bodySmall = TextStyle(fontFamily = PinakesFontFamily, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp), +/** Publication titles stay distinct from interface labels in compact rows. */ +val PublicationTitleStyle = heading(17, 21) - labelLarge = TextStyle(fontFamily = PinakesFontFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp), - labelMedium = TextStyle(fontFamily = PinakesFontFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp), - labelSmall = TextStyle(fontFamily = PinakesFontFamily, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp), +val PinakesTypography = Typography( + displayLarge = heading(54, 58), displayMedium = heading(44, 48), displaySmall = heading(40, 44), + headlineLarge = heading(36, 40), headlineMedium = heading(32, 36), headlineSmall = heading(28, 32), + titleLarge = heading(24, 28), titleMedium = ui(16, 24, FontWeight.Medium), titleSmall = ui(14, 20, FontWeight.Medium), + bodyLarge = ui(16, 27), bodyMedium = ui(14, 22), bodySmall = ui(13, 18), + labelLarge = ui(15, 20, FontWeight.SemiBold), labelMedium = ui(12, 16, FontWeight.Medium), + labelSmall = ui(11, 16, FontWeight.SemiBold), ) diff --git a/app/src/main/res/font/fraunces_italic_medium.ttf b/app/src/main/res/font/fraunces_italic_medium.ttf new file mode 100644 index 0000000..805cc84 Binary files /dev/null and b/app/src/main/res/font/fraunces_italic_medium.ttf differ diff --git a/app/src/main/res/font/fraunces_medium.ttf b/app/src/main/res/font/fraunces_medium.ttf new file mode 100644 index 0000000..537b395 Binary files /dev/null and b/app/src/main/res/font/fraunces_medium.ttf differ diff --git a/app/src/main/res/font/geist_bold.ttf b/app/src/main/res/font/geist_bold.ttf new file mode 100644 index 0000000..1970189 Binary files /dev/null and b/app/src/main/res/font/geist_bold.ttf differ diff --git a/app/src/main/res/font/geist_medium.ttf b/app/src/main/res/font/geist_medium.ttf new file mode 100644 index 0000000..d1bf6aa Binary files /dev/null and b/app/src/main/res/font/geist_medium.ttf differ diff --git a/app/src/main/res/font/geist_regular.ttf b/app/src/main/res/font/geist_regular.ttf new file mode 100644 index 0000000..fe7771a Binary files /dev/null and b/app/src/main/res/font/geist_regular.ttf differ diff --git a/app/src/main/res/font/geist_semibold.ttf b/app/src/main/res/font/geist_semibold.ttf new file mode 100644 index 0000000..f2950b4 Binary files /dev/null and b/app/src/main/res/font/geist_semibold.ttf differ diff --git a/app/src/main/res/font/inter_bold.ttf b/app/src/main/res/font/inter_bold.ttf deleted file mode 100644 index 15e908f..0000000 Binary files a/app/src/main/res/font/inter_bold.ttf and /dev/null differ diff --git a/app/src/main/res/font/inter_medium.ttf b/app/src/main/res/font/inter_medium.ttf deleted file mode 100644 index 9342730..0000000 Binary files a/app/src/main/res/font/inter_medium.ttf and /dev/null differ diff --git a/app/src/main/res/font/inter_regular.ttf b/app/src/main/res/font/inter_regular.ttf deleted file mode 100644 index c544be4..0000000 Binary files a/app/src/main/res/font/inter_regular.ttf and /dev/null differ diff --git a/app/src/main/res/font/inter_semibold.ttf b/app/src/main/res/font/inter_semibold.ttf deleted file mode 100644 index a32e2b8..0000000 Binary files a/app/src/main/res/font/inter_semibold.ttf and /dev/null differ diff --git a/app/src/test/java/com/pinakes/app/BookPlaceholderTest.kt b/app/src/test/java/com/pinakes/app/BookPlaceholderTest.kt new file mode 100644 index 0000000..12ceaa3 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/BookPlaceholderTest.kt @@ -0,0 +1,40 @@ +package com.pinakes.app + +import androidx.compose.ui.graphics.Color +import com.pinakes.app.ui.components.BookPlaceholderInk +import com.pinakes.app.ui.components.BookPlaceholderPapers +import com.pinakes.app.ui.components.bookCoverImageUrl +import com.pinakes.app.ui.components.bookPlaceholderTone +import com.pinakes.app.ui.theme.contrastRatio +import com.pinakes.app.ui.theme.mix +import org.junit.Assert.* +import org.junit.Test + +class BookPlaceholderTest { + @Test fun bindingColoursMatchTheBrowserUtf8Keys() { + assertEquals(1, bookPlaceholderTone("Il nome della rosa")) + assertEquals(3, bookPlaceholderTone("Grüße 日本語")) + assertEquals(0, bookPlaceholderTone("L’isola del tesoro")) + assertEquals(1, bookPlaceholderTone(" Cien años de soledad ")) + } + + @Test fun legacyPlaceholdersUseTheTypesetCover() { + listOf(null, "", " ", "https://library.example/uploads/placeholder.jpg", + "https://library.example/Placeholder.PNG?v=1#cover", "/uploads/placeholder.svg") + .forEach { assertNull(bookCoverImageUrl(it)) } + } + + @Test fun realArtworkIsNotMistakenForAPlaceholder() { + listOf("https://library.example/placeholder-history.jpg", "https://library.example/placeholder/7.jpg", + "https://library.example/7.jpg?title=placeholder.jpg") + .forEach { assertEquals(it, bookCoverImageUrl(" $it ")) } + } + + @Test fun inkStaysReadableEvenUnderTheBrowserGlossAndClothTexture() { + BookPlaceholderPapers.forEach { paper -> + // The browser's 22% white gloss is stronger than the native gloss. + val lightestPaper = mix(Color.White, paper, .24f) + assertTrue("Cover ink must retain AA contrast", contrastRatio(BookPlaceholderInk, lightestPaper) >= 4.5f) + } + } +} diff --git a/app/src/test/java/com/pinakes/app/StandaloneArticleContractTest.kt b/app/src/test/java/com/pinakes/app/StandaloneArticleContractTest.kt index 7f7745a..85bf285 100644 --- a/app/src/test/java/com/pinakes/app/StandaloneArticleContractTest.kt +++ b/app/src/test/java/com/pinakes/app/StandaloneArticleContractTest.kt @@ -52,4 +52,31 @@ class StandaloneArticleContractTest { assertFalse(article.copy(pdfUrl = "javascript:alert(1)").canOpenPdf) assertFalse(article.copy(pdfUrl = "/storage/private.pdf").canOpenPdf) } + + @Test fun currentArticleFieldsIncludeTheCoverSubtitleAndPublishedResource() { + val article = json.decodeFromString(""" + {"id":42,"titolo":"Article","sottotitolo":"Subtitle", + "cover_url":"https://library.example/covers/article.jpg", + "has_public_resource":true,"risorsa_url":"https://archive.example/42", + "risorsa_testo":"Archive copy","risorsa_accesso":"Reading room use"} + """.trimIndent()) + assertEquals("Subtitle", article.subtitle) + assertEquals("https://library.example/covers/article.jpg", article.coverUrl) + assertEquals("https://archive.example/42", article.publicResourceUrl) + assertEquals("Archive copy", article.resourceLabel) + assertEquals("Reading room use", article.resourceAccess) + } + + @Test fun unpublishedResourcesAndNonWebReferencesCannotBeOpened() { + val article = StandaloneArticle(hasPublicResource = true, resourceAddress = "https://archive.example/42") + assertEquals(article.resourceAddress, article.publicResourceUrl) + assertNull(article.copy(hasPublicResource = false).publicResourceAddress) + assertNull(article.copy(hasPublicResource = false).publicResourceUrl) + for (address in listOf("archive/42.pdf", "file:///archive/42.pdf", "javascript:alert(1)", "https:///42", "")) { + val reference = article.copy(resourceAddress = address) + assertNull(reference.publicResourceUrl) + if (address.isNotBlank()) assertEquals(address, reference.publicResourceAddress) + } + assertNull(StandaloneArticle(resourceAddress = "https://archive.example/42").publicResourceUrl) + } } diff --git a/app/src/test/java/com/pinakes/app/ThemePaletteTest.kt b/app/src/test/java/com/pinakes/app/ThemePaletteTest.kt new file mode 100644 index 0000000..4a6f979 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/ThemePaletteTest.kt @@ -0,0 +1,42 @@ +package com.pinakes.app + +import androidx.compose.ui.graphics.Color +import com.pinakes.app.ui.theme.* +import org.junit.Assert.* +import org.junit.Test + +class ThemePaletteTest { + private val palettes = listOf( + ThemePalette(), + ThemePalette(Color(0xFF0284C7), Color(0xFF0C4A6E), Color(0xFF0EA5E9)), + ThemePalette(Color(0xFF059669), Color(0xFF064E3B), Color(0xFF10B981)), + ThemePalette(Color(0xFFEA580C), Color(0xFF7C2D12), Color(0xFFF97316)), + ThemePalette(Color(0xFF1E40AF), Color(0xFF1E3A8A), Color(0xFF3B82F6)), + ThemePalette(Color(0xFF404040), Color.Black, Color(0xFF808080)), + ThemePalette(Color(0xFFF43F5E), Color(0xFF9F1239), Color(0xFFFB7185)), + ) + + @Test fun `theme text and filled surfaces keep AA contrast in both app modes`() { + for (palette in palettes) for (dark in listOf(false, true)) { + val c = PinakesColors(palette, dark) + val pairs = listOf(c.accentText to c.background, c.accentStrong to c.accentSoft, + c.buttonText to c.button, Color.White to c.dark, c.ink to c.surface, c.muted to c.background, + c.ink to c.soft, c.muted to c.soft, c.accentStrong to c.soft) + if (dark) assertTrue("Dark button must be distinguishable from its card", contrastRatio(c.dark, c.surface) >= 2.9999) + pairs.forEach { (text, surface) -> assertTrue("$palette dark=$dark contrast=${contrastRatio(text, surface)}", + contrastRatio(text, surface) >= 4.4999) } + } + } + + @Test fun `palette colour mixing uses sRGB channels like the web`() { + val soft = mix(Color(0xFFD70161), Color.White, .09f) + assertEquals((215f * .09f + 255f * .91f) / 255f, soft.red, (0.5f / 255f)) + assertEquals((1f * .09f + 255f * .91f) / 255f, soft.green, (0.5f / 255f)) + } + + @Test fun `a palette with dark button text preserves its pairing`() { + val c = PinakesColors(ThemePalette(button = Color(0xFF404040), buttonText = Color.Black), false) + assertEquals(Color.Black, c.buttonText) + assertTrue(contrastRatio(c.buttonText, c.button) >= 4.4999) + } +} diff --git a/app/src/test/java/com/pinakes/app/ui/screens/collections/CollectionsFlowTest.kt b/app/src/test/java/com/pinakes/app/ui/screens/collections/CollectionsFlowTest.kt new file mode 100644 index 0000000..2798027 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/ui/screens/collections/CollectionsFlowTest.kt @@ -0,0 +1,156 @@ +package com.pinakes.app.ui.screens.collections + +import androidx.lifecycle.SavedStateHandle +import com.pinakes.app.data.model.* +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.* +import com.pinakes.app.ui.navigation.Routes +import com.pinakes.app.ui.screens.search.genrePath +import com.pinakes.app.ui.screens.search.CollectionSearchViewModel +import com.pinakes.app.data.store.InstanceFeatures +import kotlinx.coroutines.* +import kotlinx.coroutines.test.* +import kotlinx.serialization.json.Json +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class CollectionsFlowTest { + private val dispatcher = StandardTestDispatcher() + @Before fun setup() { Dispatchers.setMain(dispatcher) } + @After fun cleanup() { Dispatchers.resetMain() } + + private class Source : CollectionsSource { + val offers = mutableListOf() + var receipt: ApiResult = ApiResult.Success(DonationReceipt(4, "submitted")) + var status: ApiResult = ApiResult.Failure("not_found", "", 404) + override suspend fun offer(request: DonationOfferRequest): ApiResult { offers += request; return receipt } + override suspend fun offerStatus(submission: String) = status + override suspend fun wantedBook(id: Int) = ApiResult.Success(WantedBook(id, "Requested title")) + override suspend fun wanted(query: String, cursor: String?) = ApiResult.Success(CollectionPage(emptyList())) + override suspend fun archive(id: Int) = ApiResult.Success(ArchiveRecord(id)) + override suspend fun archives(filters: ArchiveFilters, cursor: String?) = ApiResult.Success(CollectionPage(emptyList())) + override suspend fun archivesHealth() = ApiResult.Success(CollectionHealth()) + override suspend fun desiderataHealth() = ApiResult.Success(CollectionHealth()) + } + + @Test fun uncertainDonationSurvivesProcessDeathAndRecoversWithoutResending() = runTest { + val source = Source().apply { receipt = ApiResult.Failure("network", "timeout") } + val saved = SavedStateHandle() + val vm = DonationViewModel(source, saved) + vm.edit("title", "A wanted book"); vm.consent(true); vm.submit(); advanceUntilIdle() + assertTrue(vm.state.value.uncertain) + val submission = source.offers.single().submissionId + vm.edit("title", "Another book"); vm.consent(false); vm.proposeFreely() + assertEquals("A wanted book", vm.state.value.title) + assertTrue(vm.state.value.consent) + source.status = ApiResult.Success(DonationReceipt(4, "submitted")) + val restored = DonationViewModel(source, saved) + restored.submit(); advanceUntilIdle() + assertTrue(restored.state.value.submitted) + assertEquals(submission, source.offers.single().submissionId) + } + + @Test fun unknownOutcomeRetriesTheIdenticalPayloadAndUuid() = runTest { + val source = Source().apply { receipt = ApiResult.Failure("internal_error", "", 500) } + val vm = DonationViewModel(source, SavedStateHandle()) + vm.edit("title", "Book"); vm.consent(true); vm.submit(); advanceUntilIdle() + source.receipt = ApiResult.Success(DonationReceipt(5, "submitted")) + vm.submit(); advanceUntilIdle() + assertEquals(source.offers.first(), source.offers.last()) + assertTrue(vm.state.value.submitted) + } + + @Test fun uncertainLookupFailureKeepsDraftFrozenAndDoesNotPostAgain() = runTest { + val source = Source().apply { receipt = ApiResult.Failure("network", "") } + val vm = DonationViewModel(source, SavedStateHandle()) + vm.edit("title", "Book"); vm.consent(true); vm.submit(); advanceUntilIdle() + source.status = ApiResult.Failure("network", "") + vm.submit(); advanceUntilIdle() + assertEquals(1, source.offers.size) + assertTrue(vm.state.value.uncertain) + } + + @Test fun selectedBookCanBecomeFreeProposalAndStaysFreeAfterRecreation() = runTest { + val source = Source() + val saved = SavedStateHandle(mapOf(Routes.ARG_WANTED_ID to 9)) + val vm = DonationViewModel(source, saved); advanceUntilIdle() + assertEquals("Requested title", vm.state.value.title) + vm.proposeFreely() + val restored = DonationViewModel(source, saved) + restored.consent(true); restored.submit(); advanceUntilIdle() + assertNull(source.offers.single().bookId) + assertTrue(restored.state.value.submitted) + } + + @Test fun missingConsentPreventsAnyRequest() = runTest { + val source = Source(); val vm = DonationViewModel(source, SavedStateHandle()) + vm.edit("title", "Book"); vm.submit(); advanceUntilIdle() + assertTrue(source.offers.isEmpty()) + } + + @Test fun lateCursorPageCannotOverwriteTheNewQueryEvenIfTransportIgnoresCancellation() = runTest { + val pending = CompletableDeferred>>() + val pager = CollectionPager(this, { query, cursor -> + if (cursor != null) withContext(NonCancellable) { pending.await() } + else ApiResult.Success(CollectionPage(listOf(WantedBook(if (query == "old") 1 else 9)), "2")) + }, WantedBook::id) + pager.reload("old"); runCurrent(); pager.more(); runCurrent() + pager.reload("new", debounce = true) + pending.complete(ApiResult.Success(CollectionPage(listOf(WantedBook(2))))) + runCurrent(); assertTrue(pager.state.value.items.isEmpty()) + advanceUntilIdle(); assertEquals(listOf(9), pager.state.value.items.map { it.id }) + } + + @Test fun paginationDeduplicatesRecordsAndStopsRepeatingCursor() = runTest { + val pager = CollectionPager(this, { _, cursor -> + ApiResult.Success(CollectionPage(if (cursor == null) listOf(WantedBook(1)) else listOf(WantedBook(1), WantedBook(2)), "2")) + }, WantedBook::id) + pager.reload(""); runCurrent(); pager.more(); runCurrent() + assertEquals(listOf(1, 2), pager.state.value.items.map { it.id }); assertNull(pager.state.value.nextCursor) + } + + @Test fun deepGenreSelectionKeepsAllAncestors() { + var tree = GenreNode(id = 20, name = "leaf") + for (id in 19 downTo 1) tree = GenreNode(id, "$id", listOf(tree)) + assertEquals((1..20).toList(), genrePath(listOf(tree), 20).map { it.id }) + } + + @Test fun catalogBrowseSkipsArticlesButSharedAuthorAndQueriesFindThem() = runTest { + val requests = mutableListOf() + val articles = object : StandaloneArticlesSource { + override suspend fun standaloneArticlesSupported() = true + override suspend fun articles(query: String?, mastheadId: Int?, cursor: String?) = ApiResult.Success(StandaloneArticlesPage(emptyList())) + override suspend fun searchArticles(filters: ArticleFilters, cursor: String?): ApiResult { + requests += filters + return ApiResult.Success(StandaloneArticlesPage(emptyList())) + } + override suspend fun article(id: Int) = ApiResult.Success(StandaloneArticle(id = id)) + override suspend fun confirmGone() = false + } + val vm = CollectionSearchViewModel(Source(), articles) + val features = InstanceFeatures(periodicalsAvailable = true, desiderataAvailable = true, archivesAvailable = true) + vm.search(SearchFilters(), features); advanceUntilIdle() + assertTrue(requests.isEmpty()); assertFalse(vm.showArticles.value) + vm.search(SearchFilters(authorId = 42), features); advanceUntilIdle() + assertEquals(42, requests.single().authorId); assertTrue(vm.showArticles.value) + assertFalse(vm.showWanted.value); assertFalse(vm.showArchives.value) + vm.search(SearchFilters(query = "library"), features); advanceUntilIdle() + assertEquals("library", requests.last().query) + assertTrue(vm.showWanted.value); assertTrue(vm.showArchives.value) + vm.clear() + assertFalse(vm.showArticles.value); assertFalse(vm.showWanted.value); assertFalse(vm.showArchives.value) + } + + @Test fun currentServerWireFormatCarriesAllFilesAndCitationStyles() { + val json = Json { ignoreUnknownKeys = true } + val book = json.decodeFromString("""{"id":1,"digital_attachments":[{"url":"https://library.example/book.pdf","kind":"ebook"},{"url":"https://library.example/book.epub","kind":"ebook"},{"url":"https://library.example/review.pdf","kind":"supplement"},{"url":"https://library.example/audio.mp3","kind":"audio"}],"citations":[{"key":"oxford","label":"Oxford","text":"Reference"}],"genre_path":[{"id":1,"name":"Root"},{"id":20,"name":"Leaf"}]}""") + assertEquals(4, book.digitalFiles.size); assertEquals("oxford", book.citations.single().key) + assertEquals("Leaf", book.genrePath.last().name) + val archive = json.decodeFromString("""{"id":2,"fields":{},"ancestors":[],"documents":[{"url":"https://library.example/image.jpg","mime":"image/jpeg"},{"url":"https://library.example/audio.wav","mime":"audio/wav"}]}""") + assertEquals(2, archive.documents.size) + assertEquals("-50–20", ArchiveRecord(dateStart = -50, dateEnd = 20).datesLabel) + } +} diff --git a/docs/PLAY-RELEASES.md b/docs/PLAY-RELEASES.md index 9cdf6a0..08d05c8 100644 --- a/docs/PLAY-RELEASES.md +++ b/docs/PLAY-RELEASES.md @@ -1,5 +1,15 @@ # Play releases +## Candidate 1.6.0 (17), 2026-10-08 + +[PR #41](https://github.com/fabiodalez-dev/Pinakes-Android/pull/41) includes the complete 2026 restyle, the five bottom navigation destinations, typeset missing covers, native Archives and library Desiderata, donation proposals with consent and durable outcome recovery, analytic articles, shared-author searches, five citation styles and all book attachments/publishers. The requirement-by-requirement record is [UWE-PARITY.md](UWE-PARITY.md); the [mobile comparison evidence](https://github.com/fabiodalez-dev/Pinakes/blob/design/restyling-2026/docs/reviews/mobile-parity-2026-10-08/README.md) uses disposable local records. + +The matching backend is [Pinakes #458](https://github.com/fabiodalez-dev/Pinakes/pull/458), included in [release candidate #462](https://github.com/fabiodalez-dev/Pinakes/pull/462). Install the updated backend/plugins before checking the new collections: Mobile API 1.5.0, Desiderata 1.2.0, Emeroteca 1.13.0 and Archives 1.5.1. Earlier servers keep existing circulation features; unsupported analytic filters ask for an upgrade. Staff editing and uploads use protected PHP pages. + +Functional commit `6c4b745` passed 185 unit tests, 19 Compose tests on Android 15, debug and minified R8 builds, and lint with zero errors (98 warnings). The local APKs are generated artifacts, not tracked source or Play releases. PR CI rebuilds from the latest head. Production signing, the remaining gates below and store publication remain separate release steps. No production-device ANR fix or FBI/DBC integration is claimed. + +## Bundle workflow + GitHub is the source of truth. Pull requests run Android CI. After merging Android changes into main, `Play release bundle` runs release unit tests and lint, then signs an AAB and retains the bundle, checksum, R8 mapping and reports as GitHub artifacts. Manual dispatch is supported on main only. No PR code receives signing secrets. Environment `play-internal` must allow main only. Required environment secrets: `PINAKES_KEYSTORE_BASE64`, `PINAKES_KEYSTORE_PASSWORD`, `PINAKES_KEY_ALIAS`, `PINAKES_KEY_PASSWORD`. Use the existing release key; never generate a replacement silently. Keys are materialized in runner temporary storage and removed after signing; configuration caching is disabled. diff --git a/docs/UWE-PARITY.md b/docs/UWE-PARITY.md new file mode 100644 index 0000000..b3cfdb6 --- /dev/null +++ b/docs/UWE-PARITY.md @@ -0,0 +1,31 @@ +# Verifica richieste Uwe — PHP e Android, 8 ottobre 2026 + +Confrontati storia di `Pinakes/main` fino a `49a9c68d` (0.7.94), testi e commenti di HansUwe52 e il client Android precedentemente allineato al 14 settembre. La ricognizione GitHub trova 56 issue dell’autore. Questa verifica riapre il confronto delle richieste che riguardano restyling, consultazione e nuove raccolte; le issue storiche chiuse restano parte della base `main`, senza presumere che lo stato “closed” sia una prova funzionale. + +| Richiesta originale | PHP | Android 1.6.0 | +| --- | --- | --- | +| [#461](https://github.com/fabiodalez-dev/Pinakes/issues/461): editori navigabili, sottogeneri, reset lento, PDF articolo | Editor finder come autori; tutti i livelli del genere; reset AJAX con cancellazione delle richieste precedenti; PDF staff dalla route protetta, PDF pubblico ai lettori | Filtro editore, generi senza limite di otto livelli, invalidazione immediata dei risultati precedenti; PDF pubblici e accesso staff alla scheda PHP | +| [#412](https://github.com/fabiodalez-dev/Pinakes/issues/412): articoli autonomi e analitici | Nuovo articolo, autore condiviso, ospite facoltativo, metadata, MARC21, RIS, ricerche miste e navigazione per autore/pubblicazione/keyword | Articoli nel catalogo e nei fascicoli, ricerca per identità autore anche nei libri, link pubblicazione/keyword/genere, data testuale e metadati completi | +| #412: giorno/mese/anno e antologie | Citazioni con data completa dei giornali; curatori, editore, luogo e ISBN del volume ospite | Stessi dati e citazioni prodotti dal server, senza convertirli in semplici anni o conteggi di pagine | +| #412: Formato, nota di possesso, pubblicazione associata | Etichette e aiuti distinti, selettore autore unico, relazione facoltativa alla testata | Formato tradotto, nota di possesso, link distinti a testata e fascicolo; catalogazione tramite pagina amministrativa PHP protetta | +| #412: dialogo Cita anche per i libri | APA, Chicago, MLA, Harvard e Oxford (Umeå), copia testo/HTML | Dialogo per libri e articoli, selezione stile e copia con HTML/italici; RIS e MARCXML articolo | +| [#453](https://github.com/fabiodalez-dev/Pinakes/issues/453), [#454](https://github.com/fabiodalez-dev/Pinakes/issues/454), [#455](https://github.com/fabiodalez-dev/Pinakes/issues/455): ricerca, menu e schede degli articoli | Articoli nei suggerimenti con cover, risultati e schede autore; voci Articoli/Nuovo articolo; scheda amministrativa con Dettagli/Modifica; genere e keyword nelle opzioni avanzate | Accessi diretti Articoli in Home e Profilo, cover con fallback, risultati nel catalogo, scheda leggibile e link di gestione riservato allo staff | +| [#445](https://github.com/fabiodalez-dev/Pinakes/issues/445): più file e più audio | Lista di allegati ordinata, compatibile con i due campi precedenti | Tutti gli ebook, documenti collegati e tracce audio; PDF nel lettore nativo e un player audio alla volta | +| [#103](https://github.com/fabiodalez-dev/Pinakes/issues/103): ricerca tra raccolte e descrizione archivistica/fotografica | Risultati d’archivio anche insieme ai libri e dopo filtri AJAX; ricerca dei nomi delle autorità | Archivio con gerarchia, periodi anche a.C., metadati pubblici, autorità, tutti i documenti ed export; risultati d’archivio nello stesso catalogo | +| [#282](https://github.com/fabiodalez-dev/Pinakes/issues/282): ordinamento e combinazioni lingua/faceti | Lingue reali del catalogo e filtri combinati | Home: recenti/titolo/autore; catalogo: autore A–Z; lingue reali combinate con genere/autore/editore | +| [#143](https://github.com/fabiodalez-dev/Pinakes/issues/143): più editori | Registry e relazione molti-a-molti già presenti | Il dettaglio legge e mostra la lista completa degli editori | +| Desiderata, richiesto esplicitamente da Fabio | API separata e proposte da account verificato, consenso, deduplicazione durevole e recupero esito | Elenco, ricerca, dettaglio e proposta collegata/libera; distinto dalla wishlist e dai libri prestabili | + +I filtri di disponibilità si applicano ai libri con copie. La ricerca per genere/lingua/autore/editore raggiunge libri e articoli; archivio e Desiderata espongono la loro ricerca testuale, senza fingere di applicare faceti di inventario non equivalenti. Le raccolte mantengono cursori indipendenti. Il dettaglio fascicolo segnala l’eventuale limite di 100 articoli catalogati e rimanda all’elenco paginato completo. + +## Verifica e limiti concreti + +- 50 controlli d’integrazione su DB: offerte, UUID, recupero solo del proprio esito, consenso, account verificato, copie non inventate, allegati multipli, autore rinominato/ETag, genere a 12 livelli, articoli/antologie/filtri/privacy, documenti archivistici, ricerca browser mista e reset, gate app/plugin. +- 9 contratti HTTP: manifest OpenAPI completo e nuove route protette, letture ripetibili; invio senza consenso respinto. +- 36 test browser sulle richieste #412/#453–455/#461; 17 suite PHP bibliografiche/archivistiche; PHPStan livello 5 senza errori. +- Android: 185 unit test, 19 test Compose su Android 15, zero errori Lint; build debug e release R8 riuscite. Test senza donazioni o modifiche al catalogo Bibliodoc di produzione. +- Backend richiesto: Mobile API 1.5.0 e plugin aggiornati (Desiderata 1.2.0, Emeroteca 1.13.0, Archives 1.5.1). Le nuove sezioni dipendono dalle capability del server. Un server precedente continua a funzionare, ma non offre la nuova API delle raccolte; per i filtri analitici non supportati l’app richiede l’aggiornamento. +- La gestione amministrativa e gli upload degli articoli/archivi aprono il PHP protetto nel browser. Non sono nuovi editor CRUD nativi. +- [#52](https://github.com/fabiodalez-dev/Pinakes/issues/52), import danese FBI/DBC, resta una richiesta separata non implementata: token e documentazione completa richiedono accesso DBC, che non è disponibile in questa sessione. Non è una funzionalità già presente nel server da sincronizzare. +- [#57](https://github.com/fabiodalez-dev/Pinakes/issues/57) descrive l’installazione dell’estensione YAZ nel container QNAP; Uwe precisa che non è un difetto Pinakes. Non viene dichiarata risolta modificando il client Android. +- Nessuna merge, pubblicazione sugli store o distribuzione in produzione effettuata. Il caso ANR sul vecchio emulatore GUI resta distinto dai test riusciti sull’AVD headless; non si dichiara risolto un ANR su dispositivi reali. diff --git a/docs/emeroteca-standalone-articles.md b/docs/emeroteca-standalone-articles.md index 47cc92d..ab5f037 100644 --- a/docs/emeroteca-standalone-articles.md +++ b/docs/emeroteca-standalone-articles.md @@ -21,6 +21,15 @@ installation subdirectory. Servers implementing the original capability without the server is updated. Refreshing an article that became private or was deleted removes its previously displayed content and PDF action. +Covers use the server-resolved `cover_url` (including issue/masthead fallbacks). +Subtitles are displayed in both the list and the detail. Published resources are +shown only with `has_public_resource`: HTTP(S) addresses open in the browser; +archive/local references remain selectable text. Access conditions accompany the +resource. The website action preserves installation subdirectories and opens the +article page, where staff with a browser session can edit it or read private PDFs +through the existing administration permissions. The mobile bearer token is never +put in a browser URL. + ## Wire contract - `GET periodicals/articles?q=&testata_id=&cursor=` → core envelope with an array. diff --git a/docs/fonts/Fraunces-OFL.txt b/docs/fonts/Fraunces-OFL.txt new file mode 100644 index 0000000..22fa7ce --- /dev/null +++ b/docs/fonts/Fraunces-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Fraunces Project Authors (https://github.com/undercasetype/Fraunces) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/fonts/Geist-OFL.txt b/docs/fonts/Geist-OFL.txt new file mode 100644 index 0000000..61f6f4b --- /dev/null +++ b/docs/fonts/Geist-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/docs/fonts/README.md b/docs/fonts/README.md new file mode 100644 index 0000000..fde780c --- /dev/null +++ b/docs/fonts/README.md @@ -0,0 +1,6 @@ +# Bundled interface fonts + +- Geist: https://github.com/google/fonts/tree/main/ofl/geist +- Fraunces: https://github.com/google/fonts/tree/main/ofl/fraunces + +Both are licensed under the SIL Open Font License 1.1 (the complete licences are beside this file). Sources were retrieved on 2026-10-08. Static TTF faces are bundled, with the original character coverage including Latin Extended, for Android API 26 and later. Geist has 400/500/600/700 faces; Fraunces has upright and italic 500 faces, optical size 32, with the other axes at their upstream defaults. The static faces were generated using fontTools `instantiateVariableFont` with all axes pinned. No font or rendering dependency is required at runtime. diff --git a/docs/screenshots/calendar.png b/docs/screenshots/calendar.png index d468cdb..181a5cb 100644 Binary files a/docs/screenshots/calendar.png and b/docs/screenshots/calendar.png differ diff --git a/docs/screenshots/catalog.png b/docs/screenshots/catalog.png index 4e99da5..6ee4d34 100644 Binary files a/docs/screenshots/catalog.png and b/docs/screenshots/catalog.png differ diff --git a/docs/screenshots/detail.png b/docs/screenshots/detail.png index 6622cb9..b599bc9 100644 Binary files a/docs/screenshots/detail.png and b/docs/screenshots/detail.png differ diff --git a/docs/screenshots/home.png b/docs/screenshots/home.png index 74e4d7c..f98ffe4 100644 Binary files a/docs/screenshots/home.png and b/docs/screenshots/home.png differ diff --git a/docs/screenshots/login.png b/docs/screenshots/login.png index c605c35..2eda40e 100644 Binary files a/docs/screenshots/login.png and b/docs/screenshots/login.png differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7fda2ad..071b614 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ junit = "4.13.2" coroutinesTest = "1.8.1" robolectric = "4.13" testCore = "1.6.1" +testRunner = "1.6.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -65,6 +66,10 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } androidx-test-core = { group = "androidx.test", name = "core", version.ref = "testCore" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "testRunner" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/i18n/de.json b/i18n/de.json index b464c1e..2274438 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -41,9 +41,9 @@ "action_continue": "Weiter", "onboarding_http_warning": "Diese Bibliothek verwendet eine unsichere Verbindung (http). Verwende https, um fortzufahren.", "onboarding_allow_http_label": "Unsicheres HTTP zulassen", - "onboarding_allow_http_hint": "Nur für eine selbst gehostete Instanz ohne HTTPS. Die Verbindung ist nicht verschlüsselt — Nutzung auf eigenes Risiko.", + "onboarding_allow_http_hint": "Nur für eine selbst gehostete Instanz ohne HTTPS. Die Verbindung ist nicht verschlüsselt; Nutzung auf eigenes Risiko.", "onboarding_status_secure_ok": "Sichere Verbindung (https)", - "onboarding_status_secure_warn": "Unsichere Verbindung (http) — nicht empfohlen", + "onboarding_status_secure_warn": "Unsichere Verbindung (http); nicht empfohlen", "onboarding_status_app_access_ok": "Der Zugriff über die mobile App ist aktiviert", "onboarding_status_app_access_warn": "Der Zugriff über die mobile App ist für diese Bibliothek deaktiviert", "onboarding_version_prefix": "Pinakes %1$s", @@ -257,13 +257,13 @@ "book_loan_legend_limited": "Begrenzt", "book_loan_legend_unavailable": "Nicht verfügbar", "book_loan_availability_loading": "Verfügbarkeit wird geprüft…", - "book_loan_availability_error": "Verfügbarkeitskalender konnte nicht geladen werden — wählen Sie ein zukünftiges Datum.", + "book_loan_availability_error": "Verfügbarkeitskalender konnte nicht geladen werden; wählen Sie ein zukünftiges Datum.", "book_loan_pick_prompt": "Wählen Sie einen verfügbaren Tag", "cd_prev_month": "Voriger Monat", "cd_next_month": "Nächster Monat", "snackbar_request_for_date": "Ausleihe angefragt für %1$s", "snackbar_reservation_for_date": "Reservierung für den %1$s eingetragen", - "snackbar_loan_ready_for_pickup": "Ausleihe genehmigt — zur Abholung bereit.", + "snackbar_loan_ready_for_pickup": "Ausleihe genehmigt; zur Abholung bereit.", "book_section_audiobook": "Hörbuch", "audio_play": "Abspielen", "audio_pause": "Pause", @@ -317,7 +317,7 @@ "snackbar_added_to_wishlist": "Zur Wunschliste hinzugefügt", "snackbar_removed_from_wishlist": "Von der Wunschliste entfernt", "snackbar_wishlist_update_error": "Wunschliste konnte nicht aktualisiert werden.", - "snackbar_request_submitted": "Anfrage gesendet — die Bibliothek wird sie prüfen.", + "snackbar_request_submitted": "Anfrage gesendet; die Bibliothek wird sie prüfen.", "snackbar_request_conflict": "Dieses Buch ist für die gewünschten Daten nicht verfügbar.", "snackbar_request_error": "Anfrage konnte nicht gesendet werden.", "library_error_load": "Deine Bibliothek konnte nicht geladen werden.", @@ -338,12 +338,12 @@ "login_error_rate_limited_seconds": "Zu viele Versuche. Versuche es in %1$ds erneut.", "login_error_rate_limited": "Zu viele Versuche. Bitte warte und versuche es erneut.", "login_error_network": "Netzwerkfehler. Überprüfe deine Verbindung.", - "login_error_tls": "Sichere Verbindung (TLS) fehlgeschlagen — das Zertifikat des Servers ist nicht vertrauenswürdig.", + "login_error_tls": "Sichere Verbindung (TLS) fehlgeschlagen; das Zertifikat des Servers ist nicht vertrauenswürdig.", "login_error_forbidden": "Dieses Konto darf sich hier nicht anmelden.", "login_error_generic": "Anmeldung fehlgeschlagen. Bitte versuche es erneut.", "onboarding_error_empty": "Gib die Webadresse deiner Bibliothek ein.", "onboarding_error_network": "Diese Adresse konnte nicht erreicht werden. Überprüfe URL und Verbindung.", - "onboarding_error_tls": "Es konnte keine sichere HTTPS-Verbindung hergestellt werden — das TLS-Zertifikat des Servers ist nicht vertrauenswürdig. Prüfe das Zertifikat auf deinem Server.", + "onboarding_error_tls": "Es konnte keine sichere HTTPS-Verbindung hergestellt werden; das TLS-Zertifikat des Servers ist nicht vertrauenswürdig. Prüfe das Zertifikat auf deinem Server.", "onboarding_error_not_found": "Das sieht nicht nach einer Pinakes-Bibliothek aus (keine API gefunden).", "onboarding_error_generic": "Verbindung zu dieser Bibliothek nicht möglich.", "availability_reserved": "Vorgemerkt", @@ -403,7 +403,7 @@ "book_club_rules": "Clubregeln", "book_club_join": "Diesem Club beitreten", "book_club_join_active": "Du bist dem Club beigetreten", - "book_club_join_pending": "Anfrage gesendet — wartet auf Freigabe", + "book_club_join_pending": "Anfrage gesendet; wartet auf Freigabe", "book_club_update_progress": "Fortschritt aktualisieren", "book_club_progress_mark_finished": "Ich habe dieses Buch beendet", "book_club_progress_saved": "Fortschritt gespeichert", @@ -434,7 +434,7 @@ "book_club_privacy_invite": "Nur mit Einladung", "book_club_privacy_hidden": "Verborgen", "book_club_error_dashboard": "Dein Lesebereich konnte nicht geladen werden. Zum Aktualisieren ziehen.", - "register_schema_failed": "Zusätzliche Registrierungsfelder konnten nicht geladen werden — du kannst dich trotzdem registrieren oder es erneut versuchen.", + "register_schema_failed": "Zusätzliche Registrierungsfelder konnten nicht geladen werden; du kannst dich trotzdem registrieren oder es erneut versuchen.", "action_clear": "Löschen", "profile_action_periodicals": "Zeitschriften", "periodicals_title": "Zeitschriften", @@ -499,6 +499,9 @@ "periodicals_status_smarrito": "Verloren", "periodicals_status_atteso": "Erwartet", "standalone_articles_title": "Artikel", + "standalone_article_online_resource": "Online-Ressource", + "standalone_article_resource_reference": "Archivreferenz", + "standalone_article_open_web": "Artikel auf der Website öffnen", "standalone_article_title": "Artikel", "standalone_articles_in_masthead": "Artikel dieser Publikation", "standalone_articles_search": "Titel, Autor oder Publikation", @@ -522,5 +525,100 @@ "standalone_article_keywords": "Schlagwörter", "standalone_article_open_masthead": "Publikation ansehen", "standalone_article_open_issue": "Ausgabe ansehen", - "action_refresh": "Aktualisieren" + "action_refresh": "Aktualisieren", + "restyle_details": "Details", + "restyle_unknown_author": "Unbekannter Autor", + "restyle_grid": "Rasteransicht", + "restyle_list": "Listenansicht", + "restyle_digital": "Digitale Inhalte", + "restyle_download": "Herunterladen", + "restyle_digital_edition": "Digitale Ausgabe", + "restyle_audiobook": "Hörbuch", + "restyle_search_catalog": "Katalog durchsuchen", + "book_meta_format": "Format", + "book_meta_series": "Reihe", + "collection_play": "Abspielen", + "book_meta_edition": "Ausgabe", + "book_meta_publication_place": "Erscheinungsort", + "desiderata_title": "Desiderata", + "desiderata_intro": "Bücher, die die Bibliothek sucht. Wenn Sie ein Exemplar besitzen, können Sie es als Spende anbieten.", + "desiderata_wanted": "Von der Bibliothek gesucht", + "desiderata_offer": "Ich kann dieses Buch spenden", + "desiderata_free_offer": "Ein anderes Buch anbieten", + "desiderata_empty": "Zurzeit werden keine Bücher gesucht", + "collection_search": "Sammlungen durchsuchen", + "collection_more": "Mehr anzeigen", + "collection_web": "Auf der Website öffnen", + "collection_manage": "Auf der Website verwalten", + "collection_loading": "Wird geladen…", + "collection_empty": "Keine Ergebnisse", + "collection_explore": "Sammlungen entdecken", + "archives_title": "Archiv", + "archives_intro": "Bestände, Serien, Akten und Archivstücke der Bibliothek.", + "archives_fonds": "Bestände", + "archives_series": "Serien", + "archives_files": "Akten", + "archives_items": "Archivstücke", + "archives_from": "Ab Jahr", + "archives_to": "Bis Jahr", + "archives_dates_error": "Geben Sie einen gültigen Zeitraum zwischen −32768 und 32767 ein.", + "archives_apply": "Zeitraum anwenden", + "collection_clear": "Filter zurücksetzen", + "archives_children": "Untergeordnete Einheiten durchsuchen", + "archives_documents": "Dokumente", + "archives_authorities": "Verknüpfte Personen und Organisationen", + "archives_exports": "Beschreibung exportieren", + "archives_reference": "Signatur", + "archives_level": "Ebene", + "archives_dates": "Datierung", + "archives_extent": "Umfang und Material", + "archives_material": "Materialtyp", + "archives_description": "Inhalt", + "archives_history": "Archivgeschichte", + "archives_access": "Zugangsbedingungen", + "archives_reproduction": "Reproduktionsbedingungen", + "archives_arrangement": "Ordnung", + "archives_finding_aids": "Findmittel", + "archives_originals": "Aufbewahrungsort der Originale", + "archives_copies": "Aufbewahrungsort der Kopien", + "archives_related": "Verwandte Einheiten", + "archives_institution": "Einrichtung", + "archives_classification": "Lokale Klassifikation", + "archives_collection": "Sammlung", + "archives_photographer": "Fotograf / Urheber", + "donation_title": "Spendenangebot", + "donation_contact": "Die Bibliothek kontaktiert Sie über die Angaben Ihres Kontos. Ein Angebot fügt noch kein Exemplar zum Katalog hinzu.", + "donation_notes": "Zustand des Buches und Hinweise", + "donation_consent": "Ich stimme zu, wegen dieser Spende kontaktiert zu werden", + "donation_send": "Angebot senden", + "donation_success": "Angebot gesendet", + "donation_success_body": "Vielen Dank! Die Bibliothek prüft Ihr Angebot und kontaktiert Sie für die Übergabe.", + "donation_uncertain": "Das Ergebnis ist noch nicht bestätigt. Versuchen Sie es erneut, um es ohne ein zweites Angebot zu prüfen.", + "donation_done": "Zurück zu Desiderata", + "article_editors": "Herausgeber des Bandes", + "article_country": "Land", + "article_classification": "Klassifikation", + "article_holdings": "Bestandsvermerk", + "citation_cite": "Zitieren", + "article_cite": "Diesen Artikel zitieren", + "citation_copy": "Zitat kopieren", + "citation_copied": "Zitat kopiert", + "citation_ris": "RIS-Zitat herunterladen", + "article_catalogued": "Katalogisierte Artikel", + "article_toc": "Gedrucktes Inhaltsverzeichnis", + "article_issue_search": "In dieser Ausgabe suchen", + "donation_book_title": "Buchtitel", + "donation_author": "Autor", + "article_anthology": "Sammelband", + "article_print": "Gedruckt", + "article_digital": "Digital", + "article_both": "Gedruckt und digital", + "article_format_unknown": "Nicht angegeben", + "sort_author_asc": "Autor (A–Z)", + "collection_upgrade_required": "Aktualisiere Pinakes auf dem Server, um diese Artikelfilter zu verwenden.", + "book_section_related_documents": "Zugehörige Dokumente", + "archives_dimensions": "Abmessungen", + "archives_color": "Farbe", + "archives_predominant_dates": "Überwiegende Daten", + "archives_date_gaps": "Zeitliche Lücken" } diff --git a/i18n/en.json b/i18n/en.json index 9eae4b8..1cee2b8 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -41,9 +41,9 @@ "action_continue": "Continue", "onboarding_http_warning": "This library uses an insecure (http) connection. Use https to continue.", "onboarding_allow_http_label": "Allow insecure HTTP", - "onboarding_allow_http_hint": "Only for a self-hosted instance without HTTPS. The connection won’t be encrypted — use at your own risk.", + "onboarding_allow_http_hint": "Only for a self-hosted instance without HTTPS. The connection won’t be encrypted; use at your own risk.", "onboarding_status_secure_ok": "Secure connection (https)", - "onboarding_status_secure_warn": "Insecure connection (http) — not recommended", + "onboarding_status_secure_warn": "Insecure connection (http); not recommended", "onboarding_status_app_access_ok": "Mobile app access is enabled", "onboarding_status_app_access_warn": "Mobile app access is disabled on this library", "onboarding_version_prefix": "Pinakes %1$s", @@ -257,13 +257,13 @@ "book_loan_legend_limited": "Limited", "book_loan_legend_unavailable": "Unavailable", "book_loan_availability_loading": "Checking availability…", - "book_loan_availability_error": "Couldn't load the availability calendar — pick any future date.", + "book_loan_availability_error": "Couldn't load the availability calendar; pick any future date.", "book_loan_pick_prompt": "Select an available day", "cd_prev_month": "Previous month", "cd_next_month": "Next month", "snackbar_request_for_date": "Loan requested for %1$s", "snackbar_reservation_for_date": "Reservation added for %1$s", - "snackbar_loan_ready_for_pickup": "Loan approved — ready for pickup.", + "snackbar_loan_ready_for_pickup": "Loan approved; ready for pickup.", "book_section_audiobook": "Audiobook", "audio_play": "Play", "audio_pause": "Pause", @@ -317,7 +317,7 @@ "snackbar_added_to_wishlist": "Added to wishlist", "snackbar_removed_from_wishlist": "Removed from wishlist", "snackbar_wishlist_update_error": "Couldn't update wishlist.", - "snackbar_request_submitted": "Request submitted — the library will review it.", + "snackbar_request_submitted": "Request submitted; the library will review it.", "snackbar_request_conflict": "This book isn't available for the requested dates.", "snackbar_request_error": "Couldn't submit the request.", "library_error_load": "Couldn't load your library.", @@ -338,12 +338,12 @@ "login_error_rate_limited_seconds": "Too many attempts. Try again in %1$ds.", "login_error_rate_limited": "Too many attempts. Please wait and try again.", "login_error_network": "Network error. Check your connection.", - "login_error_tls": "Secure connection (TLS) failed — the server's certificate isn't trusted.", + "login_error_tls": "Secure connection (TLS) failed; the server's certificate isn't trusted.", "login_error_forbidden": "This account isn't allowed to sign in here.", "login_error_generic": "Sign-in failed. Please try again.", "onboarding_error_empty": "Enter your library's web address.", "onboarding_error_network": "Couldn't reach that address. Check the URL and your connection.", - "onboarding_error_tls": "Couldn't establish a secure HTTPS connection — the server's TLS certificate isn't trusted. Check the certificate on your server.", + "onboarding_error_tls": "Couldn't establish a secure HTTPS connection; the server's TLS certificate isn't trusted. Check the certificate on your server.", "onboarding_error_not_found": "This doesn't look like a Pinakes library (no API found there).", "onboarding_error_generic": "Couldn't connect to that library.", "availability_reserved": "Reserved", @@ -403,7 +403,7 @@ "book_club_rules": "Club rules", "book_club_join": "Join this club", "book_club_join_active": "You've joined the club", - "book_club_join_pending": "Request sent — awaiting approval", + "book_club_join_pending": "Request sent; awaiting approval", "book_club_update_progress": "Update progress", "book_club_progress_mark_finished": "I've finished this book", "book_club_progress_saved": "Progress saved", @@ -434,7 +434,7 @@ "book_club_privacy_invite": "Invite only", "book_club_privacy_hidden": "Hidden", "book_club_error_dashboard": "Couldn't load your reading section. Pull to refresh.", - "register_schema_failed": "Couldn't load the extra registration fields — you can still sign up, or retry.", + "register_schema_failed": "Couldn't load the extra registration fields; you can still sign up, or retry.", "action_clear": "Clear", "profile_action_periodicals": "Periodicals", "periodicals_title": "Periodicals", @@ -499,6 +499,9 @@ "periodicals_status_smarrito": "Lost", "periodicals_status_atteso": "Expected", "standalone_articles_title": "Articles", + "standalone_article_online_resource": "Online resource", + "standalone_article_resource_reference": "Archive reference", + "standalone_article_open_web": "Open article on the website", "standalone_article_title": "Article", "standalone_articles_in_masthead": "Articles from this publication", "standalone_articles_search": "Title, author or publication", @@ -522,5 +525,100 @@ "standalone_article_keywords": "Keywords", "standalone_article_open_masthead": "View publication", "standalone_article_open_issue": "View issue", - "action_refresh": "Refresh" + "action_refresh": "Refresh", + "restyle_details": "Details", + "restyle_unknown_author": "Unknown author", + "restyle_grid": "Grid view", + "restyle_list": "List view", + "restyle_digital": "Digital content", + "restyle_download": "Download", + "restyle_digital_edition": "Digital edition", + "restyle_audiobook": "Audiobook", + "restyle_search_catalog": "Search the catalog", + "book_meta_format": "Format", + "book_meta_series": "Series", + "collection_play": "Play", + "book_meta_edition": "Edition", + "book_meta_publication_place": "Publication place", + "desiderata_title": "Library desiderata", + "desiderata_intro": "Books the library is looking for. If you own a copy, you can offer to donate it.", + "desiderata_wanted": "Wanted by the library", + "desiderata_offer": "I can donate this book", + "desiderata_free_offer": "Offer another book", + "desiderata_empty": "No books requested right now", + "collection_search": "Search collections", + "collection_more": "Show more", + "collection_web": "Open on the website", + "collection_manage": "Manage on the website", + "collection_loading": "Loading…", + "collection_empty": "No results", + "collection_explore": "Explore collections", + "archives_title": "Archive", + "archives_intro": "The library’s fonds, series, files and archival items.", + "archives_fonds": "Fonds", + "archives_series": "Series", + "archives_files": "Files", + "archives_items": "Items", + "archives_from": "From year", + "archives_to": "To year", + "archives_dates_error": "Enter a valid year range from −32768 to 32767.", + "archives_apply": "Apply period", + "collection_clear": "Clear filters", + "archives_children": "Browse child records", + "archives_documents": "Documents", + "archives_authorities": "Related people and organisations", + "archives_exports": "Export the description", + "archives_reference": "Reference code", + "archives_level": "Level", + "archives_dates": "Dates", + "archives_extent": "Extent and medium", + "archives_material": "Material type", + "archives_description": "Scope and content", + "archives_history": "Archival history", + "archives_access": "Access conditions", + "archives_reproduction": "Reproduction conditions", + "archives_arrangement": "Arrangement", + "archives_finding_aids": "Finding aids", + "archives_originals": "Location of originals", + "archives_copies": "Location of copies", + "archives_related": "Related units", + "archives_institution": "Institution", + "archives_classification": "Local classification", + "archives_collection": "Collection", + "archives_photographer": "Photographer / creator", + "donation_title": "Donation proposal", + "donation_contact": "The library will contact you using your account details. A proposal does not add a copy to the catalogue.", + "donation_notes": "Book condition and notes", + "donation_consent": "I agree to be contacted about this donation", + "donation_send": "Send proposal", + "donation_success": "Proposal sent", + "donation_success_body": "Thank you! The library will review your proposal and contact you to arrange delivery.", + "donation_uncertain": "The submission outcome is not confirmed yet. Retry to check it without creating a second proposal.", + "donation_done": "Back to desiderata", + "article_editors": "Volume editors", + "article_country": "Country", + "article_classification": "Classification", + "article_holdings": "Holdings note", + "citation_cite": "Cite", + "article_cite": "Cite this article", + "citation_copy": "Copy citation", + "citation_copied": "Citation copied", + "citation_ris": "Download RIS citation", + "article_catalogued": "Catalogued articles", + "article_toc": "Printed table of contents", + "article_issue_search": "Search this issue", + "donation_book_title": "Book title", + "donation_author": "Author", + "article_anthology": "Anthology", + "article_print": "Print", + "article_digital": "Digital", + "article_both": "Print and digital", + "article_format_unknown": "Not specified", + "sort_author_asc": "Author (A–Z)", + "collection_upgrade_required": "Update Pinakes on the server to use these article filters.", + "book_section_related_documents": "Related documents", + "archives_dimensions": "Dimensions", + "archives_color": "Colour", + "archives_predominant_dates": "Predominant dates", + "archives_date_gaps": "Date gaps" } diff --git a/i18n/fr.json b/i18n/fr.json index 899a720..92543e8 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -41,9 +41,9 @@ "action_continue": "Continuer", "onboarding_http_warning": "Cette bibliothèque utilise une connexion non sécurisée (http). Utilisez https pour continuer.", "onboarding_allow_http_label": "Autoriser le HTTP non sécurisé", - "onboarding_allow_http_hint": "Uniquement pour une instance auto-hébergée sans HTTPS. La connexion ne sera pas chiffrée — à vos risques.", + "onboarding_allow_http_hint": "Uniquement pour une instance auto-hébergée sans HTTPS. La connexion ne sera pas chiffrée; à vos risques.", "onboarding_status_secure_ok": "Connexion sécurisée (https)", - "onboarding_status_secure_warn": "Connexion non sécurisée (http) — déconseillée", + "onboarding_status_secure_warn": "Connexion non sécurisée (http); déconseillée", "onboarding_status_app_access_ok": "L'accès depuis l'application mobile est activé", "onboarding_status_app_access_warn": "L'accès depuis l'application mobile est désactivé sur cette bibliothèque", "onboarding_version_prefix": "Pinakes %1$s", @@ -257,13 +257,13 @@ "book_loan_legend_limited": "Limité", "book_loan_legend_unavailable": "Indisponible", "book_loan_availability_loading": "Vérification de la disponibilité…", - "book_loan_availability_error": "Impossible de charger le calendrier de disponibilité — choisissez une date future.", + "book_loan_availability_error": "Impossible de charger le calendrier de disponibilité; choisissez une date future.", "book_loan_pick_prompt": "Sélectionnez un jour disponible", "cd_prev_month": "Mois précédent", "cd_next_month": "Mois suivant", "snackbar_request_for_date": "Prêt demandé pour le %1$s", "snackbar_reservation_for_date": "Réservation enregistrée pour le %1$s", - "snackbar_loan_ready_for_pickup": "Prêt approuvé — prêt à être retiré.", + "snackbar_loan_ready_for_pickup": "Prêt approuvé; prêt à être retiré.", "book_section_audiobook": "Livre audio", "audio_play": "Lecture", "audio_pause": "Pause", @@ -317,7 +317,7 @@ "snackbar_added_to_wishlist": "Ajouté à la liste de souhaits", "snackbar_removed_from_wishlist": "Retiré de la liste de souhaits", "snackbar_wishlist_update_error": "Impossible de mettre à jour la liste de souhaits.", - "snackbar_request_submitted": "Demande envoyée — la bibliothèque va l'examiner.", + "snackbar_request_submitted": "Demande envoyée; la bibliothèque va l'examiner.", "snackbar_request_conflict": "Ce livre n'est pas disponible pour les dates demandées.", "snackbar_request_error": "Impossible d'envoyer la demande.", "library_error_load": "Impossible de charger votre bibliothèque.", @@ -338,12 +338,12 @@ "login_error_rate_limited_seconds": "Trop de tentatives. Réessayez dans %1$ds.", "login_error_rate_limited": "Trop de tentatives. Veuillez patienter et réessayer.", "login_error_network": "Erreur réseau. Vérifiez votre connexion.", - "login_error_tls": "Échec de la connexion sécurisée (TLS) — le certificat du serveur n'est pas approuvé.", + "login_error_tls": "Échec de la connexion sécurisée (TLS); le certificat du serveur n'est pas approuvé.", "login_error_forbidden": "Ce compte n'est pas autorisé à se connecter ici.", "login_error_generic": "Échec de la connexion. Veuillez réessayer.", "onboarding_error_empty": "Saisissez l'adresse web de votre bibliothèque.", "onboarding_error_network": "Impossible d'atteindre cette adresse. Vérifiez l'URL et votre connexion.", - "onboarding_error_tls": "Impossible d'établir une connexion HTTPS sécurisée — le certificat TLS du serveur n'est pas approuvé. Vérifiez le certificat sur votre serveur.", + "onboarding_error_tls": "Impossible d'établir une connexion HTTPS sécurisée; le certificat TLS du serveur n'est pas approuvé. Vérifiez le certificat sur votre serveur.", "onboarding_error_not_found": "Cela ne ressemble pas à une bibliothèque Pinakes (aucune API trouvée).", "onboarding_error_generic": "Impossible de se connecter à cette bibliothèque.", "availability_reserved": "Réservé", @@ -403,7 +403,7 @@ "book_club_rules": "Règlement du club", "book_club_join": "Rejoindre ce club", "book_club_join_active": "Vous avez rejoint le club", - "book_club_join_pending": "Demande envoyée — en attente d'approbation", + "book_club_join_pending": "Demande envoyée; en attente d'approbation", "book_club_update_progress": "Mettre à jour la progression", "book_club_progress_mark_finished": "J'ai terminé ce livre", "book_club_progress_saved": "Progression enregistrée", @@ -434,7 +434,7 @@ "book_club_privacy_invite": "Sur invitation", "book_club_privacy_hidden": "Masqué", "book_club_error_dashboard": "Impossible de charger votre section de lecture. Tirez pour actualiser.", - "register_schema_failed": "Impossible de charger les champs d'inscription supplémentaires — vous pouvez quand même vous inscrire, ou réessayer.", + "register_schema_failed": "Impossible de charger les champs d'inscription supplémentaires; vous pouvez quand même vous inscrire, ou réessayer.", "action_clear": "Effacer", "profile_action_periodicals": "Périodiques", "periodicals_title": "Périodiques", @@ -499,6 +499,9 @@ "periodicals_status_smarrito": "Perdu", "periodicals_status_atteso": "Attendu", "standalone_articles_title": "Articles", + "standalone_article_online_resource": "Ressource en ligne", + "standalone_article_resource_reference": "Référence d’archive", + "standalone_article_open_web": "Ouvrir l’article sur le site", "standalone_article_title": "Article", "standalone_articles_in_masthead": "Articles de cette publication", "standalone_articles_search": "Titre, auteur ou publication", @@ -522,5 +525,100 @@ "standalone_article_keywords": "Mots-clés", "standalone_article_open_masthead": "Consulter la publication", "standalone_article_open_issue": "Consulter le numéro", - "action_refresh": "Actualiser" + "action_refresh": "Actualiser", + "restyle_details": "Détails", + "restyle_unknown_author": "Auteur inconnu", + "restyle_grid": "Vue grille", + "restyle_list": "Vue liste", + "restyle_digital": "Contenus numériques", + "restyle_download": "Télécharger", + "restyle_digital_edition": "Édition numérique", + "restyle_audiobook": "Livre audio", + "restyle_search_catalog": "Rechercher dans le catalogue", + "book_meta_format": "Format", + "book_meta_series": "Collection", + "collection_play": "Lire", + "book_meta_edition": "Édition", + "book_meta_publication_place": "Lieu de publication", + "desiderata_title": "Desiderata", + "desiderata_intro": "Les livres recherchés par la bibliothèque. Vous pouvez proposer de donner un exemplaire.", + "desiderata_wanted": "Recherché par la bibliothèque", + "desiderata_offer": "Je peux donner ce livre", + "desiderata_free_offer": "Proposer un autre livre", + "desiderata_empty": "Aucun livre recherché actuellement", + "collection_search": "Rechercher dans les collections", + "collection_more": "Afficher plus", + "collection_web": "Ouvrir sur le site", + "collection_manage": "Gérer sur le site", + "collection_loading": "Chargement…", + "collection_empty": "Aucun résultat", + "collection_explore": "Explorer les collections", + "archives_title": "Archives", + "archives_intro": "Les fonds, séries, dossiers et documents de la bibliothèque.", + "archives_fonds": "Fonds", + "archives_series": "Séries", + "archives_files": "Dossiers", + "archives_items": "Pièces", + "archives_from": "Depuis l’année", + "archives_to": "Jusqu’à l’année", + "archives_dates_error": "Saisissez une période valide entre −32768 et 32767.", + "archives_apply": "Appliquer la période", + "collection_clear": "Effacer les filtres", + "archives_children": "Parcourir les unités descendantes", + "archives_documents": "Documents", + "archives_authorities": "Personnes et organismes associés", + "archives_exports": "Exporter la description", + "archives_reference": "Cote", + "archives_level": "Niveau", + "archives_dates": "Dates", + "archives_extent": "Importance et support", + "archives_material": "Type de document", + "archives_description": "Contenu", + "archives_history": "Histoire archivistique", + "archives_access": "Conditions d’accès", + "archives_reproduction": "Conditions de reproduction", + "archives_arrangement": "Classement", + "archives_finding_aids": "Instruments de recherche", + "archives_originals": "Localisation des originaux", + "archives_copies": "Localisation des copies", + "archives_related": "Unités associées", + "archives_institution": "Institution", + "archives_classification": "Classement local", + "archives_collection": "Collection", + "archives_photographer": "Photographe / auteur", + "donation_title": "Proposition de don", + "donation_contact": "La bibliothèque vous contactera avec les coordonnées de votre compte. Une proposition n’ajoute pas d’exemplaire au catalogue.", + "donation_notes": "État du livre et remarques", + "donation_consent": "J’accepte d’être contacté au sujet de ce don", + "donation_send": "Envoyer la proposition", + "donation_success": "Proposition envoyée", + "donation_success_body": "Merci ! La bibliothèque examinera votre proposition et vous contactera pour organiser la remise.", + "donation_uncertain": "Le résultat de l’envoi n’est pas encore confirmé. Réessayez pour le vérifier sans créer une seconde proposition.", + "donation_done": "Retour aux desiderata", + "article_editors": "Directeurs du volume", + "article_country": "Pays", + "article_classification": "Classification", + "article_holdings": "Note de possession", + "citation_cite": "Citer", + "article_cite": "Citer cet article", + "citation_copy": "Copier la citation", + "citation_copied": "Citation copiée", + "citation_ris": "Télécharger la citation RIS", + "article_catalogued": "Articles catalogués", + "article_toc": "Sommaire imprimé", + "article_issue_search": "Rechercher dans ce numéro", + "donation_book_title": "Titre du livre", + "donation_author": "Auteur", + "article_anthology": "Anthologie", + "article_print": "Imprimé", + "article_digital": "Numérique", + "article_both": "Imprimé et numérique", + "article_format_unknown": "Non précisé", + "sort_author_asc": "Auteur (A–Z)", + "collection_upgrade_required": "Mettez Pinakes à jour sur le serveur pour utiliser ces filtres sur les articles.", + "book_section_related_documents": "Documents associés", + "archives_dimensions": "Dimensions", + "archives_color": "Couleur", + "archives_predominant_dates": "Dates prédominantes", + "archives_date_gaps": "Lacunes chronologiques" } diff --git a/i18n/it.json b/i18n/it.json index 33cecde..e901b7c 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -41,9 +41,9 @@ "action_continue": "Continua", "onboarding_http_warning": "Questa biblioteca usa una connessione non sicura (http). Usa https per continuare.", "onboarding_allow_http_label": "Consenti HTTP non sicuro", - "onboarding_allow_http_hint": "Solo per un’istanza self-hosted senza HTTPS. La connessione non sarà cifrata — usalo a tuo rischio.", + "onboarding_allow_http_hint": "Solo per un’istanza self-hosted senza HTTPS. La connessione non sarà cifrata; usalo a tuo rischio.", "onboarding_status_secure_ok": "Connessione sicura (https)", - "onboarding_status_secure_warn": "Connessione non sicura (http) — sconsigliata", + "onboarding_status_secure_warn": "Connessione non sicura (http); sconsigliata", "onboarding_status_app_access_ok": "L'accesso da app mobile è attivo", "onboarding_status_app_access_warn": "L'accesso da app mobile è disattivato su questa biblioteca", "onboarding_version_prefix": "Pinakes %1$s", @@ -257,13 +257,13 @@ "book_loan_legend_limited": "Limitato", "book_loan_legend_unavailable": "Non disponibile", "book_loan_availability_loading": "Verifica disponibilità…", - "book_loan_availability_error": "Impossibile caricare il calendario di disponibilità — scegli una data futura.", + "book_loan_availability_error": "Impossibile caricare il calendario di disponibilità; scegli una data futura.", "book_loan_pick_prompt": "Seleziona un giorno disponibile", "cd_prev_month": "Mese precedente", "cd_next_month": "Mese successivo", "snackbar_request_for_date": "Prestito richiesto per il %1$s", "snackbar_reservation_for_date": "Prenotazione inserita per il %1$s", - "snackbar_loan_ready_for_pickup": "Prestito approvato — pronto per il ritiro.", + "snackbar_loan_ready_for_pickup": "Prestito approvato; pronto per il ritiro.", "book_section_audiobook": "Audiolibro", "audio_play": "Riproduci", "audio_pause": "Pausa", @@ -317,7 +317,7 @@ "snackbar_added_to_wishlist": "Aggiunto alla wishlist", "snackbar_removed_from_wishlist": "Rimosso dalla wishlist", "snackbar_wishlist_update_error": "Impossibile aggiornare la wishlist.", - "snackbar_request_submitted": "Richiesta inviata — la biblioteca la esaminerà.", + "snackbar_request_submitted": "Richiesta inviata; la biblioteca la esaminerà.", "snackbar_request_conflict": "Questo libro non è disponibile per le date richieste.", "snackbar_request_error": "Impossibile inviare la richiesta.", "library_error_load": "Impossibile caricare la tua biblioteca.", @@ -338,12 +338,12 @@ "login_error_rate_limited_seconds": "Troppi tentativi. Riprova tra %1$ds.", "login_error_rate_limited": "Troppi tentativi. Attendi e riprova.", "login_error_network": "Errore di rete. Controlla la connessione.", - "login_error_tls": "Connessione sicura (TLS) fallita — il certificato del server non è attendibile.", + "login_error_tls": "Connessione sicura (TLS) fallita; il certificato del server non è attendibile.", "login_error_forbidden": "Questo account non può accedere qui.", "login_error_generic": "Accesso non riuscito. Riprova.", "onboarding_error_empty": "Inserisci l'indirizzo web della tua biblioteca.", "onboarding_error_network": "Impossibile raggiungere quell'indirizzo. Controlla l'URL e la connessione.", - "onboarding_error_tls": "Impossibile stabilire una connessione HTTPS sicura — il certificato TLS del server non è attendibile. Controlla il certificato sul server.", + "onboarding_error_tls": "Impossibile stabilire una connessione HTTPS sicura; il certificato TLS del server non è attendibile. Controlla il certificato sul server.", "onboarding_error_not_found": "Questa non sembra una biblioteca Pinakes (nessuna API trovata).", "onboarding_error_generic": "Impossibile connettersi a quella biblioteca.", "availability_reserved": "Prenotato", @@ -403,7 +403,7 @@ "book_club_rules": "Regolamento del club", "book_club_join": "Unisciti al club", "book_club_join_active": "Ti sei unito al club", - "book_club_join_pending": "Richiesta inviata — in attesa di approvazione", + "book_club_join_pending": "Richiesta inviata; in attesa di approvazione", "book_club_update_progress": "Aggiorna il progresso", "book_club_progress_mark_finished": "Ho finito questo libro", "book_club_progress_saved": "Progresso salvato", @@ -434,7 +434,7 @@ "book_club_privacy_invite": "Solo su invito", "book_club_privacy_hidden": "Nascosto", "book_club_error_dashboard": "Impossibile caricare la sezione delle tue letture. Trascina per aggiornare.", - "register_schema_failed": "Impossibile caricare i campi di registrazione aggiuntivi — puoi comunque registrarti, oppure riprovare.", + "register_schema_failed": "Impossibile caricare i campi di registrazione aggiuntivi; puoi comunque registrarti, oppure riprovare.", "action_clear": "Cancella", "profile_action_periodicals": "Emeroteca", "periodicals_title": "Emeroteca", @@ -499,6 +499,9 @@ "periodicals_status_smarrito": "Smarrito", "periodicals_status_atteso": "Atteso", "standalone_articles_title": "Articoli", + "standalone_article_online_resource": "Risorsa online", + "standalone_article_resource_reference": "Riferimento archivistico", + "standalone_article_open_web": "Apri articolo sul sito", "standalone_article_title": "Articolo", "standalone_articles_in_masthead": "Articoli di questa testata", "standalone_articles_search": "Titolo, autore o pubblicazione", @@ -522,5 +525,100 @@ "standalone_article_keywords": "Parole chiave", "standalone_article_open_masthead": "Consulta la testata", "standalone_article_open_issue": "Consulta il fascicolo", - "action_refresh": "Aggiorna" + "action_refresh": "Aggiorna", + "restyle_details": "Dettagli", + "restyle_unknown_author": "Autore sconosciuto", + "restyle_grid": "Vista griglia", + "restyle_list": "Vista elenco", + "restyle_digital": "Contenuti digitali", + "restyle_download": "Scarica", + "restyle_digital_edition": "Edizione digitale", + "restyle_audiobook": "Audiobook", + "restyle_search_catalog": "Cerca nel catalogo", + "book_meta_format": "Formato", + "book_meta_series": "Collana", + "collection_play": "Riproduci", + "book_meta_edition": "Edizione", + "book_meta_publication_place": "Luogo di pubblicazione", + "desiderata_title": "Desiderata", + "desiderata_intro": "I libri che la biblioteca cerca. Se ne hai una copia, puoi proporla in donazione.", + "desiderata_wanted": "Cercato dalla biblioteca", + "desiderata_offer": "Ce l’ho, posso donarlo", + "desiderata_free_offer": "Proponi un altro libro", + "desiderata_empty": "Nessun libro richiesto al momento", + "collection_search": "Cerca nelle raccolte", + "collection_more": "Mostra altri", + "collection_web": "Apri sul sito", + "collection_manage": "Gestisci sul sito", + "collection_loading": "Caricamento…", + "collection_empty": "Nessun risultato", + "collection_explore": "Esplora le raccolte", + "archives_title": "Archivio", + "archives_intro": "Fondi, serie, fascicoli e documenti della biblioteca.", + "archives_fonds": "Fondi", + "archives_series": "Serie", + "archives_files": "Fascicoli", + "archives_items": "Unità", + "archives_from": "Dall’anno", + "archives_to": "All’anno", + "archives_dates_error": "Inserisci un intervallo valido tra −32768 e 32767.", + "archives_apply": "Applica periodo", + "collection_clear": "Pulisci filtri", + "archives_children": "Esplora le unità discendenti", + "archives_documents": "Documenti", + "archives_authorities": "Persone ed enti collegati", + "archives_exports": "Esporta la descrizione", + "archives_reference": "Segnatura", + "archives_level": "Livello", + "archives_dates": "Datazione", + "archives_extent": "Estensione e supporto", + "archives_material": "Tipo di materiale", + "archives_description": "Contenuto", + "archives_history": "Storia archivistica", + "archives_access": "Condizioni di accesso", + "archives_reproduction": "Condizioni di riproduzione", + "archives_arrangement": "Ordinamento", + "archives_finding_aids": "Strumenti di ricerca", + "archives_originals": "Ubicazione degli originali", + "archives_copies": "Ubicazione delle copie", + "archives_related": "Unità collegate", + "archives_institution": "Istituzione", + "archives_classification": "Classificazione locale", + "archives_collection": "Collezione", + "archives_photographer": "Fotografo / autore", + "donation_title": "Proposta di donazione", + "donation_contact": "La biblioteca ti contatterà ai recapiti del tuo account. La proposta non registra ancora una copia nel catalogo.", + "donation_notes": "Condizioni del libro e note", + "donation_consent": "Acconsento a essere contattato per questa donazione", + "donation_send": "Invia proposta", + "donation_success": "Proposta inviata", + "donation_success_body": "Grazie! La biblioteca valuterà la proposta e ti contatterà per organizzare la consegna.", + "donation_uncertain": "L’esito dell’invio non è ancora confermato. Riprova per verificarlo senza creare una seconda proposta.", + "donation_done": "Torna a Desiderata", + "article_editors": "Curatori del volume", + "article_country": "Paese", + "article_classification": "Classificazione", + "article_holdings": "Nota di possesso", + "citation_cite": "Cita", + "article_cite": "Cita questo articolo", + "citation_copy": "Copia citazione", + "citation_copied": "Citazione copiata", + "citation_ris": "Scarica citazione RIS", + "article_catalogued": "Articoli catalogati", + "article_toc": "Sommario cartaceo", + "article_issue_search": "Cerca in questo fascicolo", + "donation_book_title": "Titolo del libro", + "donation_author": "Autore", + "article_anthology": "Antologia", + "article_print": "Cartaceo", + "article_digital": "Digitale", + "article_both": "Cartaceo e digitale", + "article_format_unknown": "Non specificato", + "sort_author_asc": "Autore (A–Z)", + "collection_upgrade_required": "Aggiorna Pinakes sul server per usare questi filtri sugli articoli.", + "book_section_related_documents": "Documenti collegati", + "archives_dimensions": "Dimensioni", + "archives_color": "Colore", + "archives_predominant_dates": "Date prevalenti", + "archives_date_gaps": "Lacune cronologiche" } diff --git a/tools/run-emulator.sh b/tools/run-emulator.sh new file mode 100755 index 0000000..572ad04 --- /dev/null +++ b/tools/run-emulator.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -eq 0 ]]; then + printf 'Usage: %s AVD_NAME [emulator options...]\n' "$0" >&2 + exit 64 +fi + +sdk_dir="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" +if [[ -n "$sdk_dir" && -x "$sdk_dir/emulator/emulator" ]]; then + emulator_bin="$sdk_dir/emulator/emulator" +elif emulator_bin="$(command -v emulator)"; then + : +else + printf 'Android emulator not found. Set ANDROID_HOME or add emulator to PATH.\n' >&2 + exit 127 +fi + +avd_name="$1" +shift +emulator_pid='' +assertion_pid='' + +cleanup() { + if [[ -n "$assertion_pid" ]]; then + kill "$assertion_pid" 2>/dev/null || true + wait "$assertion_pid" 2>/dev/null || true + fi +} + +stop_emulator() { + if [[ -n "$emulator_pid" ]]; then + kill "$emulator_pid" 2>/dev/null || true + wait "$emulator_pid" 2>/dev/null || true + fi +} + +trap cleanup EXIT +trap 'stop_emulator; exit 130' INT +trap 'stop_emulator; exit 143' TERM + +"$emulator_bin" -avd "$avd_name" "$@" & +emulator_pid=$! + +if [[ "$(uname -s)" == 'Darwin' ]]; then + # Keep the standalone VM active even when its Qt window is occluded. + # The assertion belongs to this PID and ends when the emulator exits. + /usr/bin/caffeinate -i -w "$emulator_pid" & + assertion_pid=$! +fi + +wait "$emulator_pid"
Login screen
Login