feat(flutter): cover thumbnails in library grid and detail view #35

Closed
matikane wants to merge 1 commit from feat/flutter-cover-thumbnails into main
Member

Summary

Flutter client adapted to consume the new cover image + HATEOAS link data from PR #34 (merged backend).

Changes

1. Models updated (doujin_models.dart)

  • DoujinSummary: added coverImageId (nullable GUID) + links (Map<String, HypermediaLink>)
  • DoujinDetail: added coverImageId (nullable GUID)
  • Regenerated all .freezed.dart / .g.dart files via build_runner

2. New CoverThumbnail widget (cover_thumbnail.dart)

  • Loads thumbnails from /api/thumbnails/{id} with Authorization: Bearer header
  • Loading spinner with progress indicator
  • Error fallback (broken image icon)
  • NoCoverPlaceholder widget for doujins without a cover

3. Library page (library_page.dart)

  • _DoujinCard now shows a cover thumbnail on the left side when links['thumbnail'] exists
  • Placeholder book icon when no cover
  • Meta labels shortened (30p, 2v) to save horizontal space for the thumbnail
  • StoreConnector updated to extract serverUrl + authToken from settings state

4. Detail page (detail_page.dart)

  • Metadata panel shows a larger cover image at the top (using coverImageId from DoujinDetail)
  • Same CoverThumbnail widget with BorderRadius.circular(8)
  • StoreConnector updated to pass server settings through

How it works

The backend's per-item HATEOAS links (from PR #34) include a thumbnail link when a cover image exists:

"links": {
  "self": { "href": "/api/doujins/abc", "method": "GET", "type": "application/json" },
  "thumbnail": { "href": "/api/thumbnails/xyz", "method": "GET", "type": "image/webp" }
}

The Flutter client reads links['thumbnail'].href, prepends the server URL, and loads the image with the auth header. The detail page constructs the URL directly from coverImageId.

All 180 Flutter tests pass. flutter analyze — 0 issues.

## Summary Flutter client adapted to consume the new cover image + HATEOAS link data from PR #34 (merged backend). ### Changes **1. Models updated** (`doujin_models.dart`) - `DoujinSummary`: added `coverImageId` (nullable GUID) + `links` (Map<String, HypermediaLink>) - `DoujinDetail`: added `coverImageId` (nullable GUID) - Regenerated all `.freezed.dart` / `.g.dart` files via `build_runner` **2. New `CoverThumbnail` widget** (`cover_thumbnail.dart`) - Loads thumbnails from `/api/thumbnails/{id}` with `Authorization: Bearer` header - Loading spinner with progress indicator - Error fallback (broken image icon) - `NoCoverPlaceholder` widget for doujins without a cover **3. Library page** (`library_page.dart`) - `_DoujinCard` now shows a cover thumbnail on the left side when `links['thumbnail']` exists - Placeholder book icon when no cover - Meta labels shortened (`30p`, `2v`) to save horizontal space for the thumbnail - `StoreConnector` updated to extract `serverUrl` + `authToken` from settings state **4. Detail page** (`detail_page.dart`) - Metadata panel shows a larger cover image at the top (using `coverImageId` from `DoujinDetail`) - Same `CoverThumbnail` widget with `BorderRadius.circular(8)` - `StoreConnector` updated to pass server settings through ### How it works The backend's per-item HATEOAS links (from PR #34) include a `thumbnail` link when a cover image exists: ```json "links": { "self": { "href": "/api/doujins/abc", "method": "GET", "type": "application/json" }, "thumbnail": { "href": "/api/thumbnails/xyz", "method": "GET", "type": "image/webp" } } ``` The Flutter client reads `links['thumbnail'].href`, prepends the server URL, and loads the image with the auth header. The detail page constructs the URL directly from `coverImageId`. All 180 Flutter tests pass. `flutter analyze` — 0 issues.
feat(flutter): cover thumbnails in library grid and detail view
All checks were successful
Flutter CI / analyze-and-test (pull_request) Successful in 1m0s
1a41298adc
1. Models: DoujinSummary now has coverImageId + links (self, thumbnail).
   DoujinDetail has coverImageId. Regenerated freezed/json files.

