# Library ## Purpose Displays the user's saved manga. Fetches the full library once from the server (`libraryMangaListProvider`), then applies all filtering, sorting, search, and grouping entirely on the client. View state persists to SharedPreferences. ## Key files | Path | Responsibility | |---|---| | `features/library/data/category_repository.dart` | Category CRUD/reorder; visible category list | | `features/library/data/graphql/query.graphql` | `AllCategories`, `LibraryManga` (full-library fetch), category mutations | | `.../library/controller/library_controller.dart` | All library providers: full fetch, filter, sort, display mode, query, badge toggles | | `.../library/controller/library_manga_list.dart` | `libraryMangaListProvider` (single full fetch) + per-category and grouped partition providers | | `.../library/controller/library_grouping.dart` | `groupLibrary()` (pure); `libraryGroupedTabsProvider`; `libraryGroupTypeProvider` | | `.../library/library_screen.dart` | Tab bar, AppBar search + filter, tablet end-drawer; routes BY_DEFAULT vs grouped modes | | `.../library/category_manga_list.dart` | Per-tab grid/list/descriptive-list/cover-only; multi-select action bar | | `.../library/widgets/library_manga_organizer.dart` | 4-tab organizer sheet (Filter / Sort / Display / Group) | | `.../library/widgets/library_manga_filter.dart` | Filter tab: tri-state filters, category and tag include/exclude dialogs, per-tracker filter | | `.../library/domain/library_search_query.dart` | Search DSL parser/evaluator (`tag:`/`genre:`/`rating:` metatags, quotes, `-` negation) | | `.../library/widgets/library_manga_sort_tile.dart` | Sort tab: sort key + direction | | `.../library/widgets/library_manga_display.dart` | Display tab: display mode, portrait/landscape column sliders, badge toggles, tab toggles | | `.../library/widgets/library_manga_group.dart` | Group tab: grouping mode radio list | | `.../category/...` | Edit-category screen, tiles, dialogs, create FAB | | `lib/src/widgets/manga_cover/providers/manga_cover_providers.dart` | Badge/display providers shared with browse | | `features/library/domain/library_group.dart` | `LibraryGroup` constants; `kDefaultLibraryGroupType`; `statusOrder` | | `features/library/domain/track_status.dart` | `kTrackStatusInfo`, `trackStatusOrder`, `trackStatusLabel` | | `features/offline/data/offline_download_providers.dart` | Full-library metadata cache for offline mode (Task 0b) | ## Data flow 1. `libraryMangaListProvider` — single `LibraryManga` GraphQL fetch returning ALL in-library manga with categories, track records, and offline metadata. In offline mode falls back to the locally persisted metadata cache (Task 0b). 2. `categoryMangaListWithQueryAndFilterProvider(categoryId)` — partitions the full library list to the given category, then applies `applyMangaFilter` + `applyMangaSort` + text query. Used by `CategoryMangaList` in BY_DEFAULT mode. 3. `groupedMangaListWithQueryAndFilterProvider(mangaIds)` — same filter/sort pipeline over an arbitrary manga-id set; used by grouped-mode tabs. 4. `libraryGroupedTabsProvider` — combines the full list + `libraryGroupTypeProvider` + visible categories into `List` via `groupLibrary()`. **`applyMangaFilter`** — eight tri-state `bool?` filters ANDed (`null`=off, `true`/`false` via XOR-style tri-state): unread, downloaded, completed, started, bookmarked, onDevice (offline), lewd, per-tracker (one filter per logged-in tracker). Category include/exclude is a separate bool-guarded set-membership filter; user-tag include/exclude works the same way, but include is OR (has any selected tag) since a manga carries many tags. Minimum personal star rating is a separate `int` threshold. **Search DSL** (`LibrarySearchQuery`) — the AppBar query is parsed once per pipeline run into AND-ed terms. Plain words match title/author/genre (substring); `key:value` tokens match a field: `tag:` (exact, case-insensitive), `genre:`/`author:`/`artist:`/ `title:` (substring), `unread:`/`downloaded:` (bool), `rating:` (int with optional `>=`/`<=`/`>`/`<`). Multi-word values quote (`tag:"slice of life"`), a leading `-` negates, and an unrecognized key falls back to plain text (so `Re:Zero` still works). Follows Mihon/Komikku library-search conventions. Pure + unit-tested (`test/src/features/library/library_search_query_test.dart`); `MangaDto.query()` and `MangaDto.filterFields` bridge it to the GraphQL type. **`applyMangaSort`** — `MangaSort` x direction: `alphabetical`, `unread`, `dateAdded`, `lastUpdated`, `lastChapterDate`, `totalChapters`, `lastRead`, `random` (stable per-session seed), `trackerScore` (normalised 0–10 across scales). ## Display modes | Mode | Widget | Notes | |---|---|---| | `grid` | `MangaCoverGridTile` | Width-based auto-columns or fixed count | | `list` | `MangaCoverListTile` | Fixed itemExtent 96 | | `descriptiveList` | `MangaCoverDescriptiveListTile` | Fixed itemExtent 176 | | `coverOnly` | `MangaCoverGridTile(showTitle: false)` | Same grid delegate as grid; distinct icon (`view_comfy_rounded`) | Portrait and landscape column counts persisted separately (`libraryPortraitColumnsProvider` / `libraryLandscapeColumnsProvider`). When count is 0 the width-based auto-delegate (`mangaCoverGridDelegate`, default 192 px) is used. ## Badges `downloadedBadge` (default **false**), `unreadBadge` (default **true**), `languageBadge`, `localBadge`, `sourceBadge` — all toggled in the Display tab. `useLangIcon` swaps the language text badge for the source icon when language badge is on. Providers live in `manga_cover/providers/`, shared with browse. Continue-reading overlay button (`showContinueReadingButtonProvider`) opens the first unread chapter directly. ## Grouping `libraryGroupTypeProvider` persists the selected mode: | Constant | Value | Behaviour | |---|---|---| | `LibraryGroup.byDefault` | 0 | Category tabs (server-side categories, existing behaviour) | | `LibraryGroup.bySource` | 1 | Client-side source tabs, sorted by name | | `LibraryGroup.byStatus` | 2 | Client-side status tabs, sorted by `statusOrder` | | `LibraryGroup.byTrackStatus` | 3 | Per-track-status tabs (shown only when trackers logged in) | | `LibraryGroup.ungrouped` | 4 | Single "All" tab | `kDefaultLibraryGroupType = 0` — typed constant replacing raw `DBKeys.*.initial as int` casts at all three call sites. `groupLibrary()` is a pure function; tested in `test/library/library_grouping_test.dart`. The library selection bar offers per-series server download presets: Next and Unread honor preferred scanlator groups, while All retains the original coverage across all groups. ## Organizer tabs The organizer sheet (`LibraryMangaOrganizer`) has four tabs: - **Filter** — tri-state filters (unread, downloaded, completed, started, bookmarked, on-device, lewd); minimum-rating row; category include/exclude dialog; tag include/exclude dialog (options from `libraryTagListProvider`); per-tracker tri-state rows. Single logged-in tracker collapses heading + row into one "Tracked" toggle tile (Komikku LibrarySettingsDialog.kt:190-215 parity). - **Sort** — `MangaSort` radio + direction toggle. - **Display** — display mode radio; portrait/landscape column count sliders; badge toggles (downloaded, unread, continue-reading, language, local, source); tab-section toggles (category tabs, show hidden, item count). - **Group** — grouping mode radio (BY_TRACK_STATUS shown only when trackers present). ## Persistence (DBKeys) `mangaSort` (**`MangaSort.lastRead`**), `mangaSortDirection` (`true`/asc), `mangaFilter{Downloaded,Unread,Completed,Started,Bookmarked,Offline,Lewd}` (`null`), per-tracker filter map, category include/exclude lists, tag include/exclude lists (`filterTags`/`filterTagsInclude`/`filterTagsExclude`), `mangaFilterMinRating` (0), `libraryDisplayMode` (`grid`), `libraryPortraitColumns` (0 = auto), `libraryLandscapeColumns` (0 = auto), `downloadedBadge` (`false`), `unreadBadge` (`true`), `languageBadge` (`false`), `useLangIcon` (`false`), `localBadge` (`false`), `sourceBadge` (`false`), `showContinueReadingButton` (`false`), `categoryTabs` (`true`), `showHiddenCategories` (`false`), `categoryNumberOfItems` (`false`), `libraryGroupType` (0 = BY_DEFAULT), `gridMangaCoverMinWidth` (128.0 minimum). Automatic columns follow Mihon's rule: `max(1, floor((width + spacing) / (minimum + spacing)))`. A one-time migration converts the old `gridMangaCoverWidth` value × 2/3, clamps it to 100–999, and removes the old key after saving; an existing new key is preserved. Search query is session-only (no persistence). ## Duplicate detection `domain/duplicate_matcher.dart` is the shared matcher (pure Dart, no Flutter) used by both the library-wide scan below and browse's add-to-library flow: - Titles match via NFKC-normalized exact equality (`normalizeTitle`), not Komikku's substring LIKE — deliberately stricter (design 2026-07-24). - Tracker matches are certain: a shared `(trackerId, remoteId)` pair. - An optional description-substring pass (title text found inside another entry's description, word-boundary padded) is off by default and gated on a minimum title length to avoid generic false hits. - Grouping is two-stage: title/tracker edges are true equivalences and union transitively (one pass, `_UnionFind`); description hits are NOT transitive and only ever attach a free entry to an existing cluster or pair two free entries — never merge two real clusters. `domain/duplicate_entry_mapper.dart` snapshots a `MangaDto` into the matcher's plain-Dart `DupEntry` shape. **Library-wide scan** (`presentation/duplicates/`, #117) — `LibraryDuplicatesScreen` + `LibraryDuplicatesController`. Runs off the main thread via `compute()` (web has no isolates, so it self-yields via a chunked pass instead). Reads the library once per scan — a routine library invalidation elsewhere never re-triggers the O(n²) scan; refresh is only via the explicit rescan button. Entry points: the library overflow menu (`update_status_popup_menu.dart`) and Library settings. Multi-select removes flagged entries via `removeMangaFromLibraryAndPurge`, confirmed first, filtering the already-scanned groups in memory rather than rescanning. Offline degrades to title-only matching (no fresh tracker/description data to trust). ## Gotchas - **`lastRead` sort is internally reversed** — the comparator swaps m1/m2 arguments. "Ascending" direction yields **most-recently-read first**. Do not fix — Mihon parity, and the **default sort is `lastRead`**, so new installs open most-recent-first. - **Empty custom categories are hidden** (`visibleCategoryListProvider`). The default category remains available when empty, unless deliberately hidden. The empty library links to Categories, where users can add or unhide categories. - **Filter/sort/display/group changes are global** — applied to all tabs at once. - **Tab index** (BY_DEFAULT mode) comes from the `:categoryId` route param matched to the visible category list — not persisted as a tab index. - **Badge providers live outside the library tree** (`widgets/manga_cover/providers/`), shared with browse. - **Timestamps are string-encoded epochs** parsed inline — invalid/missing values sort to the bottom. - **BY_TRACK_STATUS** tab is only offered in the Group tab when at least one tracker is logged in (`loggedInTrackersProvider`). - **Random sort seed** is session-stable: resets on app restart, not on every toggle. ## Account default category `data/default_category.dart` resolves the special default category separately from the category list. Account-capable servers use the paged `AccountCategoryIdentities` query; legacy servers use ID 0. An unresolved identity disables protected category actions and category assignment. The legacy category document does not contain account-only fields. `CategoryDto.defaultCategory` is the automatic-add preference. It is independent of the special category identity. Offline category rows preserve both values, and uncategorized downloads use the mirrored special category. The add-to-library setting keeps 0 as its uncategorized choice regardless of the server category ID.