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.

refactor: nav/haptics helpers

+291 -327
-7
docs/BUGS.md
··· 1 - --- 2 - title: Bugs & Inconsistencies 3 - updated: 2026-03-18 4 - --- 5 - 6 - 7 - Saved posts should be a tabbed view for local & ATProto/BSky saved posts.
+1
docs/TODO.md
··· 16 16 link to dev tools and follow audits. 17 17 - Constellation URL should remain configurable internally but the option to change the 18 18 URL should be removed from the UI. 19 + - Saved posts should be a tabbed view for local & ATProto/BSky saved posts. 19 20 20 21 ## UX 21 22
-60
docs/copywith.md
··· 1 - # Nullable `copyWith` Rule 2 - 3 - ## Problem 4 - 5 - A common state bug occurs when `copyWith` uses `field ?? this.field` for nullable fields. 6 - That pattern cannot distinguish: 7 - 8 - - "keep current value" 9 - - "set this field to null" 10 - 11 - This breaks flows where `null` is meaningful (for example clearing cursors, `likeUri`, `repostUri`, or `errorMessage`). 12 - 13 - ## Required Pattern 14 - 15 - For nullable fields in immutable state objects, use a sentinel parameter default: 16 - 17 - ```dart 18 - static const Object _unset = Object(); 19 - 20 - State copyWith({ 21 - Object? nullableField = _unset, 22 - }) { 23 - return State( 24 - nullableField: identical(nullableField, _unset) 25 - ? this.nullableField 26 - : nullableField as String?, 27 - ); 28 - } 29 - ``` 30 - 31 - ## Where Applied 32 - 33 - - `SearchState.copyWith` (cursor and nullable metadata fields) 34 - - `FeedState.copyWith` (cursor and error fields) 35 - - `PostActionState.copyWith` (`likeUri`, `repostUri`, `error`) 36 - - `MessageState.copyWith` (`cursor`, `convoId`, `errorMessage`) 37 - - `ConvoListState.copyWith` (`cursor`, `errorMessage`) 38 - - `NotificationState.copyWith` (`cursor`, `errorMessage`) 39 - - `AuthState.copyWith` (`tokens`, `errorMessage`) 40 - - `AccountSwitcherState.copyWith` (`activeDid`) 41 - - `ProfileState.copyWith` (`profile`, `errorMessage`) 42 - - `AddToListState.copyWith` (`targetDid`, `errorMessage`) 43 - 44 - ## Already Using Sentinel Pattern 45 - 46 - - `ComposeState` / `VideoAttachment` / `MediaAttachment` 47 - - `ListState` 48 - - `ListFeedState` 49 - - `MyListsState` 50 - - `ActorStarterPacksState` 51 - - `StarterPackState` 52 - - `DevToolsState` 53 - - `LogViewerState` 54 - - `ProfileContextState` 55 - - `SettingsState` (for `threadAutoCollapseDepth`) 56 - 57 - ## Review Checklist 58 - 59 - - If a field is nullable and needs to be clearable, do not use `??` in `copyWith`. 60 - - Add/keep tests that assert nullable fields can be explicitly cleared to `null`.
+123
docs/dev/patterns.md
··· 1 + # Lazurite Code Patterns 2 + 3 + This document captures recurring architectural and implementation patterns in the Lazurite codebase. 4 + 5 + ## Code Organization 6 + 7 + ```sh 8 + lib/ 9 + ├── core/ # cross-cutting app/platform concerns (router, theme, logging, network, database) 10 + ├── features/ # feature-first modules 11 + │ └── {feature} # one vertical slice per domain 12 + │ ├── data/ # repositories, DTO mapping, persistence/network access 13 + │ ├── bloc or cubit/ # state orchestration and business flow 14 + │ └── presentation/ # screens, widgets, and view-specific helpers 15 + └── shared/ # reusable widgets/helpers/utils not tied to one feature 16 + test/ # mirrors production layout (core/features/shared) for unit/widget tests 17 + docs/ # specs, tasks, operational docs, and developer references 18 + ``` 19 + 20 + ## State and UI Patterns 21 + 22 + - Prefer immutable `Equatable` state with explicit `copyWith` semantics. 23 + - Model state transitions in `Bloc`/`Cubit` layers; keep presentation widgets focused on rendering and user interactions. 24 + - Use `sealed class` events and typed state classes to keep transitions explicit and testable. 25 + - For repeated UI states, use shared widgets instead of ad-hoc `Center(...)` blocks: 26 + - `LoadingState` 27 + - `ErrorState` 28 + - `EmptyState` 29 + 30 + ## Extracted Reuse Patterns 31 + 32 + ### Utilities 33 + 34 + `lib/shared/utils/format_utils.dart` is the canonical source for display primitives (initials, compact counts, relative time labels). 35 + The extraction replaced repeated local formatters from feed/profile/search/notification surfaces with one tested implementation (`test/shared/utils/format_utils_test.dart`). 36 + 37 + ### Dialogs, Sheets, and Snackbars 38 + 39 + Transient UI interaction patterns were consolidated into shared helpers and widgets: 40 + 41 + - `showConfirmationDialog` for confirm/cancel flows 42 + - `showOptionsSheet` for action menus 43 + - `showAppSnackBar` for user feedback 44 + 45 + This keeps behavior and semantics consistent across features and is covered with focused widget tests in `test/shared/presentation/...`. 46 + 47 + ### Theme Access and Tokens 48 + 49 + Theme access should go through `lib/core/theme/theme_extensions.dart` (`context.theme`, `context.colorScheme`, `context.textTheme`) rather than repeated `Theme.of(...)` lookups. 50 + 51 + For layout rhythm, reuse spacing tokens from `lib/core/theme/spacing.dart`. 52 + 53 + For visual consistency in image treatment, use shared filters from `lib/core/theme/color_filters.dart`. 54 + 55 + ### Reusable Presentation Widgets 56 + 57 + Repeated avatar/name/icon logic from the audit was extracted into shared primitives. Prefer: 58 + 59 + - `ProfileAvatar` (`lib/shared/presentation/widgets/profile_avatar.dart`) for all profile/list/feed avatar rendering, including fallback and moderation-aware masking behavior. 60 + - `ActorNameWidget` (`lib/shared/presentation/widgets/actor_name_widget.dart`) for standardized display-name + handle rows (used in feed cards, embedded records, and conversation items). 61 + - `NotificationIconMapper` (`lib/shared/presentation/helpers/notification_icon_mapper.dart`) for mapping notification reason to icon + color style, instead of local switch blocks. 62 + 63 + ### Navigation and Haptics 64 + 65 + Navigation/haptic side effects also follow shared helper entry points. 66 + Use `navigateToProfile` and `navigateToPost` for common entity navigation, and route tactile feedback through `HapticHelper` instead of direct `HapticFeedback` calls in feature widgets. 67 + 68 + ## Other Recurring Codebase Patterns 69 + 70 + - Feature-first vertical slices under `lib/features/{feature}/{data,bloc|cubit,presentation}` keep business logic close to owning UI while preserving cross-feature boundaries. 71 + - Dependency injection is explicit at composition boundaries (`RepositoryProvider`/`BlocProvider` in app and screen roots), which makes screens and blocs easy to test in isolation. 72 + - Repository interfaces return typed results (often with cursor pagination) instead of raw maps, keeping API edges explicit and testable. 73 + - Defensive fallback behavior is preferred for unstable integrations; for example, repository logic that falls back on alternative endpoints when one API path fails is covered by contract tests. 74 + - Shared moderation-aware rendering is treated as a first-class cross-feature concern (`ModerationUI`, `ModerationBadgeRow`, `ModeratedBlurOverlay`, `ProfileAvatar` moderation hooks). 75 + - Use fully-qualified package imports (`package:lazurite/...`) for internal modules. 76 + 77 + ## Moderation-Aware Rendering Pattern 78 + 79 + - For actor/content media that can be moderated, request moderation UI state via moderation service helpers and pass it into rendering widgets. 80 + - `ProfileAvatar` supports moderated fallback/masking through `ModerationUI`. 81 + - Compose moderation badges/overlays (`ModerationBadgeRow`, `ModeratedBlurOverlay`) at feature widget boundaries. 82 + 83 + ## Nullable `copyWith` Pattern (Required) 84 + 85 + For nullable fields in immutable state, **do not** use `field ?? this.field` when `null` is a meaningful explicit update. 86 + 87 + Use a sentinel default to distinguish: 88 + 89 + - keep current value 90 + - set value to `null` 91 + 92 + ```dart 93 + static const Object _unset = Object(); 94 + 95 + State copyWith({ 96 + Object? nullableField = _unset, 97 + }) { 98 + return State( 99 + nullableField: identical(nullableField, _unset) 100 + ? this.nullableField 101 + : nullableField as String?, 102 + ); 103 + } 104 + ``` 105 + 106 + ### Review Checklist 107 + 108 + - For clearable nullable fields, avoid `??` fallback in `copyWith`. 109 + - Add tests proving nullable fields can be explicitly cleared. 110 + 111 + ## Testing Patterns 112 + 113 + - Per-file harness builders are standard (`buildSubject(...)`, plus helpers like `openSheet(...)`) so test bodies focus on behavior, not setup. 114 + - Widget tests usually mount through `MaterialApp`/`Scaffold` for component tests, and `MaterialApp.router` + `GoRouter` for navigation tests. 115 + - Interaction flow follows a consistent shape: 116 + `pumpWidget` -> input (`tap`, `enterText`) -> `pump`/`pumpAndSettle` -> assertions on visible UI and side effects. 117 + - BLoC/Cubit widget tests rely on `mocktail` + `bloc_test` (`MockBloc`, `MockCubit`, `when`, `whenListen`, `verify`, `verifyNever`), with `registerFallbackValue` in `setUpAll` when needed. 118 + - Router behavior is verified by capturing the pushed route URI and asserting path/query params 119 + - Complex service/repository tests use fit-for-purpose doubles: 120 + - `MockClient` from `http/testing.dart` for HTTP-level contracts 121 + - Small local fake implementations when protocol surfaces are complex 122 + - Async UI timing is made explicit where animations/debounce are relevant (`pump(const Duration(...))` in sheet/search tests), and responsive behavior is exercised with `setSurfaceSize` in router/shell tests. 123 + - Defensive assertions are common for robustness: boundary-value checks in utility tests, null/empty/error-path repository tests, and `expect(tester.takeException(), isNull)` guards in navigation/router tests.
docs/smoke-test.md docs/dev/smoke-test.md
-155
docs/specs/testing.md
··· 1 - # Testing Strategy 2 - 3 - Audit of the presentation layer and evaluation of visual testing tooling to 4 - improve test coverage, reduce code duplication, and establish a golden testing 5 - baseline. 6 - 7 - ## Presentation Layer Audit 8 - 9 - A full audit of `lib/features/*/presentation/` identified significant 10 - duplication across 40+ files. The findings group into ten categories. 11 - 12 - ### 1. Duplicated Utility Functions 13 - 14 - | Function | Pattern | Occurrences | 15 - | ----------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | 16 - | `_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) | 17 - | `_formatCount(int)` | Formats numbers with K/M suffixes | 5 files (post_action_bar, post_card_footer, starter_pack_card, starter_pack_detail, profile_screen) | 18 - | `_formatTime(DateTime)` | Relative time strings ("2h ago") | 4 files (notification items, search, hashtag) - overlaps with `formatPostTime()` in post_card_footer | 19 - 20 - **Target**: Extract to `lib/shared/utils/format_utils.dart`. 21 - 22 - ### 2. Duplicated Widgets 23 - 24 - **Avatar display** - 14 files repeat `CircleAvatar` / `ModeratedAvatar` with 25 - identical styling. Extract to a configurable `ProfileAvatar` widget. 26 - 27 - **Author name + handle** - Repeated two-line text widget (displayName / @handle) 28 - in post_card, grid_post_card, post_embed_view, convo_list_item. Extract to 29 - `ActorNameWidget`. 30 - 31 - **Greyscale color filter** - Identical 4x5 color matrix defined in 32 - grid_post_card and profile_screen. Extract to `lib/core/theme/color_filters.dart`. 33 - 34 - **Notification reason icon** - Large switch statement mapping notification 35 - reasons to icons/colors duplicated identically in notification_list_item and 36 - grouped_notification_list_item. Extract to `NotificationIconMapper`. 37 - 38 - ### 3. Bottom Sheets & Dialogs 39 - 40 - **Modal bottom sheets** - 9 files repeat `showModalBottomSheet` with ListTile 41 - option lists. Create `OptionsSheet` builder. 42 - 43 - **Confirmation dialogs** - 26 occurrences of AlertDialog with 44 - title/content/cancel/confirm. Create `ConfirmationDialog(title, content, 45 - confirmLabel, onConfirm)`. 46 - 47 - ### 4. State Handling Patterns 48 - 49 - **Loading** - `Center(child: CircularProgressIndicator())` in 8+ screens. 50 - **Error with retry** - Center + error message + retry button in 8+ screens. 51 - **Empty state** - Center + message + optional action in multiple screens. 52 - 53 - Create `LoadingState`, `ErrorState(message, onRetry)`, `EmptyState(message, 54 - icon, action)` widgets in `lib/shared/presentation/widgets/`. 55 - 56 - ### 5. SnackBar Display 57 - 58 - `ScaffoldMessenger.of(context).showSnackBar(SnackBar(..., behavior: 59 - floating))` appears in 14 files. Create `showAppSnackBar(context, message, 60 - {isError})` helper. 61 - 62 - ### 6. Theme Access Boilerplate 63 - 64 - `Theme.of(context).colorScheme.*` appears 142 times across 27 files. 65 - `Border.all(color: colorScheme.outlineVariant)` is the most common repeated 66 - decoration. Consider a `BuildContext` extension: 67 - 68 - ```dart 69 - extension ThemeX on BuildContext { 70 - ColorScheme get colorScheme => Theme.of(this).colorScheme; 71 - } 72 - ``` 73 - 74 - ### 7. Spacing Constants 75 - 76 - `EdgeInsets.symmetric(horizontal: 16)` appears 34+ times. `SizedBox(height: 8)` 77 - and variants repeat throughout. Define constants in 78 - `lib/core/theme/spacing.dart`. 79 - 80 - ### 8. Navigation Helpers 81 - 82 - Profile navigation (`GoRouter.maybeOf(context)?.push('/profile/view?actor=...')`) 83 - repeated across post cards and notification items. Extract to route helper 84 - functions. 85 - 86 - ### 9. Haptic Feedback 87 - 88 - 17 occurrences of `HapticFeedback.mediumImpact()` across 6 files. Minor, but a 89 - `HapticHelper` would centralize. 90 - 91 - ### 10. List Item Tiles 92 - 93 - 23 occurrences of ListTile with avatar/title/subtitle across features. A 94 - `BaseListItemTile` could reduce this, though diversity of layouts may limit 95 - reuse. 96 - 97 - ## Visual Testing Evaluation 98 - 99 - ### Current State 100 - 101 - - 122 test files across 16 feature modules 102 - - Stack: `flutter_test`, `bloc_test`, `mocktail` 103 - - Test types: unit (cubits, repos, services) + widget (screens, widgets) 104 - - **No golden tests, no integration tests, no visual regression testing** 105 - 106 - ### Widgetbook 107 - 108 - Component catalog + visual testing platform for Flutter (v3.22.0 stable, 109 - v4.0.0-beta.3). 110 - 111 - | Capability | Detail | 112 - | ----------------- | --------------------------------------------------------- | 113 - | Component catalog | Render widgets in isolation with configurable knobs | 114 - | Visual regression | `widgetbook_golden_test` generates goldens from use cases | 115 - | Widgetbook Cloud | CI visual diffs, platform-independent rendering (paid) | 116 - | Addons | Multi-theme, locale, text scale, device frame testing | 117 - 118 - **Verdict: Not recommended for Lazurite at this time.** 119 - 120 - Reasons: 121 - 122 - - Requires building/maintaining a separate Widgetbook app with use-case 123 - definitions for every widget - high overhead for a small team 124 - - The project's gap is golden tests and integration tests, not a design catalog 125 - - No dedicated designer reviewing components, so collaborative review value is 126 - unrealized 127 - - Converting 122 existing widget tests into Widgetbook use cases is low ROI 128 - 129 - **When to reconsider**: If a shared design system emerges, a designer joins, or 130 - PR visual review becomes a bottleneck. 131 - 132 - ### Recommended Approach 133 - 134 - **Golden Toolkit** (`golden_toolkit` package) - add visual regression to 135 - existing widget tests with minimal overhead: 136 - 137 - - One or two lines per existing test to capture golden snapshots 138 - - No separate app to maintain 139 - - Works with existing `pumpWidget` patterns 140 - - `multiScreenGolden` for multi-device-size snapshots 141 - 142 - **Patrol** - consider later if native feature testing (permissions, deep links) 143 - becomes important. 144 - 145 - **Built-in integration tests** - add end-to-end flow tests using 146 - `integration_test` package for critical paths (compose, auth, navigation). 147 - 148 - ### Platform Rendering Note 149 - 150 - Golden tests produce platform-dependent pixel output (macOS dev vs Linux CI). 151 - Mitigations: 152 - 153 - - Tolerance thresholds in comparisons 154 - - CI-only golden generation with committed baselines 155 - - Or Widgetbook Cloud (if budget allows) for platform-independent rendering
-75
docs/tasks/testing.md
··· 1 - # Testing Milestones 2 - 3 - ## M0 - Shared Utilities Extraction 4 - 5 - - [x] Create `lib/shared/utils/format_utils.dart` with `formatInitials`, `formatCount`, `formatRelativeTime` 6 - - [x] Replace `_initials` 7 - - [x] Replace `_formatCount` 8 - - [x] Consolidate `_formatTime` with existing `formatPostTime` in `post_card_footer.dart` 9 - - [x] Unit tests for all format functions (edge cases: empty string, zero, negative, boundary values) 10 - 11 - ## M1 - Shared State Widgets 12 - 13 - - [x] Create `lib/shared/presentation/widgets/loading_state.dart` 14 - - [x] Create `lib/shared/presentation/widgets/error_state.dart` - `ErrorState(message, onRetry)` 15 - - [x] Create `lib/shared/presentation/widgets/empty_state.dart` - `EmptyState(message, icon, action)` 16 - - [x] Replace loading/error/empty states across the app with new widgets 17 - - [x] Widget tests for each state widget 18 - 19 - ## M2 - Dialog & Sheet Consolidation 20 - 21 - - [x] Create `lib/shared/presentation/widgets/confirmation_dialog.dart` 22 - - [x] Create `lib/shared/presentation/widgets/options_sheet.dart` 23 - - [x] Create `lib/shared/presentation/helpers/snackbar_helper.dart` - `showAppSnackBar` 24 - - [x] Replace confirmation dialogs 25 - - [x] Replace modal bottom sheets 26 - - [x] Replace SnackBar patterns 27 - - [x] Tests for dialog/sheet/snackbar helpers 28 - 29 - ## M3 - Theme & Spacing Constants 30 - 31 - - [x] Create `lib/core/theme/theme_extensions.dart` - `BuildContext` extension for `colorScheme` access 32 - - [x] Create `lib/core/theme/spacing.dart` with padding/margin constants 33 - - [x] Create `lib/core/theme/color_filters.dart` - extract greyscale matrix 34 - - [x] Refactor files to use new constants/extensions 35 - - [x] Tests for theme extension 36 - 37 - ## M4 - Widget Extraction 38 - 39 - - [x] Create `lib/shared/presentation/widgets/profile_avatar.dart` (configurable size, shape, fallback) 40 - - [x] Create `lib/shared/presentation/widgets/actor_name_widget.dart` (displayName + handle) 41 - - [x] Create `lib/shared/presentation/helpers/notification_icon_mapper.dart` 42 - - [x] Replace avatar patterns 43 - - [x] Replace author name patterns 44 - - [x] Replace notification icon switch 45 - - [x] Widget tests for extracted widgets 46 - 47 - ## M5 - Navigation & Haptics Helpers 48 - 49 - - [ ] Create `lib/shared/presentation/helpers/navigation_helpers.dart` (`navigateToProfile`, `navigateToPost`) 50 - - [ ] Create `lib/shared/presentation/helpers/haptic_helper.dart` 51 - - [ ] Replace navigation patterns in: 52 - - `lib/features/feed/presentation/widgets/post_card.dart` 53 - - `lib/features/feed/presentation/widgets/grid_post_card.dart` 54 - - `lib/features/notifications/presentation/widgets/grouped_notification_list_item.dart` 55 - - [ ] Replace haptic feedback call sites (17 occurrences across 6 files) 56 - - [ ] Tests for navigation helpers 57 - 58 - ## M6 - Golden Testing Setup 59 - 60 - - [ ] Add `golden_toolkit` to dev_dependencies 61 - - [ ] Configure golden test threshold for CI tolerance 62 - - [ ] Add golden tests for shared widgets (M1-M4 extractions) 63 - - [ ] Add golden tests for post card variants (linear, grid) 64 - - [ ] Add golden tests for profile screen states 65 - - [ ] Add multi-device-size goldens for key screens 66 - - [ ] CI pipeline step for golden test comparison 67 - - [ ] Document golden update workflow (`flutter test --update-goldens`) 68 - 69 - ## M7 - Integration Tests 70 - 71 - - [ ] Add `integration_test` to dev_dependencies 72 - - [ ] Auth flow end-to-end test 73 - - [ ] Compose + post flow test 74 - - [ ] Navigation flow test (tab switching, drawer, profile) 75 - - [ ] CI pipeline step for integration tests
+5 -4
lib/features/feed/presentation/post_thread_screen.dart
··· 29 29 import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 30 30 import 'package:lazurite/features/profile/presentation/widgets/report_dialog.dart'; 31 31 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 32 + import 'package:lazurite/shared/presentation/helpers/haptic_helper.dart'; 32 33 import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 33 34 import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 34 35 import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; ··· 793 794 } 794 795 795 796 void _onReply(BuildContext context) { 796 - HapticFeedback.selectionClick(); 797 + HapticHelper.selectionClick(); 797 798 final post = thread.post; 798 799 final root = _findRoot(); 799 800 ··· 810 811 } 811 812 812 813 void _onQuote(BuildContext context) { 813 - HapticFeedback.selectionClick(); 814 + HapticHelper.selectionClick(); 814 815 final post = thread.post; 815 816 816 817 context.push( ··· 823 824 final cubit = context.read<SavedPostsCubit>(); 824 825 final post = thread.post; 825 826 826 - await HapticFeedback.lightImpact(); 827 + await HapticHelper.lightImpact(); 827 828 await cubit.toggleSave(postUri: post.uri.toString(), postJson: jsonEncode(post.toJson())); 828 829 } 829 830 830 831 void _showMoreOptions(BuildContext context) { 831 - HapticFeedback.mediumImpact(); 832 + HapticHelper.mediumImpact(); 832 833 final post = thread.post; 833 834 final postUri = post.uri.toString(); 834 835 final bskyUrl = _convertAtUriToBskyUrl(postUri);
+2 -2
lib/features/feed/presentation/widgets/grid_post_card.dart
··· 4 4 import 'package:bluesky/app_bsky_feed_post.dart'; 5 5 import 'package:bluesky/moderation.dart' as bsky_moderation; 6 6 import 'package:flutter/material.dart'; 7 - import 'package:go_router/go_router.dart'; 8 7 import 'package:lazurite/core/theme/color_filters.dart'; 9 8 import 'package:lazurite/core/theme/spacing.dart'; 10 9 import 'package:lazurite/core/theme/theme_extensions.dart'; ··· 15 14 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 16 15 import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 17 16 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 17 + import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 18 18 import 'package:lazurite/shared/presentation/widgets/actor_name_widget.dart'; 19 19 import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 20 20 ··· 146 146 children: [ 147 147 GestureDetector( 148 148 key: const ValueKey('grid_post_card_avatar'), 149 - onTap: () => GoRouter.maybeOf(context)?.push('/profile/view?actor=${Uri.encodeQueryComponent(author.did)}'), 149 + onTap: () => navigateToProfile(context, author.did), 150 150 child: ProfileAvatar( 151 151 size: 40, 152 152 moderationUi: avatarUi,
+3 -3
lib/features/feed/presentation/widgets/post_action_bar.dart
··· 1 1 import 'package:flutter/material.dart'; 2 - import 'package:flutter/services.dart'; 3 2 import 'package:lazurite/core/logging/app_logger.dart'; 4 3 import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 4 + import 'package:lazurite/shared/presentation/helpers/haptic_helper.dart'; 5 5 import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; 6 6 import 'package:lazurite/shared/utils/format_utils.dart'; 7 7 import 'package:share_plus/share_plus.dart'; ··· 123 123 } 124 124 125 125 void _showRepostOptions(BuildContext context) { 126 - HapticFeedback.mediumImpact(); 126 + HapticHelper.mediumImpact(); 127 127 showOptionsSheet<void>( 128 128 context: context, 129 129 items: [ ··· 145 145 } 146 146 147 147 void _showSaveOptions(BuildContext context) { 148 - HapticFeedback.mediumImpact(); 148 + HapticHelper.mediumImpact(); 149 149 final isLocalSaved = isSaved && (saveType == 'local' || saveType == 'both'); 150 150 final isCloudSaved = saveType == 'cloud' || saveType == 'both'; 151 151 showOptionsSheet<void>(
+2 -2
lib/features/feed/presentation/widgets/post_card.dart
··· 3 3 import 'package:bluesky/app_bsky_feed_post.dart'; 4 4 import 'package:bluesky/moderation.dart' as bsky_moderation; 5 5 import 'package:flutter/material.dart'; 6 - import 'package:go_router/go_router.dart'; 7 6 import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 8 7 import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; 9 8 import 'package:lazurite/features/feed/presentation/widgets/post_embed_view.dart'; ··· 11 10 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 12 11 import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 13 12 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 13 + import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 14 14 import 'package:lazurite/shared/presentation/widgets/actor_name_widget.dart'; 15 15 import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 16 16 import 'package:lazurite/core/theme/theme_extensions.dart'; ··· 98 98 children: [ 99 99 GestureDetector( 100 100 key: const ValueKey('post_card_avatar'), 101 - onTap: () => GoRouter.maybeOf(context)?.push('/profile/view?actor=${Uri.encodeQueryComponent(author.did)}'), 101 + onTap: () => navigateToProfile(context, author.did), 102 102 child: ProfileAvatar( 103 103 size: 40, 104 104 moderationUi: avatarUi,
+2 -2
lib/features/feed/presentation/widgets/post_card_footer.dart
··· 1 1 import 'package:flutter/material.dart'; 2 - import 'package:flutter/services.dart'; 3 2 import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 3 + import 'package:lazurite/shared/presentation/helpers/haptic_helper.dart'; 4 4 import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; 5 5 import 'package:lazurite/shared/utils/format_utils.dart'; 6 6 import 'package:lazurite/core/theme/theme_extensions.dart'; ··· 177 177 } 178 178 179 179 void _showSaveOptions(BuildContext context) { 180 - HapticFeedback.mediumImpact(); 180 + HapticHelper.mediumImpact(); 181 181 final isLocalSaved = isSaved && (saveType == 'local' || saveType == 'both'); 182 182 final isCloudSaved = saveType == 'cloud' || saveType == 'both'; 183 183
+5 -5
lib/features/feed/presentation/widgets/post_card_with_actions.dart
··· 4 4 import 'package:bluesky/app_bsky_feed_defs.dart'; 5 5 import 'package:bluesky/moderation.dart' as bsky_moderation; 6 6 import 'package:flutter/material.dart'; 7 - import 'package:flutter/services.dart'; 8 7 import 'package:flutter_bloc/flutter_bloc.dart'; 9 8 import 'package:go_router/go_router.dart'; 10 9 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; ··· 16 15 import 'package:lazurite/features/feed/presentation/widgets/grid_post_card.dart'; 17 16 import 'package:lazurite/features/feed/presentation/widgets/post_card.dart'; 18 17 import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; 18 + import 'package:lazurite/shared/presentation/helpers/haptic_helper.dart'; 19 19 import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 20 20 21 21 /// Controls which card layout variant is rendered by [PostCardWithActions]. ··· 171 171 } 172 172 173 173 void _onReply(BuildContext context) { 174 - HapticFeedback.selectionClick(); 174 + HapticHelper.selectionClick(); 175 175 final post = feedViewPost.post; 176 176 final reply = feedViewPost.reply; 177 177 ··· 201 201 Future<void> _onToggleSave(BuildContext context) async { 202 202 final cubit = context.read<SavedPostsCubit>(); 203 203 final post = feedViewPost.post; 204 - await HapticFeedback.lightImpact(); 204 + await HapticHelper.lightImpact(); 205 205 await cubit.toggleSave(postUri: post.uri.toString(), postJson: jsonEncode(post.toJson())); 206 206 } 207 207 208 208 Future<void> _onCloudSave(BuildContext context) async { 209 209 final cubit = context.read<SavedPostsCubit>(); 210 210 final post = feedViewPost.post; 211 - await HapticFeedback.lightImpact(); 211 + await HapticHelper.lightImpact(); 212 212 await cubit.cloudSave(postUri: post.uri.toString(), cid: post.cid, postJson: jsonEncode(post.toJson())); 213 213 } 214 214 215 215 Future<void> _onCloudUnsave(BuildContext context) async { 216 216 final cubit = context.read<SavedPostsCubit>(); 217 217 final post = feedViewPost.post; 218 - await HapticFeedback.lightImpact(); 218 + await HapticHelper.lightImpact(); 219 219 await cubit.cloudUnsave(post.uri.toString()); 220 220 } 221 221 }
+3 -3
lib/features/notifications/presentation/widgets/grouped_notification_list_item.dart
··· 2 2 import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; 3 3 import 'package:bluesky/moderation.dart' as bsky_moderation; 4 4 import 'package:flutter/material.dart' hide Notification; 5 - import 'package:go_router/go_router.dart'; 6 5 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 7 6 import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 7 + import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 8 8 import 'package:lazurite/shared/presentation/helpers/notification_icon_mapper.dart'; 9 9 import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 10 10 import 'package:lazurite/shared/utils/format_utils.dart'; ··· 252 252 final reason = notification.reason; 253 253 254 254 if (reason.isKnownValue && reason.knownValue == bsky.KnownNotificationReason.follow) { 255 - context.push('/profile/view?actor=${notification.author.did}'); 255 + navigateToProfile(context, notification.author.did); 256 256 return; 257 257 } 258 258 ··· 261 261 (reason.knownValue == bsky.KnownNotificationReason.like || 262 262 reason.knownValue == bsky.KnownNotificationReason.repost); 263 263 final uri = isLikeOrRepost ? (notification.reasonSubject ?? notification.uri) : notification.uri; 264 - context.push('/post?uri=${Uri.encodeComponent(uri.toString())}'); 264 + navigateToPost(context, uri.toString()); 265 265 } 266 266 }
+6 -7
lib/features/profile/presentation/widgets/profile_action_buttons.dart
··· 1 - import 'dart:async'; 2 1 import 'package:lazurite/core/theme/theme_extensions.dart'; 3 2 4 3 import 'package:flutter/material.dart'; 5 - import 'package:flutter/services.dart'; 6 4 import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 5 + import 'package:lazurite/shared/presentation/helpers/haptic_helper.dart'; 7 6 import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 8 7 9 8 class ProfileActionButtons extends StatelessWidget { ··· 155 154 } 156 155 157 156 Future<void> _confirmUnfollow(BuildContext context) async { 158 - unawaited(HapticFeedback.mediumImpact()); 157 + HapticHelper.mediumImpact(); 159 158 await showConfirmationDialog( 160 159 context: context, 161 160 title: const Text('Unfollow?'), ··· 166 165 } 167 166 168 167 Future<void> _confirmMute(BuildContext context) async { 169 - unawaited(HapticFeedback.mediumImpact()); 168 + HapticHelper.mediumImpact(); 170 169 await showConfirmationDialog( 171 170 context: context, 172 171 title: const Text('Mute Account?'), ··· 177 176 } 178 177 179 178 Future<void> _confirmUnmute(BuildContext context) async { 180 - unawaited(HapticFeedback.mediumImpact()); 179 + HapticHelper.mediumImpact(); 181 180 await showConfirmationDialog( 182 181 context: context, 183 182 title: const Text('Unmute Account?'), ··· 188 187 } 189 188 190 189 Future<void> _confirmBlock(BuildContext context) async { 191 - unawaited(HapticFeedback.heavyImpact()); 190 + HapticHelper.heavyImpact(); 192 191 await showConfirmationDialog( 193 192 context: context, 194 193 title: Row( ··· 208 207 } 209 208 210 209 Future<void> _confirmUnblock(BuildContext context) async { 211 - unawaited(HapticFeedback.mediumImpact()); 210 + HapticHelper.mediumImpact(); 212 211 await showConfirmationDialog( 213 212 context: context, 214 213 title: const Text('Unblock Account?'),
+2 -2
lib/features/profile/presentation/widgets/report_dialog.dart
··· 1 1 import 'package:atproto/com_atproto_moderation_defs.dart'; 2 2 import 'package:atproto_core/atproto_core.dart'; 3 3 import 'package:flutter/material.dart'; 4 - import 'package:flutter/services.dart'; 5 4 import 'package:flutter_bloc/flutter_bloc.dart'; 6 5 import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; 6 + import 'package:lazurite/shared/presentation/helpers/haptic_helper.dart'; 7 7 import 'package:lazurite/core/theme/theme_extensions.dart'; 8 8 9 9 enum _ReportType { post, actor } ··· 129 129 onTap: _isSubmitting 130 130 ? null 131 131 : () { 132 - HapticFeedback.selectionClick(); 132 + HapticHelper.selectionClick(); 133 133 setState(() { 134 134 _selectedReason = option.type; 135 135 });
+19
lib/shared/presentation/helpers/haptic_helper.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:flutter/services.dart'; 4 + 5 + abstract final class HapticHelper { 6 + static void selectionClick() { 7 + unawaited(HapticFeedback.selectionClick()); 8 + } 9 + 10 + static Future<void> lightImpact() => HapticFeedback.lightImpact(); 11 + 12 + static void mediumImpact() { 13 + unawaited(HapticFeedback.mediumImpact()); 14 + } 15 + 16 + static void heavyImpact() { 17 + unawaited(HapticFeedback.heavyImpact()); 18 + } 19 + }
+20
lib/shared/presentation/helpers/navigation_helpers.dart
··· 1 + import 'package:flutter/widgets.dart'; 2 + import 'package:go_router/go_router.dart'; 3 + 4 + Future<T?>? navigateToProfile<T>(BuildContext context, String actorDid) { 5 + final router = GoRouter.maybeOf(context); 6 + if (router == null) { 7 + return null; 8 + } 9 + 10 + return router.push<T>('/profile/view?actor=${Uri.encodeQueryComponent(actorDid)}'); 11 + } 12 + 13 + Future<T?>? navigateToPost<T>(BuildContext context, String postUri) { 14 + final router = GoRouter.maybeOf(context); 15 + if (router == null) { 16 + return null; 17 + } 18 + 19 + return router.push<T>('/post?uri=${Uri.encodeQueryComponent(postUri)}'); 20 + }
+98
test/shared/presentation/helpers/navigation_helpers_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:go_router/go_router.dart'; 4 + import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 5 + 6 + void main() { 7 + group('navigation_helpers', () { 8 + testWidgets('navigateToProfile pushes encoded profile route', (tester) async { 9 + const actorDid = 'did:plc:alice.test'; 10 + String? pushedRoute; 11 + 12 + final router = GoRouter( 13 + routes: [ 14 + GoRoute( 15 + path: '/', 16 + builder: (context, state) => Scaffold( 17 + body: Center( 18 + child: FilledButton(onPressed: () => navigateToProfile(context, actorDid), child: const Text('go')), 19 + ), 20 + ), 21 + ), 22 + GoRoute( 23 + path: '/profile/view', 24 + builder: (context, state) { 25 + pushedRoute = state.uri.toString(); 26 + return const Scaffold(body: Text('profile')); 27 + }, 28 + ), 29 + ], 30 + ); 31 + 32 + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); 33 + await tester.pumpAndSettle(); 34 + 35 + await tester.tap(find.text('go')); 36 + await tester.pumpAndSettle(); 37 + 38 + expect(pushedRoute, isNotNull); 39 + expect(Uri.parse(pushedRoute!).path, '/profile/view'); 40 + expect(Uri.parse(pushedRoute!).queryParameters['actor'], actorDid); 41 + }); 42 + 43 + testWidgets('navigateToPost pushes encoded post route', (tester) async { 44 + const postUri = 'at://did:plc:alice/app.bsky.feed.post/123'; 45 + String? pushedRoute; 46 + 47 + final router = GoRouter( 48 + routes: [ 49 + GoRoute( 50 + path: '/', 51 + builder: (context, state) => Scaffold( 52 + body: Center( 53 + child: FilledButton(onPressed: () => navigateToPost(context, postUri), child: const Text('go')), 54 + ), 55 + ), 56 + ), 57 + GoRoute( 58 + path: '/post', 59 + builder: (context, state) { 60 + pushedRoute = state.uri.toString(); 61 + return const Scaffold(body: Text('post')); 62 + }, 63 + ), 64 + ], 65 + ); 66 + 67 + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); 68 + await tester.pumpAndSettle(); 69 + 70 + await tester.tap(find.text('go')); 71 + await tester.pumpAndSettle(); 72 + 73 + expect(pushedRoute, isNotNull); 74 + expect(Uri.parse(pushedRoute!).path, '/post'); 75 + expect(Uri.parse(pushedRoute!).queryParameters['uri'], postUri); 76 + }); 77 + 78 + testWidgets('returns null and does not throw without a router', (tester) async { 79 + Future<Object?>? result; 80 + 81 + await tester.pumpWidget( 82 + MaterialApp( 83 + home: Scaffold( 84 + body: Builder( 85 + builder: (context) { 86 + result = navigateToProfile(context, 'did:plc:no-router'); 87 + return const SizedBox.shrink(); 88 + }, 89 + ), 90 + ), 91 + ), 92 + ); 93 + 94 + expect(result, isNull); 95 + expect(tester.takeException(), isNull); 96 + }); 97 + }); 98 + }