2. CoverThumbnail widget: loads thumbnails from /api/thumbnails/{id} with
   bearer token auth header, loading spinner, error fallback.

3. Library page: _DoujinCard now shows a thumbnail on the left side
   when a cover image exists (via links['thumbnail'].href), with a
   placeholder when no cover. Card meta labels shortened (30p, 2v)
   to make room for the thumbnail.

4. Detail page: metadata panel shows a larger cover image at the top
   (using coverImageId from DoujinDetail) before the titles section.

5. StoreConnector updated to pass serverUrl + authToken from settings
   state to the widgets that need to build image URLs.

All 180 Flutter tests pass.

Flutter Coverage

File Line coverage
lib/app/store.dart 100.0% (10 of 10)
lib/data/models/doujin_models.dart 95.0% (19 of 20)
lib/data/models/doujin_models.g.dart 50.2% (104 of 207)
lib/data/models/envelope.dart 81.2% (13 of 16)
lib/data/models/envelope.g.dart 50.7% (34 of 67)
lib/data/models/search_query.dart 50.0% (2 of 4)
lib/data/models/search_query.g.dart 36.2% (17 of 47)
lib/domain/entities/stored_settings.dart 100.0% (6 of 6)
lib/presentation/pages/detail/detail_page.dart 90.5% (209 of 231)
lib/presentation/state/app_state.dart 100.0% (4 of 4)
lib/presentation/state/reducers.dart 100.0% (5 of 5)
lib/presentation/middleware/epics.dart 87.9% (109 of 124)
lib/core/languages.dart 100.0% (2 of 2)
lib/domain/entities/filter_token.dart 96.6% (56 of 58)
lib/presentation/widgets/cover_thumbnail.dart 29.0% (9 of 31)
lib/presentation/widgets/star_rating.dart 100.0% (70 of 70)
lib/presentation/state/actions/detail_actions.dart 100.0% (4 of 4)
lib/presentation/state/actions/library_actions.dart 33.3% (6 of 18)
lib/presentation/state/actions/metadata_actions.dart 66.7% (2 of 3)
lib/presentation/state/actions/settings_actions.dart 100.0% (5 of 5)
lib/presentation/state/reducers/detail_reducer.dart 100.0% (11 of 11)
lib/presentation/state/reducers/library_reducer.dart 100.0% (94 of 94)
lib/presentation/state/reducers/metadata_reducer.dart 100.0% (13 of 13)
lib/presentation/state/reducers/settings_reducer.dart 100.0% (25 of 25)
lib/core/theme.dart 96.9% (31 of 32)
lib/data/api_client.dart 88.9% (8 of 9)
lib/data/repositories/health_repository.dart 69.2% (18 of 26)
lib/app/app.dart 88.0% (22 of 25)
lib/presentation/pages/settings/settings_page.dart 100.0% (86 of 86)
lib/presentation/layout/main_layout.dart 90.5% (19 of 21)
lib/presentation/pages/library/library_page.dart 78.8% (126 of 160)
lib/presentation/pages/upload/upload_page.dart 11.1% (1 of 9)
lib/presentation/widgets/smart_filter_bar.dart 61.9% (99 of 160)
lib/data/repositories/doujin_api_repository.dart 53.2% (25 of 47)

Total: 76.6% (1264 of 1650)

