···11+# Phase 3
22+33+## Post Composition
44+55+Posts are created via `com.atproto.repo.createRecord` with collection
66+`app.bsky.feed.post`. The compose screen is a full-screen modal opened from a
77+floating action button on the home screen.
88+99+### Post Record Structure
1010+1111+| Field | Type | Description |
1212+| ----------- | -------- | --------------------------------------------- |
1313+| `text` | string | Post body, max 300 graphemes |
1414+| `facets` | array | Rich text annotations (mentions, links, tags) |
1515+| `embed` | union | Attached media, link card, or quote post |
1616+| `reply` | object | `parent` + `root` refs for threaded replies |
1717+| `langs` | array | BCP-47 language tags |
1818+| `createdAt` | datetime | ISO 8601 timestamp |
1919+2020+### Media Uploads
2121+2222+Upload images via `com.atproto.repo.uploadBlob`. Returns a `blob` ref used in
2323+the embed object. No GIF support — images only.
2424+2525+| Constraint | Value |
2626+| -------------- | ---------------------------------- |
2727+| Max images | 4 per post |
2828+| Max file size | 1 MB per image |
2929+| Accepted types | JPEG, PNG, WebP |
3030+| Alt text | Required UI field, optional in API |
3131+3232+Embed type for images: `app.bsky.embed.images`. Each image entry has `image`
3333+(blob ref), `alt` (string), and optional `aspectRatio` (`width` / `height`).
3434+3535+### Facet Detection
3636+3737+Use **bluesky_text** to detect mentions, links, and hashtags in the post text
3838+and produce the `facets` array automatically before submission. The compose
3939+screen should render a live preview of detected facets with colour-coded
4040+highlights as the user types.
4141+4242+### Grapheme Counter
4343+4444+Display a live character counter showing remaining graphemes (300 max). Use
4545+Dart's `Characters` class for accurate grapheme cluster counting. Disable the
4646+submit button when the count exceeds 300 or the text is empty.
4747+4848+### Drafts
4949+5050+Persist unsent posts locally in a Drift `drafts` table. On network failure or
5151+explicit save, always store the draft. Drafts are account-scoped.
5252+5353+| Column | Type | Notes |
5454+| ------------- | -------- | ---------------------------------------- |
5555+| `id` | integer | PK autoincrement |
5656+| `account_did` | text | FK to `accounts` |
5757+| `text` | text | Post body |
5858+| `reply_uri` | text | Nullable; parent post URI if reply |
5959+| `embed_json` | text | Nullable; serialised embed data |
6060+| `media_paths` | text | Nullable; JSON array of local file paths |
6161+| `created_at` | datetime | When the draft was created |
6262+| `updated_at` | datetime | Last modification |
6363+6464+Display a "Drafts" entry in the compose screen accessible via a toolbar icon.
6565+Tapping a draft loads it back into the composer for editing / sending.
6666+6767+### Scheduled Posts
6868+6969+Schedule posts for future publication using a local scheduler. Store the
7070+scheduled time alongside the draft. Use a `WorkManager` (Android) /
7171+`BGTaskScheduler` (iOS) background task to submit the post at the scheduled
7272+time. If the device is offline at the scheduled time, queue the post and retry
7373+when connectivity resumes.
7474+7575+Add a `scheduled_at` (nullable datetime) column to the `drafts` table. When
7676+non-null, the draft is treated as scheduled rather than a regular draft.
7777+7878+Build a `ComposeBloc` with events: `TextChanged`, `MediaAttached`,
7979+`MediaRemoved`, `AltTextUpdated`, `DraftSaved`, `DraftLoaded`,
8080+`PostScheduled`, `PostSubmitted`.
8181+8282+## Notifications
8383+8484+Render BlueSky notifications using `app.bsky.notification.listNotifications`.
8585+No push notifications in this phase — polling only.
8686+8787+### API
8888+8989+| Endpoint | Purpose |
9090+| ----------------------------------------- | --------------------------- |
9191+| `app.bsky.notification.listNotifications` | Paginated notification list |
9292+| `app.bsky.notification.updateSeen` | Mark notifications as read |
9393+| `app.bsky.notification.getUnreadCount` | Badge count for nav bar |
9494+9595+`listNotifications` returns `notification` objects with `reason`, `author`,
9696+`record`, `isRead`, `indexedAt`. Paginate with `cursor`; `limit` 1–100.
9797+9898+### Notification Reasons
9999+100100+| Reason | Display |
101101+| --------- | ---------------------------------------------- |
102102+| `like` | "[Author] liked your post" + post preview |
103103+| `repost` | "[Author] reposted your post" + post preview |
104104+| `follow` | "[Author] followed you" |
105105+| `mention` | "[Author] mentioned you" + post preview |
106106+| `reply` | "[Author] replied to your post" + post preview |
107107+| `quote` | "[Author] quoted your post" + post preview |
108108+109109+### Rendering
110110+111111+Group notifications by day. Each notification row shows the author avatar, the
112112+reason icon, a summary line, and an optional post preview snippet. Tapping a
113113+notification navigates to the relevant post or profile.
114114+115115+Display an unread count badge on the Notifications nav bar item. Poll
116116+`getUnreadCount` on a 30-second interval when the app is foregrounded. Call
117117+`updateSeen` when the notifications screen is opened.
118118+119119+Build a `NotificationBloc` with events: `NotificationsRequested`,
120120+`NotificationsRefreshed`, `NotificationsPageLoaded`, `NotificationsMarkedRead`.
121121+122122+## Direct Messages
123123+124124+DMs use the `chat.bsky.*` lexicon namespace. The DM feature has two views: a
125125+conversation list and a message thread.
126126+127127+### API
128128+129129+| Endpoint | Purpose |
130130+| -------------------------------------- | ----------------------------- |
131131+| `chat.bsky.convo.listConvos` | Paginated conversation list |
132132+| `chat.bsky.convo.getConvo` | Single conversation metadata |
133133+| `chat.bsky.convo.getMessages` | Paginated messages in a convo |
134134+| `chat.bsky.convo.sendMessage` | Send a message |
135135+| `chat.bsky.convo.deleteMessageForSelf` | Delete a message locally |
136136+| `chat.bsky.convo.muteConvo` | Mute a conversation |
137137+| `chat.bsky.convo.unmuteConvo` | Unmute a conversation |
138138+| `chat.bsky.convo.updateRead` | Mark conversation as read |
139139+| `chat.bsky.convo.getLog` | Polling for new events |
140140+141141+### Conversation List
142142+143143+`listConvos` returns conversations sorted by last message time. Each convo
144144+includes `id`, `members` (array of `profileViewBasic`), `lastMessage`,
145145+`unreadCount`, `muted`.
146146+147147+Filter conversations into two tabs: **Primary** (accepted) and **Requests**
148148+(conversations the user has not yet responded to). A conversation is a
149149+"request" if the user has never sent a message in it.
150150+151151+### Message Thread
152152+153153+`getMessages` returns paginated `messageView` objects. Each message has `id`,
154154+`text`, `sender` (DID), `sentAt`. Messages are displayed in a standard chat
155155+bubble layout — the current user's messages right-aligned, others left-aligned.
156156+157157+Support long-press to copy individual messages. Provide a "Copy All" option in
158158+the conversation overflow menu to copy the full thread.
159159+160160+### Sending Messages
161161+162162+`sendMessage` takes `convoId` and `message` (object with `text`). To start a
163163+new conversation, the app calls `chat.bsky.convo.getConvoForMembers` with the
164164+target DID(s) — this returns an existing convo or creates a new one.
165165+166166+| Endpoint | Purpose |
167167+| ------------------------------------ | --------------------- |
168168+| `chat.bsky.convo.getConvoForMembers` | Get or create a convo |
169169+170170+Build a `ConvoListBloc` with events: `ConvosRequested`, `ConvosRefreshed`,
171171+`ConvoMuted`, `ConvoUnmuted`.
172172+173173+Build a `MessageBloc` with events: `MessagesRequested`, `MessagesPageLoaded`,
174174+`MessageSent`, `MessageDeleted`, `ConvoMarkedRead`.
175175+176176+## Account Switching
177177+178178+Support multiple authenticated accounts with full data isolation. The
179179+`accounts` table (from Phase 1) already supports multiple rows keyed by DID.
180180+181181+### Active Account
182182+183183+Store the active account DID in the Drift `settings` table under key
184184+`active_account_did`. On launch, read this value and restore the session for
185185+that account.
186186+187187+### Data Isolation
188188+189189+All user-scoped tables must include an `account_did` FK column. Queries always
190190+filter by the active account's DID. Tables requiring this constraint:
191191+192192+- `drafts`
193193+- `saved_posts`
194194+- `search_history` (Phase 2)
195195+- `cached_posts` (add `account_did` if not present)
196196+197197+### Switching Flow
198198+199199+1. User opens account switcher (bottom sheet or settings).
200200+2. Selects a different account.
201201+3. App updates `active_account_did` in settings.
202202+4. All Blocs receive a `AccountSwitched` event and reload their state for the
203203+ new account.
204204+5. If the selected account's tokens are expired, attempt silent refresh. If
205205+ refresh fails, navigate to login.
206206+207207+### Adding Accounts
208208+209209+"Add Account" triggers the same OAuth flow from Phase 1. On success, a new row
210210+is inserted into `accounts`. The new account becomes the active account.
211211+212212+Build an `AccountSwitcherCubit` that exposes the list of accounts and the
213213+active DID.
214214+215215+## Offline Reading
216216+217217+The app should render cached data when the network is unavailable. This builds
218218+on the `cached_posts` and `cached_profiles` tables from Phase 1.
219219+220220+### Cache Strategy
221221+222222+Cache the last-fetched page of each feed (timeline, pinned generators) in Drift
223223+as serialised JSON. On launch or feed switch, display cached data immediately,
224224+then fetch fresh data in the background. If the fetch fails, keep showing the
225225+cache with a "You're offline" banner.
226226+227227+### Offline Indicators
228228+229229+- A persistent banner at the top of the screen when connectivity is lost.
230230+- Disable actions that require network (compose, like, repost, follow) and show
231231+ a tooltip explaining why.
232232+- Notifications and DM screens show an empty state with "No connection" when
233233+ offline and no cached data exists.
234234+235235+### Network Detection
236236+237237+Use the **connectivity_plus** package to monitor network state changes. Expose
238238+connectivity as a stream via a `ConnectivityCubit` that all screens observe.
239239+240240+## Saved Posts
241241+242242+Allow users to bookmark posts locally for later reading. Saved posts are stored
243243+only in Drift — nothing is written to the network. This is intentionally
244244+private and local-only.
245245+246246+### Drift Table
247247+248248+| Column | Type | Notes |
249249+| ------------- | -------- | ---------------------------- |
250250+| `id` | integer | PK autoincrement |
251251+| `account_did` | text | FK to `accounts` |
252252+| `post_uri` | text | AT-URI of the saved post |
253253+| `post_json` | text | Full serialised post payload |
254254+| `saved_at` | datetime | When the user saved the post |
255255+256256+Unique constraint on (`account_did`, `post_uri`).
257257+258258+### UI
259259+260260+Add a "Save" action (bookmark icon) to the post action bar. Tapping toggles
261261+the saved state. Saved posts are viewable from a "Saved" section in the
262262+profile screen or settings.
263263+264264+Build a `SavedPostsCubit` that reads/writes the `saved_posts` table and
265265+exposes a stream of saved post URIs for quick lookup (to show filled vs
266266+outlined bookmark icons in the feed).
267267+268268+## Jump to Profile
269269+270270+Add a floating action button on the search screen. Tapping it opens a dialog
271271+with a text field for entering a handle. Use
272272+`app.bsky.actor.searchActorsTypeahead` to provide autocomplete suggestions as
273273+the user types. Selecting a result or pressing enter navigates to that user's
274274+profile screen.
275275+276276+| Endpoint | Purpose |
277277+| -------------------------------------- | ------------------- |
278278+| `app.bsky.actor.searchActorsTypeahead` | Handle autocomplete |
279279+| `app.bsky.actor.getProfile` | Full profile fetch |
+74
docs/tasks/phase-3.md
···11+# Phase 3 Milestones
22+33+## M8 — Post Composition
44+55+- [ ] Full-screen compose modal with text input, live grapheme counter (300 max), and submit button
66+- [ ] `ComposeBloc` — events: `TextChanged`, `MediaAttached`, `MediaRemoved`, `AltTextUpdated`, `DraftSaved`, `DraftLoaded`, `PostScheduled`, `PostSubmitted`
77+- [ ] Image attachment via `uploadBlob` — up to 4 images, alt text input per image
88+- [ ] Live facet detection and preview via `bluesky_text` (mentions, links, hashtags)
99+- [ ] Post creation via `com.atproto.repo.createRecord` with `app.bsky.feed.post` collection
1010+- [ ] Reply support — pass `parent` + `root` refs when composing from a post thread
1111+- [ ] Drift migration: add `drafts` table (id, account_did, text, reply_uri, embed_json, media_paths, created_at, updated_at, scheduled_at)
1212+- [ ] Draft save on network failure, explicit save, and back-navigation
1313+- [ ] Drafts list UI accessible from compose toolbar
1414+- [ ] Scheduled posts — date/time picker, background task via WorkManager / BGTaskScheduler
1515+- [ ] Floating action button on home screen to open compose modal
1616+1717+## M9 — Notifications
1818+1919+- [ ] Notifications screen with grouped-by-day notification list
2020+- [ ] `NotificationBloc` — events: `NotificationsRequested`, `NotificationsRefreshed`, `NotificationsPageLoaded`, `NotificationsMarkedRead`
2121+- [ ] Fetch notifications via `listNotifications` with cursor pagination
2222+- [ ] Render all notification reasons: like, repost, follow, mention, reply, quote
2323+- [ ] Each notification row: author avatar, reason icon, summary text, optional post preview
2424+- [ ] Unread count badge on nav bar via `getUnreadCount` polling (30s interval)
2525+- [ ] Mark as read via `updateSeen` when notifications screen opens
2626+- [ ] Tap notification to navigate to relevant post or profile
2727+2828+## M10 — Direct Messages
2929+3030+- [ ] Conversation list screen via `chat.bsky.convo.listConvos` with pagination
3131+- [ ] `ConvoListBloc` — events: `ConvosRequested`, `ConvosRefreshed`, `ConvoMuted`, `ConvoUnmuted`
3232+- [ ] Primary / Requests tab filtering on conversation list
3333+- [ ] Message thread screen via `chat.bsky.convo.getMessages` with pagination
3434+- [ ] `MessageBloc` — events: `MessagesRequested`, `MessagesPageLoaded`, `MessageSent`, `MessageDeleted`, `ConvoMarkedRead`
3535+- [ ] Chat bubble layout — current user right-aligned, others left-aligned
3636+- [ ] Send messages via `chat.bsky.convo.sendMessage`
3737+- [ ] New conversation via `chat.bsky.convo.getConvoForMembers`
3838+- [ ] Long-press to copy individual messages, overflow menu "Copy All" for full thread
3939+- [ ] Mute / unmute conversations
4040+- [ ] Mark conversation as read via `chat.bsky.convo.updateRead`
4141+4242+## M11 — Account Switching
4343+4444+- [ ] `AccountSwitcherCubit` exposing account list and active DID
4545+- [ ] Account switcher bottom sheet UI — list accounts with avatars and handles
4646+- [ ] Store `active_account_did` in Drift `settings` table
4747+- [ ] Drift migration: add `account_did` column to `cached_posts` if not present
4848+- [ ] All user-scoped queries filter by active account DID
4949+- [ ] Broadcast `AccountSwitched` event to all Blocs on switch
5050+- [ ] "Add Account" button triggers OAuth flow, inserts new `accounts` row
5151+- [ ] Silent token refresh on account switch; navigate to login on failure
5252+5353+## M12 — Offline Reading & Network Resilience
5454+5555+- [ ] `ConnectivityCubit` via **connectivity_plus** — expose network state stream
5656+- [ ] Cache last-fetched feed page as serialised JSON in Drift
5757+- [ ] Display cached data immediately on launch, refresh in background
5858+- [ ] "You're offline" banner when connectivity is lost
5959+- [ ] Disable network-dependent actions (compose, like, repost, follow) when offline with tooltip
6060+- [ ] Notifications and DM screens show "No connection" empty state when offline with no cache
6161+6262+## M13 — Saved Posts
6363+6464+- [ ] Drift migration: add `saved_posts` table (id, account_did, post_uri, post_json, saved_at) with unique constraint on (account_did, post_uri)
6565+- [ ] `SavedPostsCubit` — read/write saved posts, expose stream of saved URIs for icon state
6666+- [ ] Bookmark icon on post action bar — toggle saved state
6767+- [ ] Saved posts list screen accessible from profile or settings
6868+6969+## M14 — Jump to Profile
7070+7171+- [ ] Floating action button on search screen
7272+- [ ] Handle input dialog with autocomplete via `searchActorsTypeahead`
7373+- [ ] Navigate to profile screen on selection or enter
7474+- [ ] Update bottom navigation to include Notifications and Messages tabs (5-tab layout)