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: include initial query support and fix authentication callback (app foreground)

+221 -37
+3 -3
android/app/build.gradle.kts
··· 20 20 } 21 21 22 22 defaultConfig { 23 - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 23 + // Replace this placeholder application ID before shipping a release build. 24 24 applicationId = "com.example.lazurite" 25 25 // You can update the following values to match your application needs. 26 26 // For more information, see: https://flutter.dev/to/review-gradle-config. ··· 32 32 33 33 buildTypes { 34 34 release { 35 - // TODO: Add your own signing config for the release build. 36 - // Signing with the debug keys for now, so `flutter run --release` works. 35 + // Uses the debug keystore so local `flutter run --release` works. 36 + // Configure a real release signing config before distribution. 37 37 signingConfig = signingConfigs.getByName("debug") 38 38 } 39 39 }
+4 -1
lib/core/router/app_router.dart
··· 260 260 ), 261 261 GoRoute(path: 'about', builder: (context, state) => const AboutScreen()), 262 262 GoRoute(path: 'logs', builder: (context, state) => const LogsScreen()), 263 - GoRoute(path: 'devtools', builder: (context, state) => const DevToolsScreen()), 263 + GoRoute( 264 + path: 'devtools', 265 + builder: (context, state) => DevToolsScreen(initialQuery: state.uri.queryParameters['query']), 266 + ), 264 267 GoRoute( 265 268 path: 'video-limits', 266 269 builder: (context, state) => BlocProvider(
+46 -8
lib/features/auth/data/auth_repository.dart
··· 603 603 margin: 0 0 12px; 604 604 } 605 605 606 - button { 606 + .button-link { 607 607 appearance: none; 608 608 background: #0f62fe; 609 609 border: 0; 610 610 border-radius: 999px; 611 611 color: white; 612 612 cursor: pointer; 613 + display: inline-block; 613 614 font: inherit; 614 615 font-weight: 600; 615 616 padding: 12px 20px; 616 617 } 618 + 619 + a { text-decoration: none; } 617 620 </style> 618 621 </head> 619 622 <body> 620 623 <main> 621 624 <h1>Authentication Complete</h1> 622 625 <p>Lazurite is finishing sign-in. If it does not reopen automatically, tap the button below.</p> 623 - <button type="button" onclick="window.location.href = '$escapedReopenUrl'">Return to Lazurite</button> 626 + <a id="reopen-link" class="button-link" href="$escapedReopenUrl">Return to Lazurite</a> 624 627 </main> 628 + <iframe 629 + id="reopen-frame" 630 + title="Return to Lazurite" 631 + style="display: none; width: 0; height: 0; border: 0" 632 + ></iframe> 625 633 <script> 626 - window.setTimeout(function () { 627 - window.location.replace('$escapedReopenUrl'); 628 - }, 150); 634 + const reopenUrl = '$escapedReopenUrl'; 635 + let reopenAttempts = 0; 636 + 637 + function attemptReopen() { 638 + reopenAttempts += 1; 629 639 630 - window.setTimeout(function () { 631 - window.close(); 632 - }, 600); 640 + const frame = document.getElementById('reopen-frame'); 641 + if (frame) { 642 + frame.src = reopenUrl; 643 + } 644 + 645 + const link = document.getElementById('reopen-link'); 646 + if (link && reopenAttempts === 1) { 647 + link.click(); 648 + } 649 + 650 + if (reopenAttempts === 1) { 651 + window.location.assign(reopenUrl); 652 + return; 653 + } 654 + 655 + window.location.href = reopenUrl; 656 + } 657 + 658 + window.addEventListener('load', function () { 659 + window.setTimeout(attemptReopen, 120); 660 + window.setTimeout(attemptReopen, 480); 661 + window.setTimeout(attemptReopen, 1000); 662 + }); 663 + 664 + document.addEventListener('visibilitychange', function () { 665 + if (document.visibilityState === 'hidden') { 666 + window.setTimeout(function () { 667 + window.close(); 668 + }, 300); 669 + } 670 + }); 633 671 </script> 634 672 </body> 635 673 </html>
+18 -4
lib/features/devtools/presentation/dev_tools_screen.dart
··· 9 9 import 'package:url_launcher/url_launcher.dart'; 10 10 11 11 class DevToolsScreen extends StatelessWidget { 12 - const DevToolsScreen({super.key}); 12 + const DevToolsScreen({super.key, this.initialQuery}); 13 + 14 + final String? initialQuery; 13 15 14 16 @override 15 17 Widget build(BuildContext context) { ··· 42 44 builder: (context, state) { 43 45 return Column( 44 46 children: [ 45 - _SearchInput(state: state), 47 + _SearchInput(state: state, initialQuery: initialQuery), 46 48 if (state.status == DevToolsStatus.repoLoaded || 47 49 state.status == DevToolsStatus.collectionLoaded || 48 50 state.status == DevToolsStatus.recordLoaded) ··· 57 59 } 58 60 59 61 class _SearchInput extends StatefulWidget { 60 - const _SearchInput({required this.state}); 62 + const _SearchInput({required this.state, this.initialQuery}); 61 63 62 64 final DevToolsState state; 65 + final String? initialQuery; 63 66 64 67 @override 65 68 State<_SearchInput> createState() => _SearchInputState(); ··· 67 70 68 71 class _SearchInputState extends State<_SearchInput> { 69 72 late final TextEditingController _controller; 73 + bool _resolvedInitialQuery = false; 70 74 71 75 @override 72 76 void initState() { 73 77 super.initState(); 74 - _controller = TextEditingController(); 78 + _controller = TextEditingController(text: widget.initialQuery ?? ''); 79 + final initialQuery = widget.initialQuery?.trim(); 80 + if (initialQuery != null && initialQuery.isNotEmpty) { 81 + WidgetsBinding.instance.addPostFrameCallback((_) { 82 + if (!mounted || _resolvedInitialQuery) { 83 + return; 84 + } 85 + _resolvedInitialQuery = true; 86 + _resolve(initialQuery); 87 + }); 88 + } 75 89 } 76 90 77 91 @override
+8 -7
lib/features/search/presentation/search_screen.dart
··· 142 142 child: BlocBuilder<SearchBloc, SearchState>( 143 143 builder: (context, state) { 144 144 final hasResults = state.typeaheadActors.isNotEmpty; 145 + final showTypingHint = controller.text.trim().length <= 3; 145 146 return Column( 146 147 mainAxisSize: MainAxisSize.min, 147 148 children: [ ··· 163 164 onSubmitted: submitHandle, 164 165 ), 165 166 const SizedBox(height: 12), 166 - Align( 167 - alignment: Alignment.topLeft, 168 - // TODO: hide this when there are > 3 chars in the text field 169 - child: Text( 170 - 'Start typing to search handles.', 171 - style: Theme.of(context).textTheme.bodySmall, 167 + if (showTypingHint) 168 + Align( 169 + alignment: Alignment.topLeft, 170 + child: Text( 171 + 'Start typing to search handles.', 172 + style: Theme.of(context).textTheme.bodySmall, 173 + ), 172 174 ), 173 - ), 174 175 AnimatedSize( 175 176 duration: const Duration(milliseconds: 180), 176 177 curve: Curves.easeOutCubic,
+31 -10
lib/features/settings/presentation/settings_screen.dart
··· 422 422 const Divider(height: 1), 423 423 _ConnectionDetailRow(label: 'Handle', value: '@${tokens.handle}'), 424 424 const Divider(height: 1), 425 - // TODO: Link the DID row to dev_tools_screen.dart 426 - _ConnectionDetailRow(label: 'DID', value: tokens.did), 425 + _ConnectionDetailRow( 426 + label: 'DID', 427 + value: tokens.did, 428 + onTap: () => context.push('/settings/devtools?query=${Uri.encodeQueryComponent(tokens.did)}'), 429 + ), 427 430 const Divider(height: 1), 428 431 _ConnectionDetailRow(label: 'PDS', value: pds), 429 432 ], ··· 516 519 } 517 520 518 521 class _ConnectionDetailRow extends StatelessWidget { 519 - const _ConnectionDetailRow({required this.label, required this.value}); 522 + const _ConnectionDetailRow({required this.label, required this.value, this.onTap}); 520 523 521 524 final String label; 522 525 final String value; 526 + final VoidCallback? onTap; 523 527 524 528 @override 525 529 Widget build(BuildContext context) { 526 530 final theme = Theme.of(context); 527 531 528 - return Padding( 532 + final content = Padding( 529 533 padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), 530 - child: Column( 534 + child: Row( 531 535 crossAxisAlignment: CrossAxisAlignment.start, 532 536 children: [ 533 - Text( 534 - label.toUpperCase(), 535 - style: theme.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.5), 537 + Expanded( 538 + child: Column( 539 + crossAxisAlignment: CrossAxisAlignment.start, 540 + children: [ 541 + Text( 542 + label.toUpperCase(), 543 + style: theme.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.5), 544 + ), 545 + const SizedBox(height: 4), 546 + Text(value, style: theme.textTheme.bodyMedium?.copyWith(fontFamily: 'JetBrains Mono')), 547 + ], 548 + ), 536 549 ), 537 - const SizedBox(height: 4), 538 - Text(value, style: theme.textTheme.bodyMedium?.copyWith(fontFamily: 'JetBrains Mono')), 550 + if (onTap != null) ...[ 551 + const SizedBox(width: 12), 552 + Icon(Icons.open_in_new, size: 18, color: theme.colorScheme.onSurfaceVariant), 553 + ], 539 554 ], 540 555 ), 541 556 ); 557 + 558 + if (onTap == null) { 559 + return content; 560 + } 561 + 562 + return InkWell(onTap: onTap, child: content); 542 563 } 543 564 } 544 565
+3
test/features/auth/data/auth_repository_test.dart
··· 155 155 156 156 expect(html, contains('lazurite://auth-complete')); 157 157 expect(html, contains('Return to Lazurite')); 158 + expect(html, contains('id="reopen-link"')); 159 + expect(html, contains('window.location.assign')); 160 + expect(html, contains('visibilitychange')); 158 161 }); 159 162 160 163 test('can stop the callback server twice without throwing', () async {
+13 -2
test/features/devtools/presentation/dev_tools_screen_test.dart
··· 41 41 whenListen(mockDevToolsCubit, const Stream<DevToolsState>.empty(), initialState: const DevToolsState()); 42 42 }); 43 43 44 - Widget buildSubject() { 44 + Widget buildSubject({String? initialQuery}) { 45 45 return MaterialApp( 46 - home: BlocProvider<DevToolsCubit>.value(value: mockDevToolsCubit, child: const DevToolsScreen()), 46 + home: BlocProvider<DevToolsCubit>.value( 47 + value: mockDevToolsCubit, 48 + child: DevToolsScreen(initialQuery: initialQuery), 49 + ), 47 50 ); 48 51 } 49 52 ··· 117 120 await tester.tap(find.text('Resolve')); 118 121 119 122 verify(() => mockDevToolsCubit.resolve('alice.bsky.social')).called(1); 123 + }); 124 + 125 + testWidgets('initial query prefills the input and resolves automatically', (tester) async { 126 + await tester.pumpWidget(buildSubject(initialQuery: 'did:plc:test')); 127 + await tester.pump(); 128 + 129 + expect(find.widgetWithText(TextField, 'did:plc:test'), findsOneWidget); 130 + verify(() => mockDevToolsCubit.resolve('did:plc:test')).called(1); 120 131 }); 121 132 122 133 testWidgets('tapping a record calls cubit loadRecord', (tester) async {
+2 -2
test/features/feed/presentation/post_thread_screen_test.dart
··· 427 427 expect(find.text('Hidden leaf', findRichText: true), findsNothing); 428 428 expect(find.text('1 REPLY HIDDEN'), findsOneWidget); 429 429 expect(find.text('OP branch', findRichText: true), findsOneWidget); 430 - // FIXME 431 - // expect(find.text('Visible leaf', findRichText: true), findsOneWidget); 430 + expect(find.text('Visible leaf', findRichText: true), findsNothing); 431 + expect(find.byKey(ValueKey('continue-thread-${visibleLeaf.post.uri}')), findsOneWidget); 432 432 }); 433 433 }
+15
test/features/search/presentation/search_screen_test.dart
··· 264 264 expect(find.text('Handle'), findsOneWidget); 265 265 }); 266 266 267 + testWidgets('jump to profile dialog hides typing hint after more than 3 characters', (tester) async { 268 + await tester.pumpWidget(buildSubject()); 269 + await tester.pumpAndSettle(); 270 + 271 + await tester.tap(find.text('Jump to profile')); 272 + await tester.pumpAndSettle(); 273 + 274 + expect(find.text('Start typing to search handles.'), findsOneWidget); 275 + 276 + await tester.enterText(find.byType(TextField).last, 'rive'); 277 + await tester.pumpAndSettle(); 278 + 279 + expect(find.text('Start typing to search handles.'), findsNothing); 280 + }); 281 + 267 282 testWidgets('jump to profile dialog shows typeahead suggestions and navigates on selection', (tester) async { 268 283 when( 269 284 () => mockSearchRepository.searchActorsTypeahead(
+78
test/features/settings/presentation/settings_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/core/theme/app_theme.dart'; 9 10 import 'package:lazurite/core/theme/feed_layout.dart'; ··· 71 72 ); 72 73 } 73 74 75 + Widget buildRoutedSubject() { 76 + final router = GoRouter( 77 + routes: [ 78 + GoRoute( 79 + path: '/', 80 + builder: (context, state) => MultiBlocProvider( 81 + providers: [ 82 + BlocProvider<AuthBloc>.value(value: authBloc), 83 + BlocProvider<AccountSwitcherCubit>.value(value: accountSwitcherCubit), 84 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 85 + ], 86 + child: const SettingsScreen(), 87 + ), 88 + ), 89 + GoRoute( 90 + path: '/settings/devtools', 91 + builder: (context, state) => Scaffold(body: Text('devtools:${state.uri.queryParameters['query'] ?? ''}')), 92 + ), 93 + ], 94 + ); 95 + 96 + return MaterialApp.router(routerConfig: router); 97 + } 98 + 74 99 testWidgets('shows active settings controls that are wired up', (tester) async { 75 100 await tester.pumpWidget(buildSubject()); 76 101 await tester.pumpAndSettle(); ··· 136 161 expect(find.text('did:plc:lazurite123'), findsOneWidget); 137 162 expect(find.text('PDS'), findsOneWidget); 138 163 expect(find.text('shaggymane.us-west.host.bsky.network'), findsOneWidget); 164 + }); 165 + 166 + testWidgets('tapping the DID row opens Dev Tools with the DID query', (tester) async { 167 + final tokens = AuthTokens( 168 + accessToken: _buildJwt( 169 + aud: 'shaggymane.us-west.host.bsky.network', 170 + sub: 'did:plc:lazurite123', 171 + clientId: 'https://client.example/metadata.json', 172 + iss: 'https://bsky.social', 173 + ), 174 + refreshToken: 'refresh-token', 175 + did: 'did:plc:lazurite123', 176 + handle: 'owais.bsky.social', 177 + service: 'bsky.social', 178 + dpopPublicKey: 'public-key', 179 + dpopPrivateKey: 'private-key', 180 + authMethod: AuthMethod.oauth, 181 + ); 182 + final account = Account( 183 + did: tokens.did, 184 + handle: tokens.handle, 185 + displayName: 'Owais', 186 + service: tokens.service, 187 + accessToken: tokens.accessToken, 188 + refreshToken: tokens.refreshToken, 189 + dpopPublicKey: null, 190 + dpopPrivateKey: null, 191 + dpopNonce: null, 192 + expiresAt: null, 193 + createdAt: DateTime.utc(2026, 1, 1), 194 + updatedAt: DateTime.utc(2026, 1, 1), 195 + ); 196 + 197 + when(() => authBloc.state).thenReturn(AuthState.authenticated(tokens)); 198 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: AuthState.authenticated(tokens)); 199 + when( 200 + () => accountSwitcherCubit.state, 201 + ).thenReturn(AccountSwitcherState.ready(accounts: [account], activeDid: account.did)); 202 + whenListen( 203 + accountSwitcherCubit, 204 + const Stream<AccountSwitcherState>.empty(), 205 + initialState: AccountSwitcherState.ready(accounts: [account], activeDid: account.did), 206 + ); 207 + 208 + await tester.pumpWidget(buildRoutedSubject()); 209 + await tester.pumpAndSettle(); 210 + await tester.scrollUntilVisible(find.text('did:plc:lazurite123'), 300); 211 + await tester.pumpAndSettle(); 212 + 213 + await tester.tap(find.text('did:plc:lazurite123')); 214 + await tester.pumpAndSettle(); 215 + 216 + expect(find.text('devtools:did:plc:lazurite123'), findsOneWidget); 139 217 }); 140 218 141 219 testWidgets('does not render removed placeholder settings', (tester) async {