mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: check dirty in compose bloc

* tests for About screen

+287 -320
+76 -314
docs/BUGS.md
··· 1 1 --- 2 - title: Bugs (Post Actions & Notifications — Fix Specification) 3 - updated: 2026-03-17 2 + title: Bugs 3 + updated: 2026-03-18 4 4 --- 5 5 6 6 ## Checklist 7 7 8 - - [x] [1. Post Thread Screen](#1-post-thread-screen) 9 - - [x] [2. Post Tap Navigation](#2-post-tap-navigation) 10 - - [x] [3. Avatar Tap Navigation](#3-avatar-tap-navigation) 11 - - [x] [4. Quoted Post Tap Navigation](#4-quoted-post-tap-navigation) 12 - - [x] [5. Notification Tap Navigation](#5-notification-tap-navigation) 13 - - [x] [6. Viewer State on Own Posts](#6-viewer-state-on-own-posts) 14 - - [x] [7. Saved Posts Screen — Render Actual Posts](#7-saved-posts-screen--render-actual-posts) 15 - - [x] [8. Saved Posts — Accessible from Profile](#8-saved-posts--accessible-from-profile) 16 - - [x] [9. Saved Posts — Long Press for Local, Tap for Menu](#9-saved-posts--long-press-for-local-tap-for-menu) 17 - - [x] [10. Saved Posts — Show Save Counts](#10-saved-posts--show-save-counts) 18 - - [x] [11. Saved Posts — Cloud Save via AT Protocol](#11-saved-posts--cloud-save-via-at-protocol) 19 - - [x] [12. Failed Action Snackbar with Revert](#12-failed-action-snackbar-with-revert) 20 - - [x] [13. Delete Post — Remove from Feed](#13-delete-post--remove-from-feed) 21 - 22 - ## 1. Post Thread Screen 23 - 24 - **Status:** Missing — no `/post` route or screen exists. 25 - 26 - **Problem:** Multiple features navigate to `/post?uri=...` or `/post/{uri}`, but the 27 - route is not defined in `app_router.dart` and no screen exists. This breaks: 28 - 29 - - Notification taps (like, repost, reply, mention, quote) 30 - - Saved post "open" action 31 - - Any future post deeplink 32 - 33 - **Fix:** 34 - 35 - - Create `lib/features/feed/presentation/post_thread_screen.dart`. 36 - - Use `designs/thread.html` as a reference for the UI. 37 - - Use the Bluesky `getPostThread` API to fetch the thread (parent chain + replies). 38 - - Display the focused post with full content plus its parent posts above and replies 39 - below, each rendered as `PostCardWithActions`. 40 - - Register route in `app_router.dart`: 41 - - Path: `/post` with query param `uri` (e.g. `/post?uri=at://...`). 42 - - Handle loading, error, and blocked/not-found thread states. 43 - 44 - **Files:** 45 - 46 - - New: `lib/features/feed/presentation/post_thread_screen.dart` 47 - - New: `lib/features/feed/data/post_thread_repository.dart` (wraps `getPostThread`) 48 - - Edit: `lib/core/router/app_router.dart` — add `/post` route 49 - 50 - ## 2. Post Tap Navigation 51 - 52 - **Status:** Broken — tapping a post body does nothing. 53 - 54 - **Problem:** `PostCard` is not wrapped in a tap detector. The only interactive elements 55 - are the action bar buttons and embedded media. Users expect tapping a post to open it. 56 - 57 - **Fix:** 58 - 59 - - Wrap the post content area (header + text + embed, excluding the action bar) in an 60 - `InkWell` that navigates to `/post?uri={postUri}`. 61 - - The action bar itself should NOT trigger navigation — only the content area above it. 62 - 63 - **Files:** 64 - 65 - - Edit: `lib/features/feed/presentation/widgets/post_card.dart` — wrap content in 66 - `InkWell` with navigation callback 67 - - Edit: `lib/features/feed/presentation/widgets/post_card_with_actions.dart` — pass 68 - `onTap` callback through to `PostCard` 69 - 70 - ## 3. Avatar Tap Navigation 71 - 72 - **Status:** Broken — tapping a post author's avatar does nothing. 73 - 74 - **Problem:** The avatar `CircleAvatar` in `PostCard._buildHeader` is not tappable. 75 - Users expect tapping an avatar to navigate to that user's profile. 76 - 77 - **Fix:** 78 - 79 - - Wrap the `CircleAvatar` in `_buildHeader` with a `GestureDetector` that navigates to 80 - `/profile/view?actor={author.did}`. 81 - - Requires passing the navigation callback or `BuildContext` with router access. 82 - 83 - **Files:** 84 - 85 - - Edit: `lib/features/feed/presentation/widgets/post_card.dart` — `_buildHeader` 86 - (line ~55) 87 - 88 - ## 4. Quoted Post Tap Navigation 89 - 90 - **Status:** Incorrect — tapping a quoted post navigates to the quoted author's profile 91 - instead of the quoted post. 92 - 93 - **Problem:** `PostCard._buildQuotedRecord` (line 342-346) navigates to 94 - `/profile/view?actor={quoted.author.did}`. It should open the quoted post in the thread 95 - screen. 96 - 97 - **Fix:** 98 - 99 - - Change the `onTap` in `_buildQuotedRecord` to navigate to 100 - `/post?uri={quoted.uri}` instead of the author's profile. 101 - 102 - **Files:** 103 - 104 - - Edit: `lib/features/feed/presentation/widgets/post_card.dart` — `_buildQuotedRecord` 105 - (line ~342) 106 - 107 - ## 5. Notification Tap Navigation 108 - 109 - **Status:** Broken — tapping non-follow notifications crashes or does nothing (route 110 - doesn't exist). 111 - 112 - **Problem:** `notification_list_item.dart:263` pushes `/post?uri=...` but the route is 113 - undefined. This is blocked by [#1](#1-post-thread-screen). 114 - 115 - **Fix:** 116 - 117 - - Once the `/post` route exists ([#1](#1-post-thread-screen)), notification taps will 118 - work. Verify the URI encoding is consistent (`Uri.encodeComponent` vs query param). 119 - - Current code: `context.push('/post?uri=${Uri.encodeComponent(uri.toString())}')` 120 - - Ensure the route handler decodes this correctly. 121 - - For like/repost notifications, `notification.uri` may point to the *liker's record*, 122 - not the original post. Verify that `reasonSubject` (the post that was liked) is used 123 - instead when appropriate. 124 - 125 - **Files:** 126 - 127 - - Edit: `lib/features/notifications/presentation/widgets/notification_list_item.dart` 128 - — `_onTap` (line ~256). May need to use `notification.reasonSubject` for like/repost 129 - notifications instead of `notification.uri`. 130 - 131 - ## 6. Viewer State on Own Posts 132 - 133 - **Status:** Broken — current user's liked/reposted/saved posts don't show as active in 134 - the feed. 135 - 136 - **Problem:** `PostCardWithActions` initializes `PostActionCubit` from `viewer.like` and 137 - `viewer.repost` (lines 35-36), which correctly reflects the API state. However: 138 - 139 - - After the user likes a post, scrolls away, and scrolls back, the cubit is recreated 140 - from the stale `FeedViewPost` data (the original API response), losing the local 141 - optimistic state. 142 - - Saved state works correctly because `SavedPostsCubit` is global and checks the DB. 143 - 144 - **Fix:** 145 - 146 - - Maintain a lightweight in-memory cache (e.g. `Map<String, PostActionState>`) in a 147 - higher-level provider that `PostActionCubit` reads from on creation and writes to on 148 - state changes. This way, scrolling away and back preserves the user's actions within 149 - the session. 150 - - Alternatively, store the `likeUri`/`repostUri` in the feed bloc state so it survives 151 - cubit recreation. 152 - 153 - **Files:** 154 - 155 - - New or edit: A post action cache/provider (could be a simple `ChangeNotifier` or 156 - cubit at the feed level) 157 - - Edit: `lib/features/feed/presentation/widgets/post_card_with_actions.dart` — read 158 - from cache on cubit creation 159 - - Edit: `lib/features/feed/cubit/post_action_cubit.dart` — write to cache on state 160 - changes 161 - 162 - ## 7. Saved Posts Screen — Render Actual Posts 163 - 164 - **Status:** Incomplete — saved posts screen shows metadata cards, not the actual post 165 - content. 166 - 167 - **Problem:** `_SavedPostCard` in `saved_posts_screen.dart` shows a generic "Saved Post" 168 - `ListTile` with a date and action buttons. The full post JSON is stored in the DB 169 - (`postJson` column) but is never deserialized and rendered. 170 - 171 - **Fix:** 172 - 173 - - Deserialize `savedPost.postJson` back into a `PostView` and render it with 174 - `PostCardWithActions` (or a read-only variant). 175 - - The "open" button should navigate to `/post?uri={postUri}` (once [#1](#1-post-thread-screen) exists). 176 - - Keep swipe-to-dismiss for unsaving. 177 - 178 - **Files:** 179 - 180 - - Edit: `lib/features/feed/presentation/saved_posts_screen.dart` — replace 181 - `_SavedPostCard` with actual post rendering 182 - - The route in `_openPost` (line 186) currently uses path-style 183 - `/post/${Uri.encodeComponent(...)}` but should use query-style 184 - `/post?uri=${Uri.encodeComponent(...)}` to match the route definition. 185 - 186 - ## 8. Saved Posts — Accessible from Profile 187 - 188 - **Status:** Incorrect location — saved posts are behind Settings, not on profiles. 189 - 190 - **Problem:** The saved posts link is in `settings_screen.dart` (line 56-61). The 191 - requirement is that it should be accessible from profiles. 192 - 193 - **Fix:** 194 - 195 - - Add a "Saved Posts" button/tab on the current user's own profile screen. 196 - - Keep (or remove) the Settings entry as a secondary access point. 197 - 198 - **Files:** 8 + - [x] [1. Draft Save Redundancy — Cancel Prompts After Explicit Save](#1-draft-save-redundancy--cancel-prompts-after-explicit-save) 9 + - [ ] [2. Character Counter — No Initial State](#2-character-counter--no-initial-state) 10 + - [ ] [3. Composer Layout — Drafts Should Be Inline, Not Full-Screen](#3-composer-layout--drafts-should-be-inline-not-full-screen) 199 11 200 - - Edit: profile screen (add saved posts navigation for the current user's profile) 201 - - Optionally edit: `lib/features/settings/presentation/settings_screen.dart` 12 + ## 1. Draft Save Redundancy — Cancel Prompts After Explicit Save 202 13 203 - ## 9. Saved Posts — Long Press for Local, Tap for Menu 14 + **Status:** Broken — user can save a draft, then immediately be asked to save again on 15 + cancel. 204 16 205 - **Status:** Missing — only tap-to-toggle exists, no long press or menu. 206 - 207 - **Problem:** The bookmark button in `PostActionBar` only has `onTap` (line 82). The 208 - requirement is: 209 - 210 - - **Long press** → save/unsave locally (instant, different icon color) 211 - - **Normal press** → show menu with options: save/remove locally, save/remove from 212 - cloud (ATProto) 213 - 214 - Cloud save is not yet implemented, but the menu structure should be in place. 17 + **Problem:** The AppBar has both a "Save Draft" button (line 502) and a "Cancel" button 18 + (line 497). If the user taps "Save Draft" → draft is saved and a snackbar confirms it. 19 + If the user then taps "Cancel", `_handleBackNavigation` (line 426) checks `hasContent` 20 + (line 430), which is still `true` because the text/media haven't been cleared. The user 21 + is shown a "Save Draft?" dialog even though the draft was just saved moments ago. 215 22 216 23 **Fix:** 217 24 218 - - Add `onLongPress` to the bookmark `_ActionButton` in `PostActionBar`. 219 - - Long press: toggle local save immediately (current behavior), use a distinct color 220 - (e.g. amber/gold for local saves vs primary for cloud). 221 - - Normal press: show a bottom sheet with options: 222 - - "Save locally" / "Remove local save" 223 - - "Save to Bluesky" / "Remove from Bluesky" (disabled/placeholder until cloud is 224 - implemented) 225 - - Update `SavedPostsState` to distinguish local vs cloud saves. 25 + - Track whether the current content has been saved since the last edit. Add a 26 + `isDraftDirty` (or `hasUnsavedChanges`) flag to `ComposeState`. 27 + - Set `isDraftDirty: true` when text or media changes (`_onTextChanged`, 28 + `_onMediaChanged`, etc.). 29 + - Set `isDraftDirty: false` after a successful `DraftSaved` event. 30 + - In `_handleBackNavigation`, check `isDraftDirty` instead of (or in addition to) 31 + `hasContent`. If content exists but `isDraftDirty` is `false`, skip the dialog and 32 + pop immediately. 226 33 227 34 **Files:** 228 35 229 - - Edit: `lib/features/feed/presentation/widgets/post_action_bar.dart` — add 230 - `onLongPress`, show menu on tap 231 - - Edit: `lib/features/feed/cubit/saved_posts_cubit.dart` — support save type 232 - distinction 233 - - Edit: `lib/core/database/tables.dart` — add `saveType` column (local/cloud/both) 234 - with migration 235 - 236 - ## 10. Saved Posts — Show Save Counts 237 - 238 - **Status:** Missing — hardcoded to `0` and hidden. 36 + - Edit: `lib/features/compose/bloc/compose_state.dart` — add `isDraftDirty` field 37 + (default `true` for new compositions, `false` after draft load) 38 + - Edit: `lib/features/compose/bloc/compose_bloc.dart` — set `isDraftDirty: true` in 39 + `_onTextChanged` (line 62) and media-change handlers; set `isDraftDirty: false` in 40 + `_onDraftSaved` (line 214) and `_onDraftLoaded` (line 241) 41 + - Edit: `lib/features/compose/presentation/compose_screen.dart` — 42 + `_handleBackNavigation` (line 426): gate the dialog on `state.isDraftDirty` rather 43 + than just `hasContent` 239 44 240 - **Problem:** `PostActionBar` line 80: `count: 0` for the bookmark button. Save counts 241 - are never fetched or displayed. 45 + ## 2. Character Counter — No Initial State 242 46 243 - **Fix:** 47 + **Status:** Incomplete — counter ring starts empty and invisible until the user types. 244 48 245 - - The Bluesky API provides `PostView.bookmarkCount` (nullable `int`). Pass this value 246 - through to `PostActionBar` instead of the hardcoded `0`. 247 - - Wire it up the same way `likeCount`/`repostCount` are: read from `post.bookmarkCount` 248 - in `PostCardWithActions` and pass to `PostActionBar`. 49 + **Problem:** `_CharCounter` (compose_screen.dart, line 895) only shows the remaining 50 + character count text when `count > 0` (line 918). On an empty compose screen the user 51 + sees a bare progress ring at 0% with no text — there is no indication of the 300-character 52 + limit. When loading a draft, the counter jumps from nothing to whatever the draft's count 53 + is, which feels jarring. 249 54 250 - **Files:** 251 - 252 - - Edit: `lib/features/feed/presentation/widgets/post_card_with_actions.dart` — read 253 - `post.bookmarkCount ?? 0` and pass to action bar 254 - - Edit: `lib/features/feed/presentation/widgets/post_action_bar.dart` — use the passed 255 - count instead of hardcoded `0` 256 - 257 - ## 11. Saved Posts — Cloud Save via AT Protocol 258 - 259 - **Status:** Not implemented — "Save to Bluesky" option is disabled with "Coming soon" placeholder. 260 - 261 - **Problem:** The save menu in `PostActionBar._showSaveOptions()` has a disabled "Save to 262 - Bluesky" option. The `bluesky` package already exposes a bookmark API 263 - (`app.bsky.bookmark.*`) but it is not wired up. Currently all saves are local-only. 55 + The progress ring itself also starts as just the background circle with no fill, giving 56 + no visual cue about what it represents. 264 57 265 58 **Fix:** 266 59 267 - - Add bookmark methods to `PostActionRepository` using the existing `_bluesky.bookmark` 268 - service: 269 - - `createBookmark({uri, cid})` → `_bluesky.bookmark.createBookmark(uri, cid)` 270 - - `deleteBookmark({uri})` → `_bluesky.bookmark.deleteBookmark(uri)` 271 - - `getBookmarks({limit, cursor})` → `_bluesky.bookmark.getBookmarks(limit, cursor)` 272 - - Add `cloudSave` and `cloudUnsave` methods to `SavedPostsCubit`: 273 - - Call `PostActionRepository.createBookmark` / `deleteBookmark`. 274 - - On success, upsert the local DB row with `saveType: 'cloud'` (or `'both'` if already 275 - saved locally). On cloud unsave, downgrade `saveType` to `'local'` if a local save 276 - exists, or delete the row entirely. 277 - - Use optimistic UI: update the icon immediately, revert on failure. 278 - - Enable the "Save to Bluesky" / "Remove from Bluesky" option in 279 - `PostActionBar._showSaveOptions()` and wire it to `SavedPostsCubit.cloudSave` / 280 - `cloudUnsave` via a new callback. 281 - - Distinguish cloud vs local saves visually: 282 - - Local-only: amber/gold bookmark icon. 283 - - Cloud (or both): primary/blue bookmark icon. 284 - - `PostActionBar` already receives `isSaved`; extend it with a `saveType` parameter 285 - (or similar) so the icon color reflects the save type. 286 - - Add a one-time sync on login: call `getBookmarks` (paginated) and merge results into 287 - the local DB so cloud saves made on other clients appear. Mark these as `saveType: 288 - 'cloud'`. 60 + - Always show the remaining count text, even when `count == 0`. Remove the `if (count > 0)` 61 + guard so the counter displays `300` on an empty compose screen. 62 + - This gives users an immediate signal: "you have 300 characters" — matching the behavior 63 + of the official Bluesky app and Twitter/X composer. 289 64 290 65 **Files:** 291 66 292 - - Edit: `lib/features/feed/data/post_action_repository.dart` — add `createBookmark`, 293 - `deleteBookmark`, `getBookmarks` methods 294 - - Edit: `lib/features/feed/cubit/saved_posts_cubit.dart` — add `cloudSave`, 295 - `cloudUnsave`, `syncCloudBookmarks` methods; handle `saveType` transitions 296 - - Edit: `lib/features/feed/presentation/widgets/post_action_bar.dart` — enable cloud 297 - save option, accept `saveType` parameter, update icon color logic 298 - - Edit: `lib/features/feed/presentation/widgets/post_card_with_actions.dart` — pass 299 - `saveType` and cloud save/unsave callbacks to `PostActionBar` 67 + - Edit: `lib/features/compose/presentation/compose_screen.dart` — `_CharCounter.build` 68 + (line 918): remove the `if (count > 0)` condition so the remaining count is always 69 + visible 300 70 301 - ## 12. Failed Action Snackbar with Revert 71 + ## 3. Composer Layout — Drafts Should Be Inline, Not Full-Screen 302 72 303 - **Status:** Partially implemented — rollback works but snackbar is basic. 73 + **Status:** UX issue — drafts open as a modal bottom sheet that covers the composer. 304 74 305 - **Problem:** `PostActionCubit` correctly reverts optimistic updates on failure and shows 306 - a snackbar via `BlocListener` in `post_card_with_actions.dart` (lines 55-64). However: 75 + **Problem:** Tapping the drafts button (line 805) calls `_showDraftsDialog` (line 252), 76 + which opens a `showModalBottomSheet` with a `DraggableScrollableSheet` taking 60–90% of 77 + the screen. This obscures the compose area entirely, breaking the user's context. The 78 + overall composer is also described as "colossal" — the full-screen layout with the modal 79 + drafts on top makes it feel heavy. 307 80 308 - - The snackbar has no retry action button. 309 - - There's no visual indication during the loading state (the icon just sits there while 310 - `isLoadingLike`/`isLoadingRepost` is true). 81 + The desired behavior is: drafts should appear inline, sharing the screen with the 82 + compose area, and be toggleable open/closed. 311 83 312 84 **Fix:** 313 85 314 - - Add a "Retry" `SnackBarAction` to the error snackbar. 315 - - Show a subtle loading indicator on the action button while the network call is 316 - in-flight (e.g., replace the icon with a small spinner, or dim it). The 317 - `isLoadingLike`/`isLoadingRepost` fields already exist in state. 318 - 319 - **Files:** 320 - 321 - - Edit: `lib/features/feed/presentation/widgets/post_card_with_actions.dart` — add 322 - retry action to snackbar 323 - - Edit: `lib/features/feed/presentation/widgets/post_action_bar.dart` — show loading 324 - state visually on like/repost buttons 325 - 326 - ## 13. Delete Post — Remove from Feed 327 - 328 - **Status:** Incomplete — post is deleted on the server but remains visible in the feed. 329 - 330 - **Problem:** `PostActionCubit.deletePost()` (line 186-193) calls the API to delete but 331 - does not remove the post from the feed list. The deleted post card remains visible until 332 - the user refreshes. 333 - 334 - **Fix:** 335 - 336 - - After successful deletion, notify the parent feed bloc/cubit to remove the post from 337 - its list. 338 - - This could be done via a callback, a shared event bus, or by having the feed bloc 339 - listen for deletion events. 340 - - Show a confirmation snackbar: "Post deleted". 86 + - Replace the `showModalBottomSheet` drafts dialog with an inline, collapsible drafts 87 + panel that sits below the compose text field (or above the bottom toolbar). 88 + - Use an `AnimatedContainer` or `ExpansionTile`-style widget that expands/collapses 89 + when the drafts button is toggled. 90 + - When expanded, the drafts panel should take roughly half the available space, with the 91 + compose text field shrinking to accommodate it. The text field remains visible and 92 + editable above. 93 + - When collapsed, the panel is fully hidden and the compose area reclaims the space. 94 + - Add a toggle state (e.g. `_showDrafts` boolean in the screen's `State`) controlled by 95 + the existing drafts `IconButton` (line 804). 96 + - Keep the same drafts list UI (ListTile with content preview, time, delete button, tap 97 + to load) — just move it from a modal into the inline panel. 341 98 342 99 **Files:** 343 100 344 - - Edit: `lib/features/feed/cubit/post_action_cubit.dart` — emit a "deleted" state or 345 - invoke a callback 346 - - Edit: feed bloc/cubit — handle post removal from list 347 - - Edit: `lib/features/feed/presentation/widgets/post_card_with_actions.dart` — wire up 348 - deletion callback 101 + - Edit: `lib/features/compose/presentation/compose_screen.dart`: 102 + - Add `_showDrafts` state variable to `_ComposeScreenState` 103 + - Replace `_showDraftsDialog()` call on the drafts button (line 805) with a 104 + `setState(() => _showDrafts = !_showDrafts)` toggle 105 + - Add an inline drafts panel widget between the text field / media area and the 106 + bottom toolbar (around line 767), wrapped in an `AnimatedSize` or similar for 107 + smooth expand/collapse 108 + - Remove or repurpose `_showDraftsDialog()` (lines 252-374) — extract the list 109 + content into a reusable `_DraftsPanel` widget used by the inline panel 110 + - Fire `DraftsRequested` event when the panel is opened (same as current behavior)
+5
docs/TODO.md
··· 6 6 7 7 ## UX 8 8 9 + ### Composer 10 + 9 11 - Odd behavior when saving drafts: can save draft via the button but hitting cancel 10 12 prompts to save or discard. 11 13 - Character count doesn't have "initial state" (completely full) 12 14 - Composer is collosal -> We should show drafts on half the screen, with the option 13 15 to toggle it closed. 16 + 17 + ### Dev Tools 18 + 14 19 - Instead of tabs, navigating to a record through dev tools should instead show 15 20 - A drawer on Tablet 16 21 - Cards (stacked) on Mobile, with swipe to go back
+5 -3
lib/features/compose/bloc/compose_bloc.dart
··· 72 72 isOverLimit: isOverLimit, 73 73 isEmpty: isEmpty, 74 74 canSubmit: !isOverLimit && !isEmpty && !(state.videoAttachment?.isActive ?? false), 75 + isDraftDirty: true, 75 76 ), 76 77 ); 77 78 } ··· 83 84 ..add(MediaAttachment(localPath: event.path, width: event.width, height: event.height)); 84 85 final isEmpty = state.text.trim().isEmpty && attachments.isEmpty; 85 86 86 - emit(state.copyWith(mediaAttachments: attachments, isEmpty: isEmpty, canSubmit: !state.isOverLimit && !isEmpty)); 87 + emit(state.copyWith(mediaAttachments: attachments, isEmpty: isEmpty, canSubmit: !state.isOverLimit && !isEmpty, isDraftDirty: true)); 87 88 } 88 89 89 90 Future<void> _onMediaRemoved(MediaRemoved event, Emitter<ComposeState> emit) async { ··· 92 93 final attachments = List<MediaAttachment>.from(state.mediaAttachments)..removeAt(event.index); 93 94 final isEmpty = state.text.trim().isEmpty && attachments.isEmpty && state.videoAttachment == null; 94 95 95 - emit(state.copyWith(mediaAttachments: attachments, isEmpty: isEmpty, canSubmit: !state.isOverLimit && !isEmpty)); 96 + emit(state.copyWith(mediaAttachments: attachments, isEmpty: isEmpty, canSubmit: !state.isOverLimit && !isEmpty, isDraftDirty: true)); 96 97 } 97 98 98 99 Future<void> _onAltTextUpdated(AltTextUpdated event, Emitter<ComposeState> emit) async { ··· 231 232 updatedAt: Value(DateTime.now()), 232 233 ); 233 234 final id = await _database.saveDraft(draft); 234 - emit(state.copyWith(draftId: id, isSavingDraft: false)); 235 + emit(state.copyWith(draftId: id, isSavingDraft: false, isDraftDirty: false)); 235 236 } catch (e, stackTrace) { 236 237 log.e('Failed to save draft', error: e, stackTrace: stackTrace); 237 238 emit(state.copyWith(isSavingDraft: false)); ··· 292 293 replyParentCid: draft.replyCid, 293 294 replyRootUri: draft.rootUri, 294 295 replyRootCid: draft.rootCid, 296 + isDraftDirty: false, 295 297 ).copyWith( 296 298 graphemeCount: graphemeCount, 297 299 isOverLimit: isOverLimit,
+7
lib/features/compose/bloc/compose_state.dart
··· 81 81 this.isLoadingDrafts = false, 82 82 this.canSubmit = false, 83 83 this.videoAttachment, 84 + this.isDraftDirty = true, 84 85 }); 85 86 86 87 const ComposeState.initial() : this._(status: ComposeStatus.initial); ··· 100 101 String? quoteUri, 101 102 String? quoteCid, 102 103 VideoAttachment? videoAttachment, 104 + bool isDraftDirty = true, 103 105 }) : this._( 104 106 status: ComposeStatus.ready, 105 107 text: text, ··· 117 119 quoteCid: quoteCid, 118 120 videoAttachment: videoAttachment, 119 121 canSubmit: !isOverLimit && !isEmpty, 122 + isDraftDirty: isDraftDirty, 120 123 ); 121 124 122 125 final ComposeStatus status; ··· 139 142 final bool isLoadingDrafts; 140 143 final bool canSubmit; 141 144 final VideoAttachment? videoAttachment; 145 + final bool isDraftDirty; 142 146 143 147 bool get isSubmitting => status == ComposeStatus.submitting; 144 148 bool get hasError => status == ComposeStatus.error; ··· 173 177 bool? isLoadingDrafts, 174 178 bool? canSubmit, 175 179 Object? videoAttachment = const _Undefined(), 180 + bool? isDraftDirty, 176 181 }) { 177 182 return ComposeState._( 178 183 status: status ?? this.status, ··· 195 200 isLoadingDrafts: isLoadingDrafts ?? this.isLoadingDrafts, 196 201 canSubmit: canSubmit ?? this.canSubmit, 197 202 videoAttachment: videoAttachment is _Undefined ? this.videoAttachment : videoAttachment as VideoAttachment?, 203 + isDraftDirty: isDraftDirty ?? this.isDraftDirty, 198 204 ); 199 205 } 200 206 ··· 220 226 isLoadingDrafts, 221 227 canSubmit, 222 228 videoAttachment, 229 + isDraftDirty, 223 230 ]; 224 231 } 225 232
+1 -1
lib/features/compose/presentation/compose_screen.dart
··· 429 429 430 430 final hasContent = state.text.trim().isNotEmpty || state.mediaAttachments.isNotEmpty; 431 431 432 - if (hasContent) { 432 + if (hasContent && state.isDraftDirty) { 433 433 showDialog<bool>( 434 434 context: context, 435 435 builder: (dialogContext) => AlertDialog(
+2 -2
pubspec.lock
··· 849 849 source: hosted 850 850 version: "3.1.6" 851 851 plugin_platform_interface: 852 - dependency: transitive 852 + dependency: "direct main" 853 853 description: 854 854 name: plugin_platform_interface 855 855 sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" ··· 1134 1134 source: hosted 1135 1135 version: "3.2.5" 1136 1136 url_launcher_platform_interface: 1137 - dependency: transitive 1137 + dependency: "direct main" 1138 1138 description: 1139 1139 name: url_launcher_platform_interface 1140 1140 sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
+2
pubspec.yaml
··· 37 37 cross_file: ^0.3.4+2 38 38 characters: ^1.4.0 39 39 workmanager: ^0.5.2 40 + plugin_platform_interface: ^2.1.8 41 + url_launcher_platform_interface: ^2.3.2 40 42 41 43 dev_dependencies: 42 44 flutter_test:
+74
test/features/compose/bloc/compose_bloc_test.dart
··· 58 58 expect(composeBloc.state.canSubmit, false); 59 59 }); 60 60 61 + group('isDraftDirty', () { 62 + test('initial state has isDraftDirty true', () { 63 + expect(composeBloc.state.isDraftDirty, true); 64 + }); 65 + 66 + blocTest<ComposeBloc, ComposeState>( 67 + 'isDraftDirty is true after text changed', 68 + build: () => composeBloc, 69 + seed: () => const ComposeState.ready(isDraftDirty: false), 70 + act: (bloc) => bloc.add(const TextChanged('Hello')), 71 + expect: () => [isA<ComposeState>().having((s) => s.isDraftDirty, 'isDraftDirty', true)], 72 + ); 73 + 74 + blocTest<ComposeBloc, ComposeState>( 75 + 'isDraftDirty is true after media attached', 76 + build: () => composeBloc, 77 + seed: () => const ComposeState.ready(isDraftDirty: false), 78 + act: (bloc) => bloc.add(const MediaAttached('/path/to/image.jpg')), 79 + expect: () => [isA<ComposeState>().having((s) => s.isDraftDirty, 'isDraftDirty', true)], 80 + ); 81 + 82 + blocTest<ComposeBloc, ComposeState>( 83 + 'isDraftDirty is true after media removed', 84 + build: () => composeBloc, 85 + seed: () => const ComposeState.ready( 86 + mediaAttachments: [MediaAttachment(localPath: '/1.jpg')], 87 + isDraftDirty: false, 88 + ), 89 + act: (bloc) => bloc.add(const MediaRemoved(0)), 90 + expect: () => [isA<ComposeState>().having((s) => s.isDraftDirty, 'isDraftDirty', true)], 91 + ); 92 + 93 + blocTest<ComposeBloc, ComposeState>( 94 + 'isDraftDirty is false after draft saved successfully', 95 + build: () { 96 + when(() => mockDatabase.saveDraft(any())).thenAnswer((_) async => 1); 97 + return composeBloc; 98 + }, 99 + seed: () => const ComposeState.ready(text: 'Hello', isDraftDirty: true), 100 + act: (bloc) => bloc.add(const DraftSaved()), 101 + expect: () => [ 102 + isA<ComposeState>().having((s) => s.isSavingDraft, 'isSavingDraft', true), 103 + isA<ComposeState>().having((s) => s.isDraftDirty, 'isDraftDirty', false), 104 + ], 105 + ); 106 + 107 + blocTest<ComposeBloc, ComposeState>( 108 + 'isDraftDirty is false after draft loaded', 109 + build: () { 110 + when(() => mockDatabase.getDraft(1)).thenAnswer((_) async => _makeDraft(content: 'Draft text')); 111 + return composeBloc; 112 + }, 113 + act: (bloc) => bloc.add(const DraftLoaded(1)), 114 + expect: () => [isA<ComposeState>().having((s) => s.isDraftDirty, 'isDraftDirty', false)], 115 + ); 116 + 117 + blocTest<ComposeBloc, ComposeState>( 118 + 'isDraftDirty becomes true after editing a loaded draft', 119 + build: () { 120 + when(() => mockDatabase.getDraft(1)).thenAnswer((_) async => _makeDraft(content: 'Draft text')); 121 + return composeBloc; 122 + }, 123 + act: (bloc) async { 124 + bloc.add(const DraftLoaded(1)); 125 + await Future<void>.delayed(Duration.zero); 126 + bloc.add(const TextChanged('Draft text edited')); 127 + }, 128 + expect: () => [ 129 + isA<ComposeState>().having((s) => s.isDraftDirty, 'isDraftDirty', false), 130 + isA<ComposeState>().having((s) => s.isDraftDirty, 'isDraftDirty', true), 131 + ], 132 + ); 133 + }); 134 + 61 135 group('TextChanged', () { 62 136 blocTest<ComposeBloc, ComposeState>( 63 137 'emits correct state when text changes',
+115
test/features/settings/presentation/about_screen_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/features/settings/presentation/about_screen.dart'; 4 + import 'package:url_launcher_platform_interface/link.dart'; 5 + import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; 6 + import 'package:plugin_platform_interface/plugin_platform_interface.dart'; 7 + 8 + class _FakeUrlLauncher extends Fake with MockPlatformInterfaceMixin implements UrlLauncherPlatform { 9 + final List<String> launchedUrls = []; 10 + 11 + @override 12 + LinkDelegate? get linkDelegate => null; 13 + 14 + @override 15 + Future<bool> launchUrl(String url, LaunchOptions options) async { 16 + launchedUrls.add(url); 17 + return true; 18 + } 19 + 20 + @override 21 + Future<bool> supportsMode(PreferredLaunchMode mode) async => true; 22 + 23 + @override 24 + Future<bool> canLaunch(String url) async => true; 25 + } 26 + 27 + void main() { 28 + late _FakeUrlLauncher fakeUrlLauncher; 29 + 30 + setUp(() { 31 + fakeUrlLauncher = _FakeUrlLauncher(); 32 + UrlLauncherPlatform.instance = fakeUrlLauncher; 33 + }); 34 + 35 + Widget buildSubject() => const MaterialApp(home: AboutScreen()); 36 + 37 + group('AboutScreen', () { 38 + testWidgets('renders app bar with About title', (tester) async { 39 + await tester.pumpWidget(buildSubject()); 40 + await tester.pump(); 41 + 42 + expect(find.text('About'), findsAtLeastNWidgets(1)); 43 + }); 44 + 45 + testWidgets('renders Lazurite headline', (tester) async { 46 + await tester.pumpWidget(buildSubject()); 47 + await tester.pump(); 48 + 49 + expect(find.text('Lazurite'), findsOneWidget); 50 + }); 51 + 52 + testWidgets('renders Stormlight Labs description', (tester) async { 53 + await tester.pumpWidget(buildSubject()); 54 + await tester.pump(); 55 + 56 + expect(find.textContaining('Stormlight Labs'), findsAtLeastNWidgets(1)); 57 + }); 58 + 59 + testWidgets('renders Owais as a tappable link', (tester) async { 60 + await tester.pumpWidget(buildSubject()); 61 + await tester.pump(); 62 + 63 + expect(find.text('Owais'), findsOneWidget); 64 + }); 65 + 66 + testWidgets('renders GitHub and contribution text', (tester) async { 67 + await tester.pumpWidget(buildSubject()); 68 + await tester.pump(); 69 + 70 + expect(find.textContaining('GitHub'), findsAtLeastNWidgets(1)); 71 + }); 72 + 73 + testWidgets('renders version string', (tester) async { 74 + await tester.pumpWidget(buildSubject()); 75 + await tester.pump(); 76 + 77 + expect(find.textContaining('Lazurite v'), findsOneWidget); 78 + }); 79 + 80 + testWidgets('renders email icon', (tester) async { 81 + await tester.pumpWidget(buildSubject()); 82 + await tester.pump(); 83 + 84 + expect(find.byIcon(Icons.email_outlined), findsOneWidget); 85 + }); 86 + 87 + testWidgets('tapping Owais launches LinkedIn URL', (tester) async { 88 + await tester.pumpWidget(buildSubject()); 89 + await tester.pump(); 90 + 91 + await tester.tap(find.text('Owais')); 92 + await tester.pump(); 93 + 94 + expect(fakeUrlLauncher.launchedUrls, contains('https://linkedin.com/in/owais-jamil')); 95 + }); 96 + 97 + testWidgets('tapping email icon launches mailto URL', (tester) async { 98 + await tester.pumpWidget(buildSubject()); 99 + await tester.pump(); 100 + 101 + await tester.tap(find.byIcon(Icons.email_outlined)); 102 + await tester.pump(); 103 + 104 + expect(fakeUrlLauncher.launchedUrls, contains('mailto:info@stormlightlabs.org')); 105 + }); 106 + 107 + testWidgets('renders three link icons', (tester) async { 108 + await tester.pumpWidget(buildSubject()); 109 + await tester.pump(); 110 + 111 + // GitHub SVG, Tangled SVG, email icon — wrapped in _LinkIcon (InkWell) 112 + expect(find.byType(InkWell), findsNWidgets(3)); 113 + }); 114 + }); 115 + }