···11+---
22+title: Gallery Viewing Spec
33+updated: 2026-04-25
44+---
55+66+## Summary
77+88+Add two immersive viewing modes for feeds containing media:
99+1010+1. **Slideshow** - full-screen image carousel for image-heavy feeds
1111+2. **Short-form vertical** - TikTok-style vertical swipe for video or mixed feeds
1212+1313+These modes activate from any feed context (timeline, profile, search, list feed,
1414+hashtag, saved posts) when the feed's content qualifies.
1515+1616+## Content Classification
1717+1818+Before entering gallery mode, classify the filtered feed to determine which viewer
1919+to offer. Only posts with image or video embeds participate.
2020+2121+| Embed type | Classification |
2222+| ----------------------------------- | -------------- |
2323+| `embedImagesView` | image |
2424+| `embedVideoView` | video |
2525+| `embedRecordWithMediaView` (images) | image |
2626+| `embedRecordWithMediaView` (video) | video |
2727+| `embedExternalView`, text-only | excluded |
2828+2929+A `GalleryMediaItem` model normalises these into a uniform structure:
3030+3131+```dart
3232+class GalleryMediaItem {
3333+ final FeedViewPost feedViewPost;
3434+ final GalleryMediaType type; // image, video
3535+ final List<ImageViewerItem> images; // populated for image type
3636+ final VideoPlayerRouteArgs? video; // populated for video type
3737+}
3838+```
3939+4040+### Feed filtering
4141+4242+Extract media items from a `List<FeedViewPost>` via a utility:
4343+4444+```dart
4545+List<GalleryMediaItem> extractGalleryItems(List<FeedViewPost> posts);
4646+```
4747+4848+This walks each post's embed (and `recordWithMedia.media`), skipping posts without
4949+qualifying embeds. Moderation filtering applies - `contentMedia` blur/filter
5050+decisions are respected.
5151+5252+## Slideshow Mode (Images)
5353+5454+### Entry point
5555+5656+A "Gallery" icon button in the feed app bar (or FAB) that appears when
5757+`extractGalleryItems` returns at least one item. Tapping it opens
5858+`GalleryScreen` with the extracted items, starting at the first item (or the
5959+item nearest the current scroll position).
6060+6161+### Screen: `GalleryScreen`
6262+6363+Full-screen `PageView` with `PageController`. Each page renders based on
6464+`GalleryMediaType`:
6565+6666+**Image pages:** `PhotoViewGallery` (reuse existing `photo_view` dependency)
6767+showing all images from the post. Multi-image posts use a nested horizontal
6868+`PageView` within the vertical page, with a dot indicator. Pinch-to-zoom and
6969+pan via `PhotoView`. Hero animation from the feed thumbnail when entering from
7070+a specific post.
7171+7272+**Video pages:** Inline `VideoPlayerController` + `Chewie` (reuse existing
7373+dependencies). Auto-play when the page is visible, pause when swiped away.
7474+GIF-style videos (`presentation: "gif"`) loop muted with no controls.
7575+7676+### Navigation
7777+7878+- **Vertical swipe** (default): swipe up/down to move between posts - TikTok
7979+ style. Uses `PageView` with `scrollDirection: Axis.vertical`.
8080+- **Horizontal swipe** (slideshow): swipe left/right. Configurable via a toggle
8181+ in the gallery toolbar.
8282+- Swipe-to-dismiss: vertical drag past threshold when in horizontal mode (or
8383+ horizontal drag in vertical mode) pops the screen.
8484+8585+### Chrome overlay
8686+8787+Semi-transparent top and bottom bars, auto-hiding after 3 seconds of
8888+inactivity. Tap anywhere to toggle.
8989+9090+**Top bar:**
9191+9292+- Close button (X)
9393+- Post author avatar + handle (tap → navigate to profile)
9494+- Page counter (e.g. "3 / 12")
9595+9696+**Bottom bar:**
9797+9898+- Post text snippet (first 2 lines, tap to expand)
9999+- Like / repost / reply action row (reuse `PostActionBar` pattern via
100100+ `PostActionCubit`)
101101+- Download / share buttons (reuse `MediaActions`)
102102+- Alt text badge when present (tap to show full alt text in a sheet)
103103+104104+### State management
105105+106106+`GalleryCubit` manages:
107107+108108+```dart
109109+class GalleryState {
110110+ final List<GalleryMediaItem> items;
111111+ final int currentIndex;
112112+ final bool chromeVisible;
113113+ final Axis scrollDirection;
114114+}
115115+```
116116+117117+Events: `PageChanged`, `ChromeToggled`, `DirectionToggled`.
118118+119119+The cubit receives the pre-filtered list of `GalleryMediaItem` - no additional
120120+API calls. Pagination piggybacks on the parent feed's `FeedBloc` cursor: when
121121+the user reaches the last few pages, the cubit signals the parent to load more,
122122+and new items are appended.
123123+124124+### Preloading
125125+126126+Preload adjacent pages to reduce perceived latency:
127127+128128+- Images: `precacheImage` for ±1 pages
129129+- Videos: initialise `VideoPlayerController` for +1 page, dispose -2 pages
130130+131131+## Short-Form Vertical Video Mode
132132+133133+For feeds that are primarily video, the gallery defaults to vertical scroll
134134+direction with video-first UX:
135135+136136+- Full-bleed video fills the screen (respect `aspectRatio` from embed, pillarbox
137137+ for non-9:16 content)
138138+- Auto-play on visibility, auto-pause on swipe-away
139139+- Tap to pause/resume (no explicit controls unless user taps)
140140+- Double-tap right side → like, double-tap left side → rewind 5s
141141+- Long-press → playback speed options (1x, 1.5x, 2x)
142142+- Progress bar at bottom (thin, YouTube Shorts style)
143143+144144+### Video lifecycle
145145+146146+Use `VisibilityDetector` pattern (or `PageView.onPageChanged`) to:
147147+148148+1. Pause video leaving viewport
149149+2. Play video entering viewport
150150+3. Dispose controllers for pages > 2 positions away
151151+4. Pre-initialise controller for the next page
152152+153153+## Mixed feeds
154154+155155+When a feed has both images and videos, gallery mode interleaves them in feed
156156+order. Each page adapts its renderer based on `GalleryMediaType`. The bottom
157157+bar shows a media type icon (camera/video) so users know what's coming next.
158158+159159+## Entry points
160160+161161+| Context | Trigger |
162162+| ---------------------- | ------------------------------------------------- |
163163+| Home feed tabs | Gallery icon in `LazuriteAppBar` actions |
164164+| Profile feed tab | Gallery icon in profile feed section |
165165+| Search results (Posts) | Gallery icon in search app bar when results shown |
166166+| Hashtag screen | Gallery icon in hashtag app bar |
167167+| List feed | Gallery icon in list detail feed tab |
168168+| Saved posts | Gallery icon in saved posts app bar |
169169+| Post thread | Tap post media opens gallery seeded at that post |
170170+171171+For thread context, tapping a post's media now opens gallery mode seeded with
172172+just that thread's media items, starting at the tapped item.
173173+174174+## Routing
175175+176176+```dart
177177+GoRoute(
178178+ path: '/gallery',
179179+ parentNavigatorKey: _rootNavigatorKey,
180180+ builder: (context, state) {
181181+ final args = state.extra as GalleryRouteArgs;
182182+ return GalleryScreen(args: args);
183183+ },
184184+),
185185+```
186186+187187+`GalleryRouteArgs`:
188188+189189+```dart
190190+class GalleryRouteArgs {
191191+ final List<GalleryMediaItem> items;
192192+ final int initialIndex;
193193+ final Axis initialDirection;
194194+}
195195+```
196196+197197+## Packages
198198+199199+No new dependencies. Reuses:
200200+201201+- `photo_view` - image zoom/pan
202202+- `video_player` + `chewie` - video playback
203203+- `dio` + `gal` - download
204204+- `share_plus` - sharing
205205+- `permission_handler` - gallery save permissions
206206+207207+## Moderation
208208+209209+All gallery items pass through the existing `ModerationService` pipeline.
210210+`contentMedia` blur overlays render atop the gallery page. `noOverride` blurs
211211+cannot be dismissed. Posts filtered at `contentList` level are excluded from the
212212+gallery item list entirely.
213213+214214+## Infinite Scroll
215215+216216+Gallery mode supports continuous scrolling beyond the initially loaded posts.
217217+When the user reaches the last 3 pages, `GalleryCubit` signals the parent
218218+feed's bloc (or cubit) to load the next page via its existing cursor-based
219219+pagination. New posts are filtered through `extractGalleryItems` and appended
220220+to the gallery's item list. A loading shimmer renders on the final page while
221221+the fetch is in progress. When the cursor is exhausted, the gallery shows an
222222+"end of feed" indicator on the last page.
223223+224224+The gallery receives a callback (`onLoadMore`) and a stream/listener for new
225225+posts from the parent. This keeps the gallery decoupled from specific feed
226226+sources - it works identically whether backed by `FeedBloc`, `HashtagCubit`,
227227+`ListFeedBloc`, or `SearchBloc`.
228228+229229+## DM & Notification Media
230230+231231+### Notifications
232232+233233+Notifications that reference posts with media (likes, reposts, quotes, replies
234234+on media posts) are gallery-eligible. `NotificationBloc` already hydrates
235235+referenced posts. `extractGalleryItems` can process the `subjectPost` or
236236+`reasonSubject` from notification views to extract media.
237237+238238+Add a gallery entry point in the notifications screen when loaded notifications
239239+contain media-bearing posts. The gallery is seeded with media items extracted
240240+from notification subjects.
241241+242242+### DMs
243243+244244+The `chat.bsky.convo` lexicon currently supports text-only messages - no image
245245+or video embeds. Gallery mode for DMs is not applicable until the AT Protocol
246246+adds media message support. When/if `chat.bsky.convo.defs#messageView` gains
247247+an `embed` field, the same `extractGalleryItems` pattern applies by adapting the
248248+extractor to accept message views alongside feed views.
249249+250250+## Offline Gallery
251251+252252+Gallery mode works offline using cached feed data from the Drift database.
253253+254254+**Sources:**
255255+256256+| Cache source | Table | Contains embeds? |
257257+|--------------------|--------------------|------------------|
258258+| Feed first pages | `CachedFeedPages` | Yes (full JSON) |
259259+| Saved posts | `SavedPosts` | Yes (`postJson`) |
260260+| Liked posts | `LikedPosts` | Yes (`postJson`) |
261261+262262+When the `ConnectivityCubit` reports offline, the gallery entry point still
263263+appears if cached data contains media items. `extractGalleryItems` processes
264264+deserialised `FeedViewPost` objects from cached JSON the same way it handles
265265+live data.
266266+267267+Offline gallery disables actions requiring network (like, repost, reply) - these
268268+buttons show a disabled state with a tooltip ("Offline"). Download/share actions
269269+are disabled for images/videos not already in the device cache.
270270+271271+Pagination is unavailable offline - the gallery shows only what's cached, with
272272+an "Offline - showing cached posts" indicator when the user reaches the end.
273273+274274+## Media-Only Feed Filtering
275275+276276+No server-side "media-only" feed filter exists in the AT Protocol. Gallery mode
277277+filters client-side, which means the ratio of media-to-text posts in the
278278+underlying feed affects gallery density. To mitigate:
279279+280280+- **Aggressive prefetch:** When gallery mode is active, the parent feed fetches
281281+ with a higher `limit` (100 vs the default 50) to increase the pool of
282282+ candidate posts.
283283+- **Skip ratio indicator:** The chrome overlay shows the media density
284284+ (e.g. "12 media posts from 47 loaded") so users understand the scope.
285285+- **Feed generator hint:** For profile feeds, use `filter: postsWithMedia`
286286+ (`FeedFilter.postsWithMedia`) which is supported by `getAuthorFeed` - this
287287+ returns only posts containing images or video, making gallery mode fully
288288+ dense for profile contexts.
289289+290290+## Accessibility
291291+292292+- Gallery pages announce post author + media type via `Semantics`
293293+- Alt text is exposed to screen readers for every image/video
294294+- Chrome overlay elements are focusable and labeled
295295+- Physical keyboard: arrow keys navigate pages, Escape dismisses
+14-14
docs/specs/testing.md
···1515| ----------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
1616| `_initials(String)` | Generates initials from display name | 10 files (post_card, grid_post_card, post_embed_view, notification items, suggested_follows, labeler_detail, moderation_settings, search, hashtag) |
1717| `_formatCount(int)` | Formats numbers with K/M suffixes | 5 files (post_action_bar, post_card_footer, starter_pack_card, starter_pack_detail, profile_screen) |
1818-| `_formatTime(DateTime)` | Relative time strings ("2h ago") | 4 files (notification items, search, hashtag) — overlaps with `formatPostTime()` in post_card_footer |
1818+| `_formatTime(DateTime)` | Relative time strings ("2h ago") | 4 files (notification items, search, hashtag) - overlaps with `formatPostTime()` in post_card_footer |
19192020**Target**: Extract to `lib/shared/utils/format_utils.dart`.
21212222### 2. Duplicated Widgets
23232424-**Avatar display** — 14 files repeat `CircleAvatar` / `ModeratedAvatar` with
2424+**Avatar display** - 14 files repeat `CircleAvatar` / `ModeratedAvatar` with
2525identical styling. Extract to a configurable `ProfileAvatar` widget.
26262727-**Author name + handle** — Repeated two-line text widget (displayName / @handle)
2727+**Author name + handle** - Repeated two-line text widget (displayName / @handle)
2828in post_card, grid_post_card, post_embed_view, convo_list_item. Extract to
2929`ActorNameWidget`.
30303131-**Greyscale color filter** — Identical 4x5 color matrix defined in
3131+**Greyscale color filter** - Identical 4x5 color matrix defined in
3232grid_post_card and profile_screen. Extract to `lib/core/theme/color_filters.dart`.
33333434-**Notification reason icon** — Large switch statement mapping notification
3434+**Notification reason icon** - Large switch statement mapping notification
3535reasons to icons/colors duplicated identically in notification_list_item and
3636grouped_notification_list_item. Extract to `NotificationIconMapper`.
37373838### 3. Bottom Sheets & Dialogs
39394040-**Modal bottom sheets** — 9 files repeat `showModalBottomSheet` with ListTile
4040+**Modal bottom sheets** - 9 files repeat `showModalBottomSheet` with ListTile
4141option lists. Create `OptionsSheet` builder.
42424343-**Confirmation dialogs** — 26 occurrences of AlertDialog with
4343+**Confirmation dialogs** - 26 occurrences of AlertDialog with
4444title/content/cancel/confirm. Create `ConfirmationDialog(title, content,
4545confirmLabel, onConfirm)`.
46464747### 4. State Handling Patterns
48484949-**Loading** — `Center(child: CircularProgressIndicator())` in 8+ screens.
5050-**Error with retry** — Center + error message + retry button in 8+ screens.
5151-**Empty state** — Center + message + optional action in multiple screens.
4949+**Loading** - `Center(child: CircularProgressIndicator())` in 8+ screens.
5050+**Error with retry** - Center + error message + retry button in 8+ screens.
5151+**Empty state** - Center + message + optional action in multiple screens.
52525353Create `LoadingState`, `ErrorState(message, onRetry)`, `EmptyState(message,
5454icon, action)` widgets in `lib/shared/presentation/widgets/`.
···120120Reasons:
121121122122- Requires building/maintaining a separate Widgetbook app with use-case
123123- definitions for every widget — high overhead for a small team
123123+ definitions for every widget - high overhead for a small team
124124- The project's gap is golden tests and integration tests, not a design catalog
125125- No dedicated designer reviewing components, so collaborative review value is
126126 unrealized
···131131132132### Recommended Approach
133133134134-**Golden Toolkit** (`golden_toolkit` package) — add visual regression to
134134+**Golden Toolkit** (`golden_toolkit` package) - add visual regression to
135135existing widget tests with minimal overhead:
136136137137- One or two lines per existing test to capture golden snapshots
···139139- Works with existing `pumpWidget` patterns
140140- `multiScreenGolden` for multi-device-size snapshots
141141142142-**Patrol** — consider later if native feature testing (permissions, deep links)
142142+**Patrol** - consider later if native feature testing (permissions, deep links)
143143becomes important.
144144145145-**Built-in integration tests** — add end-to-end flow tests using
145145+**Built-in integration tests** - add end-to-end flow tests using
146146`integration_test` package for critical paths (compose, auth, navigation).
147147148148### Platform Rendering Note
+259
docs/specs/typeahead.md
···11+---
22+title: Configurable Typeahead
33+updated: 2026-04-25
44+---
55+66+## Summary
77+88+Replace the current single-source typeahead with a configurable system that
99+supports multiple backends. Two integration points:
1010+1111+1. **Login** - server/handle resolution during OAuth sign-in
1212+2. **Search** - actor autocomplete in search, jump-to-profile, list member
1313+ add, and starter pack member add
1414+1515+## Typeahead backends
1616+1717+### 1. Bluesky Official (default)
1818+1919+Existing endpoint already used by `SearchRepository.searchActorsTypeahead`.
2020+2121+- **Endpoint:** `app.bsky.actor.searchActorsTypeahead`
2222+- **SDK:** `_bluesky.actor.searchActorsTypeahead(q:, limit:)`
2323+- **Auth:** Required (session token)
2424+- **Response:** `List<ProfileViewBasic>` - `did`, `handle`, `displayName`,
2525+ `avatar`, `labels`
2626+- **Rate limit:** Standard Bluesky XRPC limits
2727+2828+### 2. waow.tech Community Typeahead
2929+3030+Community-run drop-in replacement. Useful for unauthenticated contexts (login)
3131+and as a faster/broader index.
3232+3333+- **Endpoint:** `GET https://typeahead.waow.tech/xrpc/app.bsky.actor.searchActorsTypeahead`
3434+- **Params:** `q` (required), `limit` (optional, 1–100, default 10)
3535+- **Headers:** `X-Client: lazurite` (identifies app for traffic stats)
3636+- **Auth:** None required
3737+- **Response:** Same `actors` array shape as Bluesky, minus `viewer` field
3838+- **Rate limit:** 60 req/min per IP, 60s result cache
3939+- **Discovery:** Auto-indexes via Jetstream monitoring; on-demand backfill for
4040+ unknown accounts
4141+- **Moderation:** Respects Bluesky `!hide`/`!takedown`/`!suspend`/`spam` labels;
4242+ filters slur handles
4343+4444+### Response normalisation
4545+4646+Both backends return actors in a compatible shape. The waow.tech response omits
4747+`viewer` (requires auth). Normalise into a common model:
4848+4949+```dart
5050+class TypeaheadResult {
5151+ final String did;
5252+ final String handle;
5353+ final String? displayName;
5454+ final String? avatarUrl;
5555+ final List<Label> labels;
5656+}
5757+```
5858+5959+Parse from `ProfileViewBasic` (Bluesky) or raw JSON (waow.tech) via a factory.
6060+6161+## Configuration
6262+6363+### Settings model
6464+6565+Add a `typeaheadProvider` field to the settings table:
6666+6767+| Key | Type | Values | Default |
6868+| -------------------- | ------ | ---------------------- | --------- |
6969+| `typeahead_provider` | string | `bluesky`, `community` | `bluesky` |
7070+7171+Exposed via `SettingsCubit` as `state.typeaheadProvider`.
7272+7373+### Settings UI
7474+7575+Add a "Typeahead Provider" option in Settings under a "Search" section:
7676+7777+- **Bluesky** - official endpoint, requires login
7878+- **Community (waow.tech)** - faster, works pre-login, community-run
7979+8080+Show a brief description for each option. The community option notes it's a
8181+third-party service.
8282+8383+## TypeaheadRepository
8484+8585+Central abstraction that delegates to the configured backend:
8686+8787+```dart
8888+class TypeaheadRepository {
8989+ TypeaheadRepository({
9090+ required Bluesky? bluesky,
9191+ required String provider,
9292+ ModerationService? moderationService,
9393+ });
9494+9595+ Future<List<TypeaheadResult>> search({
9696+ required String query,
9797+ int limit = 10,
9898+ });
9999+}
100100+```
101101+102102+When `provider == 'bluesky'`:
103103+104104+- Delegates to `bluesky.actor.searchActorsTypeahead`
105105+- Passes moderation headers
106106+- Filters via `ModerationService`
107107+108108+When `provider == 'community'`:
109109+110110+- HTTP GET to `https://typeahead.waow.tech/xrpc/app.bsky.actor.searchActorsTypeahead`
111111+- Uses the existing `http` package (already a dependency)
112112+- Adds `X-Client: lazurite` header
113113+- Parses `actors` array from JSON response
114114+- Applies local moderation filtering (labels are included in response)
115115+116116+### Fallback
117117+118118+If the community endpoint fails (timeout, rate limit, 5xx), fall back to the
119119+Bluesky endpoint when a session is available. Log the fallback via `AppLogger`.
120120+121121+## Integration: Login Screen
122122+123123+### Current state
124124+125125+The login screen has a `TextFormField` for handle/DID entry with no
126126+autocomplete. Users must type their full handle.
127127+128128+### New behaviour
129129+130130+Add typeahead suggestions below the handle field as the user types:
131131+132132+1. User types ≥ 2 characters
133133+2. Debounce 300ms
134134+3. Call `TypeaheadRepository.search` - **always uses community backend** on the
135135+ login screen since no session exists yet (override the setting for this context)
136136+4. Show results in a `ListView` overlay anchored below the text field
137137+5. Each result shows: avatar, display name, handle
138138+6. Tapping a result fills the handle field and triggers the OAuth flow
139139+140140+### Widget: `TypeaheadTextField`
141141+142142+Reusable widget combining `TextFormField` + overlay suggestions:
143143+144144+```dart
145145+class TypeaheadTextField extends StatefulWidget {
146146+ final TextEditingController controller;
147147+ final TypeaheadRepository repository;
148148+ final ValueChanged<TypeaheadResult> onSelected;
149149+ final InputDecoration? decoration;
150150+ final int debounceMs;
151151+ final int minChars;
152152+ final int limit;
153153+}
154154+```
155155+156156+Uses `OverlayEntry` positioned via `LayerLink` + `CompositedTransformFollower`
157157+for correct placement. The overlay follows the text field and adapts to keyboard
158158+presence.
159159+160160+### Login-specific flow
161161+162162+On the login screen, the `TypeaheadRepository` is created without a `Bluesky`
163163+instance and forces `provider: 'community'`. This allows handle discovery
164164+before authentication.
165165+166166+## Integration: Search Screen
167167+168168+### Current state
169169+170170+`SearchBloc` has `TypeaheadRequested` / `TypeaheadResultsLoaded` events that
171171+call `SearchRepository.searchActorsTypeahead` on debounced text input. Results
172172+are stored in `state.typeaheadActors` and shown in the search screen.
173173+174174+### Changes
175175+176176+Replace the direct `SearchRepository.searchActorsTypeahead` call in
177177+`SearchBloc._onTypeaheadRequested` with `TypeaheadRepository.search`. The bloc
178178+receives a `TypeaheadRepository` instead of calling `SearchRepository` for
179179+typeahead.
180180+181181+The typeahead provider setting determines the backend. Users who prefer the
182182+community index get it everywhere (search, jump-to-profile, list member add,
183183+starter pack member add).
184184+185185+### Existing typeahead consumers
186186+187187+All these currently call `SearchRepository.searchActorsTypeahead` and should
188188+migrate to `TypeaheadRepository`:
189189+190190+| Location | Context |
191191+| ---------------------------------- | ---------------------------- |
192192+| `SearchBloc._onTypeaheadRequested` | Search screen autocomplete |
193193+| Jump-to-profile dialog | Search screen FAB |
194194+| List member add screen | `searchActorsTypeahead` call |
195195+| Starter pack member search | Create/edit starter pack |
196196+197197+## TypeaheadCubit
198198+199199+Shared cubit for typeahead state, usable by any screen:
200200+201201+```dart
202202+class TypeaheadCubit extends Cubit<TypeaheadState> {
203203+ TypeaheadCubit({required TypeaheadRepository repository});
204204+205205+ void onQueryChanged(String query); // debounced
206206+ void clear();
207207+}
208208+209209+class TypeaheadState {
210210+ final List<TypeaheadResult> results;
211211+ final bool isLoading;
212212+ final String? error;
213213+}
214214+```
215215+216216+This replaces the typeahead-related events in `SearchBloc`, keeping search
217217+concerns separate from typeahead concerns.
218218+219219+## Debouncing & rate limiting
220220+221221+- 300ms debounce on text changes (existing pattern in `SearchBloc`)
222222+- Cancel in-flight requests when a new query arrives
223223+- Community backend: 60 req/min limit - debounce alone keeps usage well under
224224+ this (user would need to change input 60 times in a minute past debounce)
225225+- Empty/whitespace queries return empty results immediately (no API call)
226226+227227+## Moderation
228228+229229+Bluesky backend: moderation headers are included; server-side filtering applies.
230230+Results also pass through local `ModerationService.shouldFilterProfileBasicInList`.
231231+232232+Community backend: response includes labels. Apply local moderation filtering
233233+using the same `ModerationService` logic. The community service already hides
234234+`!hide`/`!takedown`/`!suspend` server-side, but local filtering catches
235235+user-specific label preferences.
236236+237237+## Bloc architecture
238238+239239+```text
240240+SettingsCubit.typeaheadProvider
241241+ │
242242+ ▼
243243+TypeaheadRepository ──► Bluesky SDK (authenticated)
244244+ │ HTTP client (community, unauthenticated)
245245+ ▼
246246+TypeaheadCubit ──► TypeaheadState { results, isLoading, error }
247247+ │
248248+ ▼
249249+TypeaheadTextField (Login, Search, Lists, Starter Packs)
250250+```
251251+252252+## Limitations
253253+254254+- Community backend has no `viewer` field - cannot show follow status in
255255+ typeahead results (degrade gracefully: hide follow badge)
256256+- Community backend caches for 60s - very recent handle changes may lag
257257+- Login typeahead cannot fall back to Bluesky (no session)
258258+- DID entry (not handles) bypasses typeahead entirely - typeahead is handle/name
259259+ search only
+105
docs/tasks/gallery.md
···11+# Gallery Viewing Milestones
22+33+## M0 - Data Layer & Models
44+55+- [ ] Create `lib/features/gallery/data/gallery_media_item.dart` - `GalleryMediaItem` model with `feedViewPost`, `type` enum (`image`/`video`), `images` list, `video` args
66+- [ ] Create `lib/features/gallery/data/gallery_utils.dart` - `extractGalleryItems(List<FeedViewPost>)` utility
77+ - Walk `embed.isEmbedImagesView`, `embed.isEmbedVideoView`, `embed.isEmbedRecordWithMediaView`
88+ - Skip external-only and text-only posts
99+ - Apply moderation filtering (`contentMedia` blur/filter)
1010+- [ ] Create `lib/features/gallery/data/gallery_route_args.dart` - `GalleryRouteArgs` with items, initial index, initial direction
1111+- [ ] Unit tests for `extractGalleryItems` - image-only, video-only, mixed, text-only, moderated, record-with-media edge cases
1212+1313+## M1 - GalleryCubit
1414+1515+- [ ] Create `lib/features/gallery/cubit/gallery_cubit.dart`
1616+- [ ] Create `lib/features/gallery/cubit/gallery_state.dart`
1717+ - State: `items`, `currentIndex`, `chromeVisible`, `scrollDirection`
1818+ - Methods: `onPageChanged`, `toggleChrome`, `toggleDirection`, `appendItems`
1919+- [ ] Unit tests for cubit - page transitions, chrome toggle, direction toggle, item append
2020+2121+## M2 - Gallery Screen (Images)
2222+2323+- [ ] Create `lib/features/gallery/presentation/gallery_screen.dart`
2424+ - Full-screen `PageView` with `scrollDirection` from cubit
2525+ - Image pages: `PhotoViewGallery` (reuse `photo_view`) for single/multi-image posts
2626+ - Nested horizontal `PageView` + dot indicator for multi-image posts within vertical scroll
2727+ - Swipe-to-dismiss gesture detection
2828+ - Background opacity fade on drag
2929+- [ ] Chrome overlay - auto-hiding top/bottom bars with 3s timer
3030+ - Top: close, author avatar+handle, page counter
3131+ - Bottom: post text snippet, action buttons, download/share, alt text badge
3232+- [ ] Preloading: `precacheImage` for ±1 adjacent image pages
3333+- [ ] Register `/gallery` route in `app_router.dart`
3434+- [ ] Widget tests for gallery screen - page swipe, chrome toggle, dismiss gesture
3535+3636+## M3 - Gallery Screen (Videos)
3737+3838+- [ ] Video page renderer within `GalleryScreen`
3939+ - `VideoPlayerController` + `Chewie` for video items
4040+ - Auto-play on page visible, auto-pause on swipe away
4141+ - GIF handling: loop + mute + no controls
4242+ - Full-bleed with pillarbox for non-9:16 aspect ratios
4343+- [ ] Video lifecycle management
4444+ - Pause leaving viewport, play entering viewport
4545+ - Dispose controllers >2 pages away, pre-init +1 page
4646+- [ ] Short-form interactions
4747+ - Tap to pause/resume
4848+ - Double-tap right → like animation + action
4949+ - Double-tap left → rewind 5s
5050+ - Long-press → speed selector (1x, 1.5x, 2x)
5151+ - Thin progress bar at bottom
5252+- [ ] Widget tests for video pages - play/pause, lifecycle, gesture handling
5353+5454+## M4 - Feed Entry Points
5555+5656+- [ ] Add gallery icon button to `LazuriteAppBar` actions in `HomeFeedScreen`
5757+ - Visible when `extractGalleryItems` returns ≥1 item from loaded posts
5858+ - Tap opens `/gallery` with extracted items
5959+- [ ] Add gallery icon to `ProfileScreen` feed tab section
6060+- [ ] Add gallery icon to `SearchScreen` app bar (when post results have media)
6161+- [ ] Add gallery icon to `HashtagScreen` app bar
6262+- [ ] Add gallery icon to `ListDetailScreen` feed tab
6363+- [ ] Add gallery icon to `SavedPostsScreen` app bar
6464+- [ ] Post thread: tapping media opens gallery seeded with thread's media items
6565+- [ ] Widget tests for entry point visibility - shown/hidden based on media presence
6666+6767+## M5 - Infinite Scroll & Pagination
6868+6969+- [ ] Add `onLoadMore` callback and post stream/listener to `GalleryCubit`
7070+ - Decoupled from specific feed source - works with `FeedBloc`, `HashtagCubit`, `ListFeedBloc`, `SearchBloc`
7171+ - When user reaches last 3 pages, invoke callback to trigger parent feed load-more
7272+- [ ] `GalleryCubit.appendItems` - filter new posts through `extractGalleryItems`, merge into item list
7373+- [ ] Loading shimmer on final page while fetch is in progress
7474+- [ ] End-of-feed indicator when cursor is exhausted
7575+- [ ] For profile feeds, use `FeedFilter.postsWithMedia` to maximise gallery density
7676+- [ ] Higher `limit` (100) on feed fetches when gallery mode is active
7777+- [ ] Integration tests: gallery pagination triggers feed load-more, cursor exhaustion handled
7878+7979+## M6 - Notification Media & Offline Gallery
8080+8181+- [ ] Extract media from notification subjects
8282+ - Walk `subjectPost` / `reasonSubject` from notification views through `extractGalleryItems`
8383+ - Add gallery entry point in `NotificationsScreen` when media-bearing notifications exist
8484+- [ ] Offline gallery support
8585+ - Parse `CachedFeedPages.payload` JSON into `FeedViewPost` objects for `extractGalleryItems`
8686+ - Parse `SavedPosts.postJson` and `LikedPosts.postJson` for gallery items
8787+ - Gallery entry point visible when `ConnectivityCubit.isOffline` and cached data has media
8888+ - Disable like/repost/reply buttons (disabled state + "Offline" tooltip)
8989+ - Disable download/share for uncached media
9090+ - Show "Offline - showing cached posts" indicator at end of gallery
9191+ - Pagination unavailable offline - only show cached items
9292+- [ ] Unit tests: offline gallery from cached data, disabled actions, end indicator
9393+- [ ] Widget tests: notification gallery entry point, offline gallery rendering
9494+9595+## M7 - Media Density & Polish
9696+9797+- [ ] Media density indicator in chrome overlay (e.g. "12 media from 47 loaded")
9898+- [ ] `Semantics` labels on gallery pages (author + media type)
9999+- [ ] Alt text exposed to screen readers for images and videos
100100+- [ ] Chrome overlay elements focusable and labeled
101101+- [ ] Keyboard navigation: arrow keys for pages, Escape to dismiss
102102+- [ ] Haptic feedback on like (double-tap)
103103+- [ ] Smooth page transition animations
104104+- [ ] `flutter analyze` clean
105105+- [ ] Full test suite passes