diff --git a/docs/specs/gallery.md b/docs/specs/gallery.md new file mode 100644 index 0000000..9d0f8cf --- /dev/null +++ b/docs/specs/gallery.md @@ -0,0 +1,295 @@ +--- +title: Gallery Viewing Spec +updated: 2026-04-25 +--- + +## Summary + +Add two immersive viewing modes for feeds containing media: + +1. **Slideshow** - full-screen image carousel for image-heavy feeds +2. **Short-form vertical** - TikTok-style vertical swipe for video or mixed feeds + +These modes activate from any feed context (timeline, profile, search, list feed, +hashtag, saved posts) when the feed's content qualifies. + +## Content Classification + +Before entering gallery mode, classify the filtered feed to determine which viewer +to offer. Only posts with image or video embeds participate. + +| Embed type | Classification | +| ----------------------------------- | -------------- | +| `embedImagesView` | image | +| `embedVideoView` | video | +| `embedRecordWithMediaView` (images) | image | +| `embedRecordWithMediaView` (video) | video | +| `embedExternalView`, text-only | excluded | + +A `GalleryMediaItem` model normalises these into a uniform structure: + +```dart +class GalleryMediaItem { + final FeedViewPost feedViewPost; + final GalleryMediaType type; // image, video + final List images; // populated for image type + final VideoPlayerRouteArgs? video; // populated for video type +} +``` + +### Feed filtering + +Extract media items from a `List` via a utility: + +```dart +List extractGalleryItems(List posts); +``` + +This walks each post's embed (and `recordWithMedia.media`), skipping posts without +qualifying embeds. Moderation filtering applies - `contentMedia` blur/filter +decisions are respected. + +## Slideshow Mode (Images) + +### Entry point + +A "Gallery" icon button in the feed app bar (or FAB) that appears when +`extractGalleryItems` returns at least one item. Tapping it opens +`GalleryScreen` with the extracted items, starting at the first item (or the +item nearest the current scroll position). + +### Screen: `GalleryScreen` + +Full-screen `PageView` with `PageController`. Each page renders based on +`GalleryMediaType`: + +**Image pages:** `PhotoViewGallery` (reuse existing `photo_view` dependency) +showing all images from the post. Multi-image posts use a nested horizontal +`PageView` within the vertical page, with a dot indicator. Pinch-to-zoom and +pan via `PhotoView`. Hero animation from the feed thumbnail when entering from +a specific post. + +**Video pages:** Inline `VideoPlayerController` + `Chewie` (reuse existing +dependencies). Auto-play when the page is visible, pause when swiped away. +GIF-style videos (`presentation: "gif"`) loop muted with no controls. + +### Navigation + +- **Vertical swipe** (default): swipe up/down to move between posts - TikTok + style. Uses `PageView` with `scrollDirection: Axis.vertical`. +- **Horizontal swipe** (slideshow): swipe left/right. Configurable via a toggle + in the gallery toolbar. +- Swipe-to-dismiss: vertical drag past threshold when in horizontal mode (or + horizontal drag in vertical mode) pops the screen. + +### Chrome overlay + +Semi-transparent top and bottom bars, auto-hiding after 3 seconds of +inactivity. Tap anywhere to toggle. + +**Top bar:** + +- Close button (X) +- Post author avatar + handle (tap → navigate to profile) +- Page counter (e.g. "3 / 12") + +**Bottom bar:** + +- Post text snippet (first 2 lines, tap to expand) +- Like / repost / reply action row (reuse `PostActionBar` pattern via + `PostActionCubit`) +- Download / share buttons (reuse `MediaActions`) +- Alt text badge when present (tap to show full alt text in a sheet) + +### State management + +`GalleryCubit` manages: + +```dart +class GalleryState { + final List items; + final int currentIndex; + final bool chromeVisible; + final Axis scrollDirection; +} +``` + +Events: `PageChanged`, `ChromeToggled`, `DirectionToggled`. + +The cubit receives the pre-filtered list of `GalleryMediaItem` - no additional +API calls. Pagination piggybacks on the parent feed's `FeedBloc` cursor: when +the user reaches the last few pages, the cubit signals the parent to load more, +and new items are appended. + +### Preloading + +Preload adjacent pages to reduce perceived latency: + +- Images: `precacheImage` for ±1 pages +- Videos: initialise `VideoPlayerController` for +1 page, dispose -2 pages + +## Short-Form Vertical Video Mode + +For feeds that are primarily video, the gallery defaults to vertical scroll +direction with video-first UX: + +- Full-bleed video fills the screen (respect `aspectRatio` from embed, pillarbox + for non-9:16 content) +- Auto-play on visibility, auto-pause on swipe-away +- Tap to pause/resume (no explicit controls unless user taps) +- Double-tap right side → like, double-tap left side → rewind 5s +- Long-press → playback speed options (1x, 1.5x, 2x) +- Progress bar at bottom (thin, YouTube Shorts style) + +### Video lifecycle + +Use `VisibilityDetector` pattern (or `PageView.onPageChanged`) to: + +1. Pause video leaving viewport +2. Play video entering viewport +3. Dispose controllers for pages > 2 positions away +4. Pre-initialise controller for the next page + +## Mixed feeds + +When a feed has both images and videos, gallery mode interleaves them in feed +order. Each page adapts its renderer based on `GalleryMediaType`. The bottom +bar shows a media type icon (camera/video) so users know what's coming next. + +## Entry points + +| Context | Trigger | +| ---------------------- | ------------------------------------------------- | +| Home feed tabs | Gallery icon in `LazuriteAppBar` actions | +| Profile feed tab | Gallery icon in profile feed section | +| Search results (Posts) | Gallery icon in search app bar when results shown | +| Hashtag screen | Gallery icon in hashtag app bar | +| List feed | Gallery icon in list detail feed tab | +| Saved posts | Gallery icon in saved posts app bar | +| Post thread | Tap post media opens gallery seeded at that post | + +For thread context, tapping a post's media now opens gallery mode seeded with +just that thread's media items, starting at the tapped item. + +## Routing + +```dart +GoRoute( + path: '/gallery', + parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) { + final args = state.extra as GalleryRouteArgs; + return GalleryScreen(args: args); + }, +), +``` + +`GalleryRouteArgs`: + +```dart +class GalleryRouteArgs { + final List items; + final int initialIndex; + final Axis initialDirection; +} +``` + +## Packages + +No new dependencies. Reuses: + +- `photo_view` - image zoom/pan +- `video_player` + `chewie` - video playback +- `dio` + `gal` - download +- `share_plus` - sharing +- `permission_handler` - gallery save permissions + +## Moderation + +All gallery items pass through the existing `ModerationService` pipeline. +`contentMedia` blur overlays render atop the gallery page. `noOverride` blurs +cannot be dismissed. Posts filtered at `contentList` level are excluded from the +gallery item list entirely. + +## Infinite Scroll + +Gallery mode supports continuous scrolling beyond the initially loaded posts. +When the user reaches the last 3 pages, `GalleryCubit` signals the parent +feed's bloc (or cubit) to load the next page via its existing cursor-based +pagination. New posts are filtered through `extractGalleryItems` and appended +to the gallery's item list. A loading shimmer renders on the final page while +the fetch is in progress. When the cursor is exhausted, the gallery shows an +"end of feed" indicator on the last page. + +The gallery receives a callback (`onLoadMore`) and a stream/listener for new +posts from the parent. This keeps the gallery decoupled from specific feed +sources - it works identically whether backed by `FeedBloc`, `HashtagCubit`, +`ListFeedBloc`, or `SearchBloc`. + +## DM & Notification Media + +### Notifications + +Notifications that reference posts with media (likes, reposts, quotes, replies +on media posts) are gallery-eligible. `NotificationBloc` already hydrates +referenced posts. `extractGalleryItems` can process the `subjectPost` or +`reasonSubject` from notification views to extract media. + +Add a gallery entry point in the notifications screen when loaded notifications +contain media-bearing posts. The gallery is seeded with media items extracted +from notification subjects. + +### DMs + +The `chat.bsky.convo` lexicon currently supports text-only messages - no image +or video embeds. Gallery mode for DMs is not applicable until the AT Protocol +adds media message support. When/if `chat.bsky.convo.defs#messageView` gains +an `embed` field, the same `extractGalleryItems` pattern applies by adapting the +extractor to accept message views alongside feed views. + +## Offline Gallery + +Gallery mode works offline using cached feed data from the Drift database. + +**Sources:** + +| Cache source | Table | Contains embeds? | +|--------------------|--------------------|------------------| +| Feed first pages | `CachedFeedPages` | Yes (full JSON) | +| Saved posts | `SavedPosts` | Yes (`postJson`) | +| Liked posts | `LikedPosts` | Yes (`postJson`) | + +When the `ConnectivityCubit` reports offline, the gallery entry point still +appears if cached data contains media items. `extractGalleryItems` processes +deserialised `FeedViewPost` objects from cached JSON the same way it handles +live data. + +Offline gallery disables actions requiring network (like, repost, reply) - these +buttons show a disabled state with a tooltip ("Offline"). Download/share actions +are disabled for images/videos not already in the device cache. + +Pagination is unavailable offline - the gallery shows only what's cached, with +an "Offline - showing cached posts" indicator when the user reaches the end. + +## Media-Only Feed Filtering + +No server-side "media-only" feed filter exists in the AT Protocol. Gallery mode +filters client-side, which means the ratio of media-to-text posts in the +underlying feed affects gallery density. To mitigate: + +- **Aggressive prefetch:** When gallery mode is active, the parent feed fetches + with a higher `limit` (100 vs the default 50) to increase the pool of + candidate posts. +- **Skip ratio indicator:** The chrome overlay shows the media density + (e.g. "12 media posts from 47 loaded") so users understand the scope. +- **Feed generator hint:** For profile feeds, use `filter: postsWithMedia` + (`FeedFilter.postsWithMedia`) which is supported by `getAuthorFeed` - this + returns only posts containing images or video, making gallery mode fully + dense for profile contexts. + +## Accessibility + +- Gallery pages announce post author + media type via `Semantics` +- Alt text is exposed to screen readers for every image/video +- Chrome overlay elements are focusable and labeled +- Physical keyboard: arrow keys navigate pages, Escape dismisses diff --git a/docs/specs/testing.md b/docs/specs/testing.md index 28685af..aae4e9a 100644 --- a/docs/specs/testing.md +++ b/docs/specs/testing.md @@ -15,40 +15,40 @@ duplication across 40+ files. The findings group into ten categories. | ----------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `_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) | | `_formatCount(int)` | Formats numbers with K/M suffixes | 5 files (post_action_bar, post_card_footer, starter_pack_card, starter_pack_detail, profile_screen) | -| `_formatTime(DateTime)` | Relative time strings ("2h ago") | 4 files (notification items, search, hashtag) — overlaps with `formatPostTime()` in post_card_footer | +| `_formatTime(DateTime)` | Relative time strings ("2h ago") | 4 files (notification items, search, hashtag) - overlaps with `formatPostTime()` in post_card_footer | **Target**: Extract to `lib/shared/utils/format_utils.dart`. ### 2. Duplicated Widgets -**Avatar display** — 14 files repeat `CircleAvatar` / `ModeratedAvatar` with +**Avatar display** - 14 files repeat `CircleAvatar` / `ModeratedAvatar` with identical styling. Extract to a configurable `ProfileAvatar` widget. -**Author name + handle** — Repeated two-line text widget (displayName / @handle) +**Author name + handle** - Repeated two-line text widget (displayName / @handle) in post_card, grid_post_card, post_embed_view, convo_list_item. Extract to `ActorNameWidget`. -**Greyscale color filter** — Identical 4x5 color matrix defined in +**Greyscale color filter** - Identical 4x5 color matrix defined in grid_post_card and profile_screen. Extract to `lib/core/theme/color_filters.dart`. -**Notification reason icon** — Large switch statement mapping notification +**Notification reason icon** - Large switch statement mapping notification reasons to icons/colors duplicated identically in notification_list_item and grouped_notification_list_item. Extract to `NotificationIconMapper`. ### 3. Bottom Sheets & Dialogs -**Modal bottom sheets** — 9 files repeat `showModalBottomSheet` with ListTile +**Modal bottom sheets** - 9 files repeat `showModalBottomSheet` with ListTile option lists. Create `OptionsSheet` builder. -**Confirmation dialogs** — 26 occurrences of AlertDialog with +**Confirmation dialogs** - 26 occurrences of AlertDialog with title/content/cancel/confirm. Create `ConfirmationDialog(title, content, confirmLabel, onConfirm)`. ### 4. State Handling Patterns -**Loading** — `Center(child: CircularProgressIndicator())` in 8+ screens. -**Error with retry** — Center + error message + retry button in 8+ screens. -**Empty state** — Center + message + optional action in multiple screens. +**Loading** - `Center(child: CircularProgressIndicator())` in 8+ screens. +**Error with retry** - Center + error message + retry button in 8+ screens. +**Empty state** - Center + message + optional action in multiple screens. Create `LoadingState`, `ErrorState(message, onRetry)`, `EmptyState(message, icon, action)` widgets in `lib/shared/presentation/widgets/`. @@ -120,7 +120,7 @@ v4.0.0-beta.3). Reasons: - Requires building/maintaining a separate Widgetbook app with use-case - definitions for every widget — high overhead for a small team + definitions for every widget - high overhead for a small team - The project's gap is golden tests and integration tests, not a design catalog - No dedicated designer reviewing components, so collaborative review value is unrealized @@ -131,7 +131,7 @@ PR visual review becomes a bottleneck. ### Recommended Approach -**Golden Toolkit** (`golden_toolkit` package) — add visual regression to +**Golden Toolkit** (`golden_toolkit` package) - add visual regression to existing widget tests with minimal overhead: - One or two lines per existing test to capture golden snapshots @@ -139,10 +139,10 @@ existing widget tests with minimal overhead: - Works with existing `pumpWidget` patterns - `multiScreenGolden` for multi-device-size snapshots -**Patrol** — consider later if native feature testing (permissions, deep links) +**Patrol** - consider later if native feature testing (permissions, deep links) becomes important. -**Built-in integration tests** — add end-to-end flow tests using +**Built-in integration tests** - add end-to-end flow tests using `integration_test` package for critical paths (compose, auth, navigation). ### Platform Rendering Note diff --git a/docs/specs/typeahead.md b/docs/specs/typeahead.md new file mode 100644 index 0000000..1ff50f5 --- /dev/null +++ b/docs/specs/typeahead.md @@ -0,0 +1,259 @@ +--- +title: Configurable Typeahead +updated: 2026-04-25 +--- + +## Summary + +Replace the current single-source typeahead with a configurable system that +supports multiple backends. Two integration points: + +1. **Login** - server/handle resolution during OAuth sign-in +2. **Search** - actor autocomplete in search, jump-to-profile, list member + add, and starter pack member add + +## Typeahead backends + +### 1. Bluesky Official (default) + +Existing endpoint already used by `SearchRepository.searchActorsTypeahead`. + +- **Endpoint:** `app.bsky.actor.searchActorsTypeahead` +- **SDK:** `_bluesky.actor.searchActorsTypeahead(q:, limit:)` +- **Auth:** Required (session token) +- **Response:** `List` - `did`, `handle`, `displayName`, + `avatar`, `labels` +- **Rate limit:** Standard Bluesky XRPC limits + +### 2. waow.tech Community Typeahead + +Community-run drop-in replacement. Useful for unauthenticated contexts (login) +and as a faster/broader index. + +- **Endpoint:** `GET https://typeahead.waow.tech/xrpc/app.bsky.actor.searchActorsTypeahead` +- **Params:** `q` (required), `limit` (optional, 1–100, default 10) +- **Headers:** `X-Client: lazurite` (identifies app for traffic stats) +- **Auth:** None required +- **Response:** Same `actors` array shape as Bluesky, minus `viewer` field +- **Rate limit:** 60 req/min per IP, 60s result cache +- **Discovery:** Auto-indexes via Jetstream monitoring; on-demand backfill for + unknown accounts +- **Moderation:** Respects Bluesky `!hide`/`!takedown`/`!suspend`/`spam` labels; + filters slur handles + +### Response normalisation + +Both backends return actors in a compatible shape. The waow.tech response omits +`viewer` (requires auth). Normalise into a common model: + +```dart +class TypeaheadResult { + final String did; + final String handle; + final String? displayName; + final String? avatarUrl; + final List