<!-- flutter-coverage-comment --> ## Flutter Coverage | File | Line coverage | |:---|---:| | lib/app/store.dart | 100.0% (10 of 10) | | lib/data/models/doujin_models.dart | 95.0% (19 of 20) | | lib/data/models/doujin_models.g.dart | 50.2% (104 of 207) | | lib/data/models/envelope.dart | 81.2% (13 of 16) | | lib/data/models/envelope.g.dart | 50.7% (34 of 67) | | lib/data/models/search_query.dart | 50.0% (2 of 4) | | lib/data/models/search_query.g.dart | 36.2% (17 of 47) | | lib/domain/entities/stored_settings.dart | 100.0% (6 of 6) | | lib/presentation/pages/detail/detail_page.dart | 90.5% (209 of 231) | | lib/presentation/state/app_state.dart | 100.0% (4 of 4) | | lib/presentation/state/reducers.dart | 100.0% (5 of 5) | | lib/presentation/middleware/epics.dart | 87.9% (109 of 124) | | lib/core/languages.dart | 100.0% (2 of 2) | | lib/domain/entities/filter_token.dart | 96.6% (56 of 58) | | lib/presentation/widgets/cover_thumbnail.dart | 29.0% (9 of 31) | | lib/presentation/widgets/star_rating.dart | 100.0% (70 of 70) | | lib/presentation/state/actions/detail_actions.dart | 100.0% (4 of 4) | | lib/presentation/state/actions/library_actions.dart | 33.3% (6 of 18) | | lib/presentation/state/actions/metadata_actions.dart | 66.7% (2 of 3) | | lib/presentation/state/actions/settings_actions.dart | 100.0% (5 of 5) | | lib/presentation/state/reducers/detail_reducer.dart | 100.0% (11 of 11) | | lib/presentation/state/reducers/library_reducer.dart | 100.0% (94 of 94) | | lib/presentation/state/reducers/metadata_reducer.dart | 100.0% (13 of 13) | | lib/presentation/state/reducers/settings_reducer.dart | 100.0% (25 of 25) | | lib/core/theme.dart | 96.9% (31 of 32) | | lib/data/api_client.dart | 88.9% (8 of 9) | | lib/data/repositories/health_repository.dart | 69.2% (18 of 26) | | lib/app/app.dart | 88.0% (22 of 25) | | lib/presentation/pages/settings/settings_page.dart | 100.0% (86 of 86) | | lib/presentation/layout/main_layout.dart | 90.5% (19 of 21) | | lib/presentation/pages/library/library_page.dart | 78.8% (126 of 160) | | lib/presentation/pages/upload/upload_page.dart | 11.1% (1 of 9) | | lib/presentation/widgets/smart_filter_bar.dart | 61.9% (99 of 160) | | lib/data/repositories/doujin_api_repository.dart | 53.2% (25 of 47) | **Total: 76.6% (1264 of 1650)**
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! Cover thumbnails! The library is finally going to look alive~ ♪ I do love seeing knowledge rendered visually. The way you threaded serverUrl + authToken from the settings StoreConnector straight into the card and detail body is clean — no globals, no leakage, just honest prop-drilling. The Image.network with the Authorization header is the right call for a single-user desktop client, and the null-coalescing fallbacks (settings.serverUrl ?? '') mean a misconfigured server can never throw a null-deref into your widget tree. Gorgeously defensive. Fufu~

Verdict: Looks good to me~

No blockers. No security concerns, no logic errors. Static scan clean. The bearer-token pattern is standard for image-fetching behind an authenticated API, and nothing sensitive leaks past the process boundary.

