diff --git a/docs/BUGS.md b/docs/BUGS.md index 33b8e30..7398092 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -1,348 +1,110 @@ --- -title: Bugs (Post Actions & Notifications — Fix Specification) -updated: 2026-03-17 +title: Bugs +updated: 2026-03-18 --- ## Checklist -- [x] [1. Post Thread Screen](#1-post-thread-screen) -- [x] [2. Post Tap Navigation](#2-post-tap-navigation) -- [x] [3. Avatar Tap Navigation](#3-avatar-tap-navigation) -- [x] [4. Quoted Post Tap Navigation](#4-quoted-post-tap-navigation) -- [x] [5. Notification Tap Navigation](#5-notification-tap-navigation) -- [x] [6. Viewer State on Own Posts](#6-viewer-state-on-own-posts) -- [x] [7. Saved Posts Screen — Render Actual Posts](#7-saved-posts-screen--render-actual-posts) -- [x] [8. Saved Posts — Accessible from Profile](#8-saved-posts--accessible-from-profile) -- [x] [9. Saved Posts — Long Press for Local, Tap for Menu](#9-saved-posts--long-press-for-local-tap-for-menu) -- [x] [10. Saved Posts — Show Save Counts](#10-saved-posts--show-save-counts) -- [x] [11. Saved Posts — Cloud Save via AT Protocol](#11-saved-posts--cloud-save-via-at-protocol) -- [x] [12. Failed Action Snackbar with Revert](#12-failed-action-snackbar-with-revert) -- [x] [13. Delete Post — Remove from Feed](#13-delete-post--remove-from-feed) +- [x] [1. Draft Save Redundancy — Cancel Prompts After Explicit Save](#1-draft-save-redundancy--cancel-prompts-after-explicit-save) +- [ ] [2. Character Counter — No Initial State](#2-character-counter--no-initial-state) +- [ ] [3. Composer Layout — Drafts Should Be Inline, Not Full-Screen](#3-composer-layout--drafts-should-be-inline-not-full-screen) -## 1. Post Thread Screen +## 1. Draft Save Redundancy — Cancel Prompts After Explicit Save -**Status:** Missing — no `/post` route or screen exists. +**Status:** Broken — user can save a draft, then immediately be asked to save again on +cancel. -**Problem:** Multiple features navigate to `/post?uri=...` or `/post/{uri}`, but the -route is not defined in `app_router.dart` and no screen exists. This breaks: - -- Notification taps (like, repost, reply, mention, quote) -- Saved post "open" action -- Any future post deeplink - -**Fix:** - -- Create `lib/features/feed/presentation/post_thread_screen.dart`. -- Use `designs/thread.html` as a reference for the UI. -- Use the Bluesky `getPostThread` API to fetch the thread (parent chain + replies). -- Display the focused post with full content plus its parent posts above and replies - below, each rendered as `PostCardWithActions`. -- Register route in `app_router.dart`: - - Path: `/post` with query param `uri` (e.g. `/post?uri=at://...`). -- Handle loading, error, and blocked/not-found thread states. - -**Files:** - -- New: `lib/features/feed/presentation/post_thread_screen.dart` -- New: `lib/features/feed/data/post_thread_repository.dart` (wraps `getPostThread`) -- Edit: `lib/core/router/app_router.dart` — add `/post` route - -## 2. Post Tap Navigation - -**Status:** Broken — tapping a post body does nothing. - -**Problem:** `PostCard` is not wrapped in a tap detector. The only interactive elements -are the action bar buttons and embedded media. Users expect tapping a post to open it. +**Problem:** The AppBar has both a "Save Draft" button (line 502) and a "Cancel" button +(line 497). If the user taps "Save Draft" → draft is saved and a snackbar confirms it. +If the user then taps "Cancel", `_handleBackNavigation` (line 426) checks `hasContent` +(line 430), which is still `true` because the text/media haven't been cleared. The user +is shown a "Save Draft?" dialog even though the draft was just saved moments ago. **Fix:** -- Wrap the post content area (header + text + embed, excluding the action bar) in an - `InkWell` that navigates to `/post?uri={postUri}`. -- The action bar itself should NOT trigger navigation — only the content area above it. +- Track whether the current content has been saved since the last edit. Add a + `isDraftDirty` (or `hasUnsavedChanges`) flag to `ComposeState`. +- Set `isDraftDirty: true` when text or media changes (`_onTextChanged`, + `_onMediaChanged`, etc.). +- Set `isDraftDirty: false` after a successful `DraftSaved` event. +- In `_handleBackNavigation`, check `isDraftDirty` instead of (or in addition to) + `hasContent`. If content exists but `isDraftDirty` is `false`, skip the dialog and + pop immediately. **Files:** -- Edit: `lib/features/feed/presentation/widgets/post_card.dart` — wrap content in - `InkWell` with navigation callback -- Edit: `lib/features/feed/presentation/widgets/post_card_with_actions.dart` — pass - `onTap` callback through to `PostCard` - -## 3. Avatar Tap Navigation - -**Status:** Broken — tapping a post author's avatar does nothing. +- Edit: `lib/features/compose/bloc/compose_state.dart` — add `isDraftDirty` field + (default `true` for new compositions, `false` after draft load) +- Edit: `lib/features/compose/bloc/compose_bloc.dart` — set `isDraftDirty: true` in + `_onTextChanged` (line 62) and media-change handlers; set `isDraftDirty: false` in + `_onDraftSaved` (line 214) and `_onDraftLoaded` (line 241) +- Edit: `lib/features/compose/presentation/compose_screen.dart` — + `_handleBackNavigation` (line 426): gate the dialog on `state.isDraftDirty` rather + than just `hasContent` -**Problem:** The avatar `CircleAvatar` in `PostCard._buildHeader` is not tappable. -Users expect tapping an avatar to navigate to that user's profile. +## 2. Character Counter — No Initial State -**Fix:** - -- Wrap the `CircleAvatar` in `_buildHeader` with a `GestureDetector` that navigates to - `/profile/view?actor={author.did}`. -- Requires passing the navigation callback or `BuildContext` with router access. - -**Files:** - -- Edit: `lib/features/feed/presentation/widgets/post_card.dart` — `_buildHeader` - (line ~55) - -## 4. Quoted Post Tap Navigation +**Status:** Incomplete — counter ring starts empty and invisible until the user types. -**Status:** Incorrect — tapping a quoted post navigates to the quoted author's profile -instead of the quoted post. +**Problem:** `_CharCounter` (compose_screen.dart, line 895) only shows the remaining +character count text when `count > 0` (line 918). On an empty compose screen the user +sees a bare progress ring at 0% with no text — there is no indication of the 300-character +limit. When loading a draft, the counter jumps from nothing to whatever the draft's count +is, which feels jarring. -**Problem:** `PostCard._buildQuotedRecord` (line 342-346) navigates to -`/profile/view?actor={quoted.author.did}`. It should open the quoted post in the thread -screen. +The progress ring itself also starts as just the background circle with no fill, giving +no visual cue about what it represents. **Fix:** -- Change the `onTap` in `_buildQuotedRecord` to navigate to - `/post?uri={quoted.uri}` instead of the author's profile. +- Always show the remaining count text, even when `count == 0`. Remove the `if (count > 0)` + guard so the counter displays `300` on an empty compose screen. +- This gives users an immediate signal: "you have 300 characters" — matching the behavior + of the official Bluesky app and Twitter/X composer. **Files:** -- Edit: `lib/features/feed/presentation/widgets/post_card.dart` — `_buildQuotedRecord` - (line ~342) - -## 5. Notification Tap Navigation - -**Status:** Broken — tapping non-follow notifications crashes or does nothing (route -doesn't exist). - -**Problem:** `notification_list_item.dart:263` pushes `/post?uri=...` but the route is -undefined. This is blocked by [#1](#1-post-thread-screen). - -**Fix:** - -- Once the `/post` route exists ([#1](#1-post-thread-screen)), notification taps will - work. Verify the URI encoding is consistent (`Uri.encodeComponent` vs query param). -- Current code: `context.push('/post?uri=${Uri.encodeComponent(uri.toString())}')` -- Ensure the route handler decodes this correctly. -- For like/repost notifications, `notification.uri` may point to the *liker's record*, - not the original post. Verify that `reasonSubject` (the post that was liked) is used - instead when appropriate. - -**Files:** - -- Edit: `lib/features/notifications/presentation/widgets/notification_list_item.dart` - — `_onTap` (line ~256). May need to use `notification.reasonSubject` for like/repost - notifications instead of `notification.uri`. - -## 6. Viewer State on Own Posts - -**Status:** Broken — current user's liked/reposted/saved posts don't show as active in -the feed. - -**Problem:** `PostCardWithActions` initializes `PostActionCubit` from `viewer.like` and -`viewer.repost` (lines 35-36), which correctly reflects the API state. However: - -- After the user likes a post, scrolls away, and scrolls back, the cubit is recreated - from the stale `FeedViewPost` data (the original API response), losing the local - optimistic state. -- Saved state works correctly because `SavedPostsCubit` is global and checks the DB. - -**Fix:** - -- Maintain a lightweight in-memory cache (e.g. `Map`) in a - higher-level provider that `PostActionCubit` reads from on creation and writes to on - state changes. This way, scrolling away and back preserves the user's actions within - the session. -- Alternatively, store the `likeUri`/`repostUri` in the feed bloc state so it survives - cubit recreation. - -**Files:** - -- New or edit: A post action cache/provider (could be a simple `ChangeNotifier` or - cubit at the feed level) -- Edit: `lib/features/feed/presentation/widgets/post_card_with_actions.dart` — read - from cache on cubit creation -- Edit: `lib/features/feed/cubit/post_action_cubit.dart` — write to cache on state - changes - -## 7. Saved Posts Screen — Render Actual Posts - -**Status:** Incomplete — saved posts screen shows metadata cards, not the actual post -content. - -**Problem:** `_SavedPostCard` in `saved_posts_screen.dart` shows a generic "Saved Post" -`ListTile` with a date and action buttons. The full post JSON is stored in the DB -(`postJson` column) but is never deserialized and rendered. - -**Fix:** - -- Deserialize `savedPost.postJson` back into a `PostView` and render it with - `PostCardWithActions` (or a read-only variant). -- The "open" button should navigate to `/post?uri={postUri}` (once [#1](#1-post-thread-screen) exists). -- Keep swipe-to-dismiss for unsaving. - -**Files:** - -- Edit: `lib/features/feed/presentation/saved_posts_screen.dart` — replace - `_SavedPostCard` with actual post rendering -- The route in `_openPost` (line 186) currently uses path-style - `/post/${Uri.encodeComponent(...)}` but should use query-style - `/post?uri=${Uri.encodeComponent(...)}` to match the route definition. - -## 8. Saved Posts — Accessible from Profile - -**Status:** Incorrect location — saved posts are behind Settings, not on profiles. - -**Problem:** The saved posts link is in `settings_screen.dart` (line 56-61). The -requirement is that it should be accessible from profiles. - -**Fix:** - -- Add a "Saved Posts" button/tab on the current user's own profile screen. -- Keep (or remove) the Settings entry as a secondary access point. - -**Files:** - -- Edit: profile screen (add saved posts navigation for the current user's profile) -- Optionally edit: `lib/features/settings/presentation/settings_screen.dart` - -## 9. Saved Posts — Long Press for Local, Tap for Menu - -**Status:** Missing — only tap-to-toggle exists, no long press or menu. - -**Problem:** The bookmark button in `PostActionBar` only has `onTap` (line 82). The -requirement is: - -- **Long press** → save/unsave locally (instant, different icon color) -- **Normal press** → show menu with options: save/remove locally, save/remove from - cloud (ATProto) - -Cloud save is not yet implemented, but the menu structure should be in place. - -**Fix:** - -- Add `onLongPress` to the bookmark `_ActionButton` in `PostActionBar`. -- Long press: toggle local save immediately (current behavior), use a distinct color - (e.g. amber/gold for local saves vs primary for cloud). -- Normal press: show a bottom sheet with options: - - "Save locally" / "Remove local save" - - "Save to Bluesky" / "Remove from Bluesky" (disabled/placeholder until cloud is - implemented) -- Update `SavedPostsState` to distinguish local vs cloud saves. - -**Files:** - -- Edit: `lib/features/feed/presentation/widgets/post_action_bar.dart` — add - `onLongPress`, show menu on tap -- Edit: `lib/features/feed/cubit/saved_posts_cubit.dart` — support save type - distinction -- Edit: `lib/core/database/tables.dart` — add `saveType` column (local/cloud/both) - with migration - -## 10. Saved Posts — Show Save Counts - -**Status:** Missing — hardcoded to `0` and hidden. - -**Problem:** `PostActionBar` line 80: `count: 0` for the bookmark button. Save counts -are never fetched or displayed. - -**Fix:** - -- The Bluesky API provides `PostView.bookmarkCount` (nullable `int`). Pass this value - through to `PostActionBar` instead of the hardcoded `0`. -- Wire it up the same way `likeCount`/`repostCount` are: read from `post.bookmarkCount` - in `PostCardWithActions` and pass to `PostActionBar`. - -**Files:** - -- Edit: `lib/features/feed/presentation/widgets/post_card_with_actions.dart` — read - `post.bookmarkCount ?? 0` and pass to action bar -- Edit: `lib/features/feed/presentation/widgets/post_action_bar.dart` — use the passed - count instead of hardcoded `0` - -## 11. Saved Posts — Cloud Save via AT Protocol - -**Status:** Not implemented — "Save to Bluesky" option is disabled with "Coming soon" placeholder. - -**Problem:** The save menu in `PostActionBar._showSaveOptions()` has a disabled "Save to -Bluesky" option. The `bluesky` package already exposes a bookmark API -(`app.bsky.bookmark.*`) but it is not wired up. Currently all saves are local-only. - -**Fix:** - -- Add bookmark methods to `PostActionRepository` using the existing `_bluesky.bookmark` - service: - - `createBookmark({uri, cid})` → `_bluesky.bookmark.createBookmark(uri, cid)` - - `deleteBookmark({uri})` → `_bluesky.bookmark.deleteBookmark(uri)` - - `getBookmarks({limit, cursor})` → `_bluesky.bookmark.getBookmarks(limit, cursor)` -- Add `cloudSave` and `cloudUnsave` methods to `SavedPostsCubit`: - - Call `PostActionRepository.createBookmark` / `deleteBookmark`. - - On success, upsert the local DB row with `saveType: 'cloud'` (or `'both'` if already - saved locally). On cloud unsave, downgrade `saveType` to `'local'` if a local save - exists, or delete the row entirely. - - Use optimistic UI: update the icon immediately, revert on failure. -- Enable the "Save to Bluesky" / "Remove from Bluesky" option in - `PostActionBar._showSaveOptions()` and wire it to `SavedPostsCubit.cloudSave` / - `cloudUnsave` via a new callback. -- Distinguish cloud vs local saves visually: - - Local-only: amber/gold bookmark icon. - - Cloud (or both): primary/blue bookmark icon. - - `PostActionBar` already receives `isSaved`; extend it with a `saveType` parameter - (or similar) so the icon color reflects the save type. -- Add a one-time sync on login: call `getBookmarks` (paginated) and merge results into - the local DB so cloud saves made on other clients appear. Mark these as `saveType: - 'cloud'`. - -**Files:** - -- Edit: `lib/features/feed/data/post_action_repository.dart` — add `createBookmark`, - `deleteBookmark`, `getBookmarks` methods -- Edit: `lib/features/feed/cubit/saved_posts_cubit.dart` — add `cloudSave`, - `cloudUnsave`, `syncCloudBookmarks` methods; handle `saveType` transitions -- Edit: `lib/features/feed/presentation/widgets/post_action_bar.dart` — enable cloud - save option, accept `saveType` parameter, update icon color logic -- Edit: `lib/features/feed/presentation/widgets/post_card_with_actions.dart` — pass - `saveType` and cloud save/unsave callbacks to `PostActionBar` - -## 12. Failed Action Snackbar with Revert - -**Status:** Partially implemented — rollback works but snackbar is basic. - -**Problem:** `PostActionCubit` correctly reverts optimistic updates on failure and shows -a snackbar via `BlocListener` in `post_card_with_actions.dart` (lines 55-64). However: - -- The snackbar has no retry action button. -- There's no visual indication during the loading state (the icon just sits there while - `isLoadingLike`/`isLoadingRepost` is true). - -**Fix:** - -- Add a "Retry" `SnackBarAction` to the error snackbar. -- Show a subtle loading indicator on the action button while the network call is - in-flight (e.g., replace the icon with a small spinner, or dim it). The - `isLoadingLike`/`isLoadingRepost` fields already exist in state. - -**Files:** +- Edit: `lib/features/compose/presentation/compose_screen.dart` — `_CharCounter.build` + (line 918): remove the `if (count > 0)` condition so the remaining count is always + visible -- Edit: `lib/features/feed/presentation/widgets/post_card_with_actions.dart` — add - retry action to snackbar -- Edit: `lib/features/feed/presentation/widgets/post_action_bar.dart` — show loading - state visually on like/repost buttons +## 3. Composer Layout — Drafts Should Be Inline, Not Full-Screen -## 13. Delete Post — Remove from Feed +**Status:** UX issue — drafts open as a modal bottom sheet that covers the composer. -**Status:** Incomplete — post is deleted on the server but remains visible in the feed. +**Problem:** Tapping the drafts button (line 805) calls `_showDraftsDialog` (line 252), +which opens a `showModalBottomSheet` with a `DraggableScrollableSheet` taking 60–90% of +the screen. This obscures the compose area entirely, breaking the user's context. The +overall composer is also described as "colossal" — the full-screen layout with the modal +drafts on top makes it feel heavy. -**Problem:** `PostActionCubit.deletePost()` (line 186-193) calls the API to delete but -does not remove the post from the feed list. The deleted post card remains visible until -the user refreshes. +The desired behavior is: drafts should appear inline, sharing the screen with the +compose area, and be toggleable open/closed. **Fix:** -- After successful deletion, notify the parent feed bloc/cubit to remove the post from - its list. -- This could be done via a callback, a shared event bus, or by having the feed bloc - listen for deletion events. -- Show a confirmation snackbar: "Post deleted". +- Replace the `showModalBottomSheet` drafts dialog with an inline, collapsible drafts + panel that sits below the compose text field (or above the bottom toolbar). +- Use an `AnimatedContainer` or `ExpansionTile`-style widget that expands/collapses + when the drafts button is toggled. +- When expanded, the drafts panel should take roughly half the available space, with the + compose text field shrinking to accommodate it. The text field remains visible and + editable above. +- When collapsed, the panel is fully hidden and the compose area reclaims the space. +- Add a toggle state (e.g. `_showDrafts` boolean in the screen's `State`) controlled by + the existing drafts `IconButton` (line 804). +- Keep the same drafts list UI (ListTile with content preview, time, delete button, tap + to load) — just move it from a modal into the inline panel. **Files:** -- Edit: `lib/features/feed/cubit/post_action_cubit.dart` — emit a "deleted" state or - invoke a callback -- Edit: feed bloc/cubit — handle post removal from list -- Edit: `lib/features/feed/presentation/widgets/post_card_with_actions.dart` — wire up - deletion callback +- Edit: `lib/features/compose/presentation/compose_screen.dart`: + - Add `_showDrafts` state variable to `_ComposeScreenState` + - Replace `_showDraftsDialog()` call on the drafts button (line 805) with a + `setState(() => _showDrafts = !_showDrafts)` toggle + - Add an inline drafts panel widget between the text field / media area and the + bottom toolbar (around line 767), wrapped in an `AnimatedSize` or similar for + smooth expand/collapse + - Remove or repurpose `_showDraftsDialog()` (lines 252-374) — extract the list + content into a reusable `_DraftsPanel` widget used by the inline panel + - Fire `DraftsRequested` event when the panel is opened (same as current behavior) diff --git a/docs/TODO.md b/docs/TODO.md index 7efadaa..67fbcb8 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -6,11 +6,16 @@ ## UX +### Composer + - Odd behavior when saving drafts: can save draft via the button but hitting cancel prompts to save or discard. - Character count doesn't have "initial state" (completely full) - Composer is collosal -> We should show drafts on half the screen, with the option to toggle it closed. + +### Dev Tools + - Instead of tabs, navigating to a record through dev tools should instead show - A drawer on Tablet - Cards (stacked) on Mobile, with swipe to go back diff --git a/lib/features/compose/bloc/compose_bloc.dart b/lib/features/compose/bloc/compose_bloc.dart index 19a73a2..0038e67 100644 --- a/lib/features/compose/bloc/compose_bloc.dart +++ b/lib/features/compose/bloc/compose_bloc.dart @@ -72,6 +72,7 @@ class ComposeBloc extends Bloc { isOverLimit: isOverLimit, isEmpty: isEmpty, canSubmit: !isOverLimit && !isEmpty && !(state.videoAttachment?.isActive ?? false), + isDraftDirty: true, ), ); } @@ -83,7 +84,7 @@ class ComposeBloc extends Bloc { ..add(MediaAttachment(localPath: event.path, width: event.width, height: event.height)); final isEmpty = state.text.trim().isEmpty && attachments.isEmpty; - emit(state.copyWith(mediaAttachments: attachments, isEmpty: isEmpty, canSubmit: !state.isOverLimit && !isEmpty)); + emit(state.copyWith(mediaAttachments: attachments, isEmpty: isEmpty, canSubmit: !state.isOverLimit && !isEmpty, isDraftDirty: true)); } Future _onMediaRemoved(MediaRemoved event, Emitter emit) async { @@ -92,7 +93,7 @@ class ComposeBloc extends Bloc { final attachments = List.from(state.mediaAttachments)..removeAt(event.index); final isEmpty = state.text.trim().isEmpty && attachments.isEmpty && state.videoAttachment == null; - emit(state.copyWith(mediaAttachments: attachments, isEmpty: isEmpty, canSubmit: !state.isOverLimit && !isEmpty)); + emit(state.copyWith(mediaAttachments: attachments, isEmpty: isEmpty, canSubmit: !state.isOverLimit && !isEmpty, isDraftDirty: true)); } Future _onAltTextUpdated(AltTextUpdated event, Emitter emit) async { @@ -231,7 +232,7 @@ class ComposeBloc extends Bloc { updatedAt: Value(DateTime.now()), ); final id = await _database.saveDraft(draft); - emit(state.copyWith(draftId: id, isSavingDraft: false)); + emit(state.copyWith(draftId: id, isSavingDraft: false, isDraftDirty: false)); } catch (e, stackTrace) { log.e('Failed to save draft', error: e, stackTrace: stackTrace); emit(state.copyWith(isSavingDraft: false)); @@ -292,6 +293,7 @@ class ComposeBloc extends Bloc { replyParentCid: draft.replyCid, replyRootUri: draft.rootUri, replyRootCid: draft.rootCid, + isDraftDirty: false, ).copyWith( graphemeCount: graphemeCount, isOverLimit: isOverLimit, diff --git a/lib/features/compose/bloc/compose_state.dart b/lib/features/compose/bloc/compose_state.dart index 6887662..934c08f 100644 --- a/lib/features/compose/bloc/compose_state.dart +++ b/lib/features/compose/bloc/compose_state.dart @@ -81,6 +81,7 @@ class ComposeState extends Equatable { this.isLoadingDrafts = false, this.canSubmit = false, this.videoAttachment, + this.isDraftDirty = true, }); const ComposeState.initial() : this._(status: ComposeStatus.initial); @@ -100,6 +101,7 @@ class ComposeState extends Equatable { String? quoteUri, String? quoteCid, VideoAttachment? videoAttachment, + bool isDraftDirty = true, }) : this._( status: ComposeStatus.ready, text: text, @@ -117,6 +119,7 @@ class ComposeState extends Equatable { quoteCid: quoteCid, videoAttachment: videoAttachment, canSubmit: !isOverLimit && !isEmpty, + isDraftDirty: isDraftDirty, ); final ComposeStatus status; @@ -139,6 +142,7 @@ class ComposeState extends Equatable { final bool isLoadingDrafts; final bool canSubmit; final VideoAttachment? videoAttachment; + final bool isDraftDirty; bool get isSubmitting => status == ComposeStatus.submitting; bool get hasError => status == ComposeStatus.error; @@ -173,6 +177,7 @@ class ComposeState extends Equatable { bool? isLoadingDrafts, bool? canSubmit, Object? videoAttachment = const _Undefined(), + bool? isDraftDirty, }) { return ComposeState._( status: status ?? this.status, @@ -195,6 +200,7 @@ class ComposeState extends Equatable { isLoadingDrafts: isLoadingDrafts ?? this.isLoadingDrafts, canSubmit: canSubmit ?? this.canSubmit, videoAttachment: videoAttachment is _Undefined ? this.videoAttachment : videoAttachment as VideoAttachment?, + isDraftDirty: isDraftDirty ?? this.isDraftDirty, ); } @@ -220,6 +226,7 @@ class ComposeState extends Equatable { isLoadingDrafts, canSubmit, videoAttachment, + isDraftDirty, ]; } diff --git a/lib/features/compose/presentation/compose_screen.dart b/lib/features/compose/presentation/compose_screen.dart index 44f3873..049a63f 100644 --- a/lib/features/compose/presentation/compose_screen.dart +++ b/lib/features/compose/presentation/compose_screen.dart @@ -429,7 +429,7 @@ class _ComposeScreenState extends State { final hasContent = state.text.trim().isNotEmpty || state.mediaAttachments.isNotEmpty; - if (hasContent) { + if (hasContent && state.isDraftDirty) { showDialog( context: context, builder: (dialogContext) => AlertDialog( diff --git a/pubspec.lock b/pubspec.lock index 7a9811f..f74f08a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -849,7 +849,7 @@ packages: source: hosted version: "3.1.6" plugin_platform_interface: - dependency: transitive + dependency: "direct main" description: name: plugin_platform_interface sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" @@ -1134,7 +1134,7 @@ packages: source: hosted version: "3.2.5" url_launcher_platform_interface: - dependency: transitive + dependency: "direct main" description: name: url_launcher_platform_interface sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" diff --git a/pubspec.yaml b/pubspec.yaml index 2ce8131..17b5731 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,8 @@ dependencies: cross_file: ^0.3.4+2 characters: ^1.4.0 workmanager: ^0.5.2 + plugin_platform_interface: ^2.1.8 + url_launcher_platform_interface: ^2.3.2 dev_dependencies: flutter_test: diff --git a/test/features/compose/bloc/compose_bloc_test.dart b/test/features/compose/bloc/compose_bloc_test.dart index 3827d12..b3c64ac 100644 --- a/test/features/compose/bloc/compose_bloc_test.dart +++ b/test/features/compose/bloc/compose_bloc_test.dart @@ -58,6 +58,80 @@ void main() { expect(composeBloc.state.canSubmit, false); }); + group('isDraftDirty', () { + test('initial state has isDraftDirty true', () { + expect(composeBloc.state.isDraftDirty, true); + }); + + blocTest( + 'isDraftDirty is true after text changed', + build: () => composeBloc, + seed: () => const ComposeState.ready(isDraftDirty: false), + act: (bloc) => bloc.add(const TextChanged('Hello')), + expect: () => [isA().having((s) => s.isDraftDirty, 'isDraftDirty', true)], + ); + + blocTest( + 'isDraftDirty is true after media attached', + build: () => composeBloc, + seed: () => const ComposeState.ready(isDraftDirty: false), + act: (bloc) => bloc.add(const MediaAttached('/path/to/image.jpg')), + expect: () => [isA().having((s) => s.isDraftDirty, 'isDraftDirty', true)], + ); + + blocTest( + 'isDraftDirty is true after media removed', + build: () => composeBloc, + seed: () => const ComposeState.ready( + mediaAttachments: [MediaAttachment(localPath: '/1.jpg')], + isDraftDirty: false, + ), + act: (bloc) => bloc.add(const MediaRemoved(0)), + expect: () => [isA().having((s) => s.isDraftDirty, 'isDraftDirty', true)], + ); + + blocTest( + 'isDraftDirty is false after draft saved successfully', + build: () { + when(() => mockDatabase.saveDraft(any())).thenAnswer((_) async => 1); + return composeBloc; + }, + seed: () => const ComposeState.ready(text: 'Hello', isDraftDirty: true), + act: (bloc) => bloc.add(const DraftSaved()), + expect: () => [ + isA().having((s) => s.isSavingDraft, 'isSavingDraft', true), + isA().having((s) => s.isDraftDirty, 'isDraftDirty', false), + ], + ); + + blocTest( + 'isDraftDirty is false after draft loaded', + build: () { + when(() => mockDatabase.getDraft(1)).thenAnswer((_) async => _makeDraft(content: 'Draft text')); + return composeBloc; + }, + act: (bloc) => bloc.add(const DraftLoaded(1)), + expect: () => [isA().having((s) => s.isDraftDirty, 'isDraftDirty', false)], + ); + + blocTest( + 'isDraftDirty becomes true after editing a loaded draft', + build: () { + when(() => mockDatabase.getDraft(1)).thenAnswer((_) async => _makeDraft(content: 'Draft text')); + return composeBloc; + }, + act: (bloc) async { + bloc.add(const DraftLoaded(1)); + await Future.delayed(Duration.zero); + bloc.add(const TextChanged('Draft text edited')); + }, + expect: () => [ + isA().having((s) => s.isDraftDirty, 'isDraftDirty', false), + isA().having((s) => s.isDraftDirty, 'isDraftDirty', true), + ], + ); + }); + group('TextChanged', () { blocTest( 'emits correct state when text changes', diff --git a/test/features/settings/presentation/about_screen_test.dart b/test/features/settings/presentation/about_screen_test.dart new file mode 100644 index 0000000..5fa50cd --- /dev/null +++ b/test/features/settings/presentation/about_screen_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/settings/presentation/about_screen.dart'; +import 'package:url_launcher_platform_interface/link.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +class _FakeUrlLauncher extends Fake with MockPlatformInterfaceMixin implements UrlLauncherPlatform { + final List launchedUrls = []; + + @override + LinkDelegate? get linkDelegate => null; + + @override + Future launchUrl(String url, LaunchOptions options) async { + launchedUrls.add(url); + return true; + } + + @override + Future supportsMode(PreferredLaunchMode mode) async => true; + + @override + Future canLaunch(String url) async => true; +} + +void main() { + late _FakeUrlLauncher fakeUrlLauncher; + + setUp(() { + fakeUrlLauncher = _FakeUrlLauncher(); + UrlLauncherPlatform.instance = fakeUrlLauncher; + }); + + Widget buildSubject() => const MaterialApp(home: AboutScreen()); + + group('AboutScreen', () { + testWidgets('renders app bar with About title', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + expect(find.text('About'), findsAtLeastNWidgets(1)); + }); + + testWidgets('renders Lazurite headline', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + expect(find.text('Lazurite'), findsOneWidget); + }); + + testWidgets('renders Stormlight Labs description', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + expect(find.textContaining('Stormlight Labs'), findsAtLeastNWidgets(1)); + }); + + testWidgets('renders Owais as a tappable link', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + expect(find.text('Owais'), findsOneWidget); + }); + + testWidgets('renders GitHub and contribution text', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + expect(find.textContaining('GitHub'), findsAtLeastNWidgets(1)); + }); + + testWidgets('renders version string', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + expect(find.textContaining('Lazurite v'), findsOneWidget); + }); + + testWidgets('renders email icon', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + expect(find.byIcon(Icons.email_outlined), findsOneWidget); + }); + + testWidgets('tapping Owais launches LinkedIn URL', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + await tester.tap(find.text('Owais')); + await tester.pump(); + + expect(fakeUrlLauncher.launchedUrls, contains('https://linkedin.com/in/owais-jamil')); + }); + + testWidgets('tapping email icon launches mailto URL', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.email_outlined)); + await tester.pump(); + + expect(fakeUrlLauncher.launchedUrls, contains('mailto:info@stormlightlabs.org')); + }); + + testWidgets('renders three link icons', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + // GitHub SVG, Tangled SVG, email icon — wrapped in _LinkIcon (InkWell) + expect(find.byType(InkWell), findsNWidgets(3)); + }); + }); +}