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: 'Jump to profile'

+316 -8
+2 -2
docs/BUGS.md
··· 3 3 updated: 2026-03-18 4 4 --- 5 5 6 - The create post FAB should be on profiles and auto-fill @handle of that user's profile 7 - like the official app does. 6 + 7 + Saved posts should be a tabbed view for local & ATProto/BSky saved posts.
+4 -4
docs/tasks/phase-4.md
··· 59 59 60 60 ## M16 — Jump to Profile 61 61 62 - - [ ] Floating action button on search screen 63 - - [ ] Handle input dialog with autocomplete via `searchActorsTypeahead` 64 - - [ ] Navigate to profile screen on selection or enter 65 - - [ ] Update bottom navigation to include Notifications and Messages tabs (5-tab layout) 62 + - [x] Floating action button on search screen 63 + - [x] Handle input dialog with autocomplete via `searchActorsTypeahead` 64 + - [x] Navigate to profile screen on selection or enter 65 + - [x] Update bottom navigation to include Notifications and Messages tabs (5-tab layout) 66 66 67 67 ## M17 — Labelers & Content Moderation 68 68
+1
lib/core/router/app_router.dart
··· 90 90 quoteCid: args?.quoteCid, 91 91 quoteAuthorHandle: args?.quoteAuthorHandle, 92 92 draftId: args?.draftId, 93 + initialText: args?.initialText, 93 94 ), 94 95 ); 95 96 },
+1 -1
lib/core/router/app_shell.dart
··· 142 142 padding: const EdgeInsets.fromLTRB(12, 8, 8, 8), 143 143 child: Row( 144 144 children: [ 145 - Text('Menu', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), 145 + Text('Lazurite', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), 146 146 const Spacer(), 147 147 IconButton( 148 148 tooltip: 'Close menu',
+2
lib/features/compose/presentation/compose_route_args.dart
··· 9 9 this.quoteCid, 10 10 this.quoteAuthorHandle, 11 11 this.draftId, 12 + this.initialText, 12 13 }); 13 14 14 15 final String? replyParentUri; ··· 20 21 final String? quoteCid; 21 22 final String? quoteAuthorHandle; 22 23 final int? draftId; 24 + final String? initialText; 23 25 }
+8
lib/features/compose/presentation/compose_screen.dart
··· 21 21 this.quoteCid, 22 22 this.quoteAuthorHandle, 23 23 this.draftId, 24 + this.initialText, 24 25 }); 25 26 26 27 final String? replyParentUri; ··· 32 33 final String? quoteCid; 33 34 final String? quoteAuthorHandle; 34 35 final int? draftId; 36 + final String? initialText; 35 37 36 38 @override 37 39 State<ComposeScreen> createState() => _ComposeScreenState(); ··· 46 48 void initState() { 47 49 super.initState(); 48 50 _textController = _FacetHighlightController(); 51 + if (widget.initialText?.isNotEmpty ?? false) { 52 + _textController.text = widget.initialText!; 53 + } 49 54 50 55 if (widget.draftId != null) { 51 56 context.read<ComposeBloc>().add(DraftLoaded(widget.draftId!)); ··· 67 72 } 68 73 69 74 _textController.addListener(_onTextChanged); 75 + if (widget.initialText?.isNotEmpty ?? false) { 76 + context.read<ComposeBloc>().add(TextChanged(widget.initialText!)); 77 + } 70 78 } 71 79 72 80 @override
+22
lib/features/profile/presentation/profile_screen.dart
··· 11 11 import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 12 12 import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; 13 13 import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 14 + import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 14 15 import 'package:lazurite/features/profile/presentation/widgets/profile_action_buttons.dart'; 15 16 import 'package:share_plus/share_plus.dart'; 16 17 import 'package:url_launcher/url_launcher.dart'; ··· 147 148 ); 148 149 }, 149 150 ), 151 + floatingActionButton: _buildComposeFab(context), 152 + ); 153 + } 154 + 155 + Widget? _buildComposeFab(BuildContext context) { 156 + return BlocBuilder<ProfileBloc, ProfileState>( 157 + builder: (context, state) { 158 + final profile = state.profile; 159 + if (profile == null) { 160 + return const SizedBox.shrink(); 161 + } 162 + 163 + final currentUserDid = context.read<AuthBloc>().state.tokens?.did; 164 + final isOwnProfile = profile.did == currentUserDid; 165 + final initialText = isOwnProfile ? null : '@${profile.handle} '; 166 + 167 + return FloatingActionButton( 168 + onPressed: () => context.push('/compose', extra: ComposeRouteArgs(initialText: initialText)), 169 + child: const Icon(Icons.add), 170 + ); 171 + }, 150 172 ); 151 173 } 152 174
+121
lib/features/search/presentation/search_screen.dart
··· 100 100 ); 101 101 } 102 102 103 + void _openJumpToProfileDialog() { 104 + final controller = TextEditingController(); 105 + final searchBloc = context.read<SearchBloc>(); 106 + 107 + showDialog<void>( 108 + context: context, 109 + builder: (dialogContext) { 110 + return BlocProvider.value( 111 + value: searchBloc, 112 + child: StatefulBuilder( 113 + builder: (context, setDialogState) { 114 + void submitHandle([String? value]) { 115 + final rawValue = (value ?? controller.text).trim(); 116 + final handle = rawValue.startsWith('@') ? rawValue.substring(1).trim() : rawValue; 117 + if (handle.isEmpty) { 118 + return; 119 + } 120 + 121 + searchBloc.add(const TypeaheadRequested(query: '')); 122 + Navigator.of(dialogContext).pop(); 123 + WidgetsBinding.instance.addPostFrameCallback((_) { 124 + if (mounted) { 125 + GoRouter.of(this.context).push('/profile/view?actor=${Uri.encodeQueryComponent(handle)}'); 126 + } 127 + }); 128 + } 129 + 130 + return AlertDialog( 131 + title: const Text('Jump to profile'), 132 + content: SizedBox( 133 + width: 420, 134 + child: BlocBuilder<SearchBloc, SearchState>( 135 + builder: (context, state) { 136 + final hasResults = state.typeaheadActors.isNotEmpty; 137 + return Column( 138 + mainAxisSize: MainAxisSize.min, 139 + children: [ 140 + TextField( 141 + controller: controller, 142 + autofocus: true, 143 + autocorrect: false, 144 + textInputAction: TextInputAction.search, 145 + decoration: const InputDecoration( 146 + labelText: 'Handle', 147 + hintText: 'alice.bsky.social', 148 + prefixText: '@', 149 + border: OutlineInputBorder(), 150 + ), 151 + onChanged: (value) { 152 + setDialogState(() {}); 153 + searchBloc.add(TypeaheadRequested(query: value.isEmpty ? '' : '@$value')); 154 + }, 155 + onSubmitted: submitHandle, 156 + ), 157 + const SizedBox(height: 12), 158 + Align( 159 + alignment: Alignment.topLeft, 160 + // TODO: hide this when there are > 3 chars in the text field 161 + child: Text( 162 + 'Start typing to search handles.', 163 + style: Theme.of(context).textTheme.bodySmall, 164 + ), 165 + ), 166 + AnimatedSize( 167 + duration: const Duration(milliseconds: 180), 168 + curve: Curves.easeOutCubic, 169 + alignment: Alignment.topCenter, 170 + child: hasResults 171 + ? Padding( 172 + padding: const EdgeInsets.only(top: 12), 173 + child: SizedBox( 174 + height: 220, 175 + child: ListView.builder( 176 + shrinkWrap: true, 177 + itemCount: state.typeaheadActors.length, 178 + itemBuilder: (context, index) { 179 + final actor = state.typeaheadActors[index]; 180 + return _ActorListTile( 181 + actor: actor, 182 + onTap: () { 183 + controller.text = actor.handle; 184 + submitHandle(actor.did); 185 + }, 186 + ); 187 + }, 188 + ), 189 + ), 190 + ) 191 + : const SizedBox.shrink(), 192 + ), 193 + ], 194 + ); 195 + }, 196 + ), 197 + ), 198 + actions: [ 199 + TextButton( 200 + onPressed: () { 201 + searchBloc.add(const TypeaheadRequested(query: '')); 202 + Navigator.of(dialogContext).pop(); 203 + }, 204 + child: const Text('Cancel'), 205 + ), 206 + FilledButton( 207 + onPressed: controller.text.trim().isEmpty ? null : submitHandle, 208 + child: const Text('Open'), 209 + ), 210 + ], 211 + ); 212 + }, 213 + ), 214 + ); 215 + }, 216 + ); 217 + } 218 + 103 219 @override 104 220 Widget build(BuildContext context) { 105 221 return Scaffold( 222 + floatingActionButton: FloatingActionButton.extended( 223 + onPressed: _openJumpToProfileDialog, 224 + icon: const Icon(Icons.person_search), 225 + label: const Text('Jump to profile'), 226 + ), 106 227 body: SafeArea( 107 228 child: BlocBuilder<SearchBloc, SearchState>( 108 229 builder: (context, state) {
+1 -1
test/core/router/app_router_test.dart
··· 151 151 await tester.tap(find.byTooltip('Open menu')); 152 152 await tester.pumpAndSettle(); 153 153 154 - expect(find.text('Menu'), findsOneWidget); 154 + expect(find.text('Lazurite'), findsOneWidget); 155 155 expect(find.text('New Post'), findsOneWidget); 156 156 await tester.scrollUntilVisible(find.text('Log Out'), 200, scrollable: find.byType(Scrollable).last); 157 157 expect(find.text('Log Out'), findsOneWidget);
+17
test/features/compose/presentation/compose_screen_test.dart
··· 73 73 74 74 expect(find.text('-5'), findsOneWidget); 75 75 }); 76 + 77 + testWidgets('prefills initial text and dispatches TextChanged when provided', (tester) async { 78 + seedState(const ComposeState.ready(text: '@river.bsky.social ', graphemeCount: 19, isEmpty: false)); 79 + 80 + await tester.pumpWidget( 81 + MaterialApp( 82 + home: BlocProvider<ComposeBloc>.value( 83 + value: mockBloc, 84 + child: const ComposeScreen(initialText: '@river.bsky.social '), 85 + ), 86 + ), 87 + ); 88 + await tester.pump(); 89 + 90 + expect(find.text('@river.bsky.social '), findsOneWidget); 91 + verify(() => mockBloc.add(const TextChanged('@river.bsky.social '))).called(1); 92 + }); 76 93 }); 77 94 78 95 group('inline drafts panel (Bug #3)', () {
+55
test/features/profile/presentation/profile_screen_test.dart
··· 3 3 import 'package:flutter/material.dart'; 4 4 import 'package:flutter_bloc/flutter_bloc.dart'; 5 5 import 'package:flutter_test/flutter_test.dart'; 6 + import 'package:go_router/go_router.dart'; 6 7 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 8 + import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 7 9 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 8 10 import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; 9 11 import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; ··· 150 152 verify( 151 153 () => feedBloc.add(const FeedLoadRequested(actor: 'did:plc:me', filter: FeedFilter.postsWithMedia)), 152 154 ).called(1); 155 + }); 156 + 157 + testWidgets('compose FAB on other profiles prefills the mentioned handle', (tester) async { 158 + const otherProfile = ProfileViewDetailed( 159 + did: 'did:plc:other', 160 + handle: 'other.bsky.social', 161 + displayName: 'Other User', 162 + ); 163 + when(() => profileBloc.state).thenReturn(const ProfileState.loaded(profile: otherProfile)); 164 + whenListen( 165 + profileBloc, 166 + const Stream<ProfileState>.empty(), 167 + initialState: const ProfileState.loaded(profile: otherProfile), 168 + ); 169 + final mockProfileActionRepository = MockProfileActionRepository(); 170 + 171 + final router = GoRouter( 172 + routes: [ 173 + GoRoute( 174 + path: '/', 175 + builder: (context, state) => RepositoryProvider<ProfileActionRepository>.value( 176 + value: mockProfileActionRepository, 177 + child: MultiBlocProvider( 178 + providers: [ 179 + BlocProvider<AuthBloc>.value(value: authBloc), 180 + BlocProvider<ProfileBloc>.value(value: profileBloc), 181 + BlocProvider<FeedBloc>.value(value: feedBloc), 182 + ], 183 + child: const ProfileScreen(actor: 'did:plc:other', showBackButton: true), 184 + ), 185 + ), 186 + ), 187 + GoRoute( 188 + path: '/compose', 189 + builder: (context, state) { 190 + final args = state.extra as ComposeRouteArgs?; 191 + return Scaffold(body: Text(args?.initialText ?? '')); 192 + }, 193 + ), 194 + ], 195 + ); 196 + 197 + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); 198 + await tester.pumpAndSettle(); 199 + 200 + expect(find.byType(FloatingActionButton), findsOneWidget); 201 + 202 + await tester.tap(find.byType(FloatingActionButton)); 203 + await tester.pumpAndSettle(); 204 + 205 + expect(find.text('@other.bsky.social '), findsOneWidget); 206 + 207 + router.dispose(); 153 208 }); 154 209 }
+82
test/features/search/presentation/search_screen_test.dart
··· 4 4 import 'package:flutter/material.dart'; 5 5 import 'package:flutter_bloc/flutter_bloc.dart'; 6 6 import 'package:flutter_test/flutter_test.dart'; 7 + import 'package:go_router/go_router.dart'; 7 8 import 'package:lazurite/core/database/app_database.dart'; 8 9 import 'package:lazurite/features/search/bloc/search_bloc.dart'; 9 10 import 'package:lazurite/features/search/data/search_repository.dart'; ··· 54 55 child: const SearchScreen(), 55 56 ), 56 57 ); 58 + } 59 + 60 + Widget buildRoutedSubject() { 61 + final router = GoRouter( 62 + routes: [ 63 + GoRoute( 64 + path: '/', 65 + builder: (context, state) => BlocProvider<SearchBloc>( 66 + create: (_) => SearchBloc( 67 + searchRepository: mockSearchRepository, 68 + database: mockDatabase, 69 + accountDid: 'did:plc:test', 70 + ), 71 + child: const SearchScreen(), 72 + ), 73 + ), 74 + GoRoute( 75 + path: '/profile/view', 76 + builder: (context, state) => Scaffold(body: Text('profile:${state.uri.queryParameters['actor']}')), 77 + ), 78 + ], 79 + ); 80 + 81 + return MaterialApp.router(routerConfig: router); 57 82 } 58 83 59 84 testWidgets('displays search input and tabs', (tester) async { ··· 180 205 181 206 expect(find.text('Recent Searches'), findsOneWidget); 182 207 expect(find.text('flutter'), findsOneWidget); 208 + }); 209 + 210 + testWidgets('shows jump to profile FAB and opens the handle dialog', (tester) async { 211 + await tester.pumpWidget(buildSubject()); 212 + await tester.pumpAndSettle(); 213 + 214 + expect(find.text('Jump to profile'), findsOneWidget); 215 + 216 + await tester.tap(find.text('Jump to profile')); 217 + await tester.pumpAndSettle(); 218 + 219 + expect(find.text('Jump to profile'), findsNWidgets(2)); 220 + expect(find.text('Handle'), findsOneWidget); 221 + }); 222 + 223 + testWidgets('jump to profile dialog shows typeahead suggestions and navigates on selection', (tester) async { 224 + when( 225 + () => mockSearchRepository.searchActorsTypeahead( 226 + query: any(named: 'query'), 227 + limit: any(named: 'limit'), 228 + ), 229 + ).thenAnswer( 230 + (_) async => const [ 231 + ProfileViewBasic(did: 'did:plc:river', handle: 'river.bsky.social', displayName: 'River Tam'), 232 + ], 233 + ); 234 + 235 + await tester.pumpWidget(buildRoutedSubject()); 236 + await tester.pumpAndSettle(); 237 + 238 + await tester.tap(find.text('Jump to profile')); 239 + await tester.pumpAndSettle(); 240 + 241 + await tester.enterText(find.byType(TextField).last, 'river'); 242 + await tester.pump(const Duration(milliseconds: 350)); 243 + await tester.pumpAndSettle(); 244 + 245 + expect(find.text('River Tam'), findsOneWidget); 246 + 247 + await tester.tap(find.text('River Tam')); 248 + await tester.pumpAndSettle(); 249 + 250 + expect(find.text('profile:did:plc:river'), findsOneWidget); 251 + }); 252 + 253 + testWidgets('jump to profile dialog navigates on enter', (tester) async { 254 + await tester.pumpWidget(buildRoutedSubject()); 255 + await tester.pumpAndSettle(); 256 + 257 + await tester.tap(find.text('Jump to profile')); 258 + await tester.pumpAndSettle(); 259 + 260 + await tester.enterText(find.byType(TextField).last, 'custom.bsky.social'); 261 + await tester.testTextInput.receiveAction(TextInputAction.search); 262 + await tester.pumpAndSettle(); 263 + 264 + expect(find.text('profile:custom.bsky.social'), findsOneWidget); 183 265 }); 184 266 }); 185 267 }