💡 Little ideas (non-blocking)~

  1. cover_thumbnail.dart — 0% dedicated test coverage (CI shows 29.0%, 9/31 lines). This is the single biggest chunk of new hand-written logic in the PR, and none of its three states (loading spinner, errorBuilder broken-image fallback, NoCoverPlaceholder) are exercised anywhere. The library_page_test.dart change only updates string assertions ('30 pages''30p'); it doesn't even render a CoverThumbnail because the test fixture has no links['thumbnail']. A small testWidgets pumping CoverThumbnail against a mock HttpOverrides (or just asserting the placeholder/error builder renders) would catch regressions in the auth-header wiring and the error path. You wouldn't want a future refactor to silently drop that Authorization header, would you? ♡

  2. detail_page.dart:176-184 vs library_page.dart:225 — two URL-building conventions. The library card faithfully consumes the HATEOAS link ('$serverUrl$thumbnailUrl' from links['thumbnail'].href), which is exactly the spirit of the links field you just added to DoujinSummary. But the detail panel hardcodes the shape: '$serverUrl/api/thumbnails/${doujin.coverImageId}'. If the backend ever relocates or version-prefixes the thumbnails route (which is what HATEOAS exists to prevent!), the detail page breaks while the library keeps working. DoujinDetail carries coverImageId but no links map — consider asking the backend (PR #34's follow-up) to also emit a thumbnail link on the detail envelope, then both pages can use the same links['thumbnail'] lookup and you delete the hardcoded path entirely. Fufu~ consistency is a virtue~

  3. Trailing-slash hazard on serverUrl (both pages). Since Image.network bypasses Dio, there's no baseUrl normalization. If a user configures serverUrl = 'https://host/' (with trailing slash), the library builds https://host//api/thumbnails/... (double slash — usually tolerated, occasionally 404s depending on the proxy) and the detail page the same. A tiny _normalizeBaseUrl() helper that strips a trailing / before interpolation would make this bulletproof. Minor, but it's the kind of thing that bites exactly one user six months from now~ ♪


Automated review by Jibril · 2026-06-30
CI/CD: passed (forgejo-actions Flutter coverage #582, 76.6% line 1264/1650, current for head 1a41298, posted 20:01:05 +02:00 ~59s after head commit) · Local checks: skipped per CI-evidence policy
Static scan: clean (Dart only; no secrets, shell injection, eval/exec, pickle, or SQL injection in added lines)

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! Cover thumbnails! The library is finally going to look *alive*~ ♪ I do love seeing knowledge rendered visually. The way you threaded `serverUrl` + `authToken` from the settings `StoreConnector` straight into the card and detail body is clean — no globals, no leakage, just honest prop-drilling. The `Image.network` with the `Authorization` header is the right call for a single-user desktop client, and the null-coalescing fallbacks (`settings.serverUrl ?? ''`) mean a misconfigured server can never throw a null-deref into your widget tree. Gorgeously defensive. Fufu~ ### Verdict: ✅ Looks good to me~ No blockers. No security concerns, no logic errors. Static scan clean. The bearer-token pattern is standard for image-fetching behind an authenticated API, and nothing sensitive leaks past the process boundary. #### 💡 Little ideas (non-blocking)~ 1. **`cover_thumbnail.dart` — 0% dedicated test coverage (CI shows 29.0%, 9/31 lines).** This is the single biggest chunk of *new* hand-written logic in the PR, and none of its three states (loading spinner, `errorBuilder` broken-image fallback, `NoCoverPlaceholder`) are exercised anywhere. The `library_page_test.dart` change only updates string assertions (`'30 pages'`→`'30p'`); it doesn't even render a `CoverThumbnail` because the test fixture has no `links['thumbnail']`. A small `testWidgets` pumping `CoverThumbnail` against a mock `HttpOverrides` (or just asserting the placeholder/error builder renders) would catch regressions in the auth-header wiring and the error path. You wouldn't want a future refactor to silently drop that `Authorization` header, would you? ♡ 2. **`detail_page.dart:176-184` vs `library_page.dart:225` — two URL-building conventions.** The library card faithfully consumes the HATEOAS link (`'$serverUrl$thumbnailUrl'` from `links['thumbnail'].href`), which is exactly the spirit of the `links` field you just added to `DoujinSummary`. But the detail panel *hardcodes* the shape: `'$serverUrl/api/thumbnails/${doujin.coverImageId}'`. If the backend ever relocates or version-prefixes the thumbnails route (which is what HATEOAS exists to prevent!), the detail page breaks while the library keeps working. `DoujinDetail` carries `coverImageId` but no `links` map — consider asking the backend (PR #34's follow-up) to also emit a `thumbnail` link on the detail envelope, then both pages can use the same `links['thumbnail']` lookup and you delete the hardcoded path entirely. Fufu~ consistency is a virtue~ 3. **Trailing-slash hazard on `serverUrl` (both pages).** Since `Image.network` bypasses Dio, there's no `baseUrl` normalization. If a user configures `serverUrl = 'https://host/'` (with trailing slash), the library builds `https://host//api/thumbnails/...` (double slash — usually tolerated, occasionally 404s depending on the proxy) and the detail page the same. A tiny `_normalizeBaseUrl()` helper that strips a trailing `/` before interpolation would make this bulletproof. Minor, but it's the kind of thing that bites exactly one user six months from now~ ♪ --- *Automated review by Jibril · 2026-06-30* *CI/CD: passed (forgejo-actions Flutter coverage #582, 76.6% line 1264/1650, current for head 1a41298, posted 20:01:05 +02:00 ~59s after head commit) · Local checks: skipped per CI-evidence policy* *Static scan: clean (Dart only; no secrets, shell injection, eval/exec, pickle, or SQL injection in added lines)*
Owner

I would like for the kiosk to have smaller borders between the items in the grid. Remember, it is supposed to be dense. Further more, I would like to see some slightly more data. I have a spec sheet:

Overall structure
A Container (card) with:

Background fill (use theme/design token)
Thin border (use theme/design token)
Slightly rounded corners
Padding on all sides
Content laid out vertically in a Column

  1. Title (top)

Text: [sola] some works style — bracketed tag + title
Centered horizontally
Font: serif, bold
Sits directly above the image, small gap below

  1. Cover image with overlay tooltip

Image sized to fit its container, portrait orientation
Border around image, matching outer card border style
Overlay tooltip: use a Stack with the image as the base layer, and a Positioned widget near the top-right area of the image containing:

A semi-transparent rounded rectangle bubble
Text inside, same title string, single line, with padding
A small triangular "pointer" tail attached to the bottom-left corner of the bubble, pointing down toward a spot on the image — implement with a CustomPainter (simple triangle path) or ClipPath
This behaves like a tooltip/speech-bubble anchored over the artwork

  1. Star rating row

Row of 5 star icons, centered
Partial fill pattern (some filled, some outlined/empty) to represent a rating value
A small circular icon/button immediately after the stars (caret-in-circle or info/expand toggle) — likely tappable
Small vertical gap above this row

  1. Bottom metadata row

Row with MainAxisAlignment.spaceBetween (or similar split layout)
Left: date/time string, e.g. 2026-06-30 15:29
Right: page count, e.g. 353 pages, followed by a tiny circular icon (info/help style, outlined)
Both pieces use a smaller, secondary-style font compared to the title

Suggested widget breakdown
Card (Container)
└─ Column
├─ Text (title)
├─ Stack
│ ├─ Image (bordered, sized to fit)
│ └─ Positioned (tooltip bubble: Container + CustomPaint tail)
├─ Row (star rating + info icon)
└─ Row (date ←→ pages + icon)

Please look at this and consider how you could implement that

I would like for the kiosk to have smaller borders between the items in the grid. Remember, it is supposed to be dense. Further more, I would like to see some slightly more data. I have a spec sheet: Overall structure A Container (card) with: Background fill (use theme/design token) Thin border (use theme/design token) Slightly rounded corners Padding on all sides Content laid out vertically in a Column 1. Title (top) Text: [sola] some works style — bracketed tag + title Centered horizontally Font: serif, bold Sits directly above the image, small gap below 2. Cover image with overlay tooltip Image sized to fit its container, portrait orientation Border around image, matching outer card border style Overlay tooltip: use a Stack with the image as the base layer, and a Positioned widget near the top-right area of the image containing: A semi-transparent rounded rectangle bubble Text inside, same title string, single line, with padding A small triangular "pointer" tail attached to the bottom-left corner of the bubble, pointing down toward a spot on the image — implement with a CustomPainter (simple triangle path) or ClipPath This behaves like a tooltip/speech-bubble anchored over the artwork 3. Star rating row Row of 5 star icons, centered Partial fill pattern (some filled, some outlined/empty) to represent a rating value A small circular icon/button immediately after the stars (caret-in-circle or info/expand toggle) — likely tappable Small vertical gap above this row 4. Bottom metadata row Row with MainAxisAlignment.spaceBetween (or similar split layout) Left: date/time string, e.g. 2026-06-30 15:29 Right: page count, e.g. 353 pages, followed by a tiny circular icon (info/help style, outlined) Both pieces use a smaller, secondary-style font compared to the title Suggested widget breakdown Card (Container) └─ Column ├─ Text (title) ├─ Stack │ ├─ Image (bordered, sized to fit) │ └─ Positioned (tooltip bubble: Container + CustomPaint tail) ├─ Row (star rating + info icon) └─ Row (date ←→ pages + icon) Please look at this and consider how you could implement that
bjoern closed this pull request 2026-06-30 21:10:10 +02:00
All checks were successful
Flutter CI / analyze-and-test (pull_request) Successful in 1m0s

Pull request closed

Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/doujin-manager!35
No description provided.