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: tap outside listener

+159 -93
+3 -10
lib/features/feed/presentation/feed_management_screen.dart
··· 4 4 import 'package:flutter/material.dart'; 5 5 import 'package:flutter_bloc/flutter_bloc.dart'; 6 6 import 'package:lazurite/core/cache/lazurite_image_cache.dart'; 7 + import 'package:lazurite/core/theme/theme_extensions.dart'; 7 8 import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; 8 9 import 'package:lazurite/features/feed/data/feed_repository.dart'; 9 10 import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 10 11 import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 11 12 import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; 12 13 import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; 13 - import 'package:lazurite/core/theme/theme_extensions.dart'; 14 + import 'package:lazurite/shared/utils/format_utils.dart'; 14 15 15 16 class FeedManagementScreen extends StatefulWidget { 16 17 const FeedManagementScreen({super.key}); ··· 227 228 crossAxisAlignment: CrossAxisAlignment.start, 228 229 children: [ 229 230 Text( 230 - _feedDisplayName(feed), 231 + feedDisplayName(feed), 231 232 style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600), 232 233 ), 233 234 Text( ··· 323 324 ) 324 325 : const Icon(Icons.rss_feed, color: Colors.white), 325 326 ); 326 - } 327 - 328 - String _feedDisplayName(GeneratorView feed) { 329 - final displayName = feed.displayName.trim(); 330 - if (displayName.isNotEmpty) { 331 - return displayName; 332 - } 333 - return feed.uri.rkey; 334 327 } 335 328 336 329 Future<void> _confirmRemoveFeed(BuildContext context, String feedId) async {
+3 -63
lib/features/search/presentation/search_screen.dart
··· 9 9 import 'package:lazurite/core/theme/animation_tokens.dart'; 10 10 import 'package:lazurite/core/theme/animation_utils.dart'; 11 11 import 'package:lazurite/core/theme/theme_extensions.dart'; 12 - import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 13 - import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 14 12 import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; 15 13 import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 16 14 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; ··· 18 16 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 19 17 import 'package:lazurite/features/search/bloc/search_bloc.dart'; 20 18 import 'package:lazurite/features/search/data/post_search_filters.dart'; 19 + import 'package:lazurite/features/search/presentation/widgets/follow_button.dart'; 21 20 import 'package:lazurite/features/search/presentation/widgets/search_result_states.dart'; 22 21 import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; 23 22 import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; ··· 1192 1191 ), 1193 1192 ), 1194 1193 const SizedBox(width: 8), 1195 - _FollowButton(actor: actor), 1194 + FollowButton(actor: actor), 1196 1195 ], 1197 1196 ), 1198 1197 ), ··· 1208 1207 1209 1208 @override 1210 1209 Widget build(BuildContext context) { 1211 - final displayName = _feedDisplayName(feed); 1210 + final displayName = feedDisplayName(feed); 1212 1211 final avatarUrl = feed.avatar ?? feed.creator.avatar; 1213 1212 final isAdded = context.select<FeedPreferencesCubit, bool>( 1214 1213 (cubit) => cubit.state.containsFeedValue(feed.uri.toString()), ··· 1285 1284 ], 1286 1285 ), 1287 1286 ); 1288 - } 1289 - 1290 - String _feedDisplayName(GeneratorView value) { 1291 - final displayName = value.displayName.trim(); 1292 - if (displayName.isNotEmpty) { 1293 - return displayName; 1294 - } 1295 - return value.uri.rkey; 1296 - } 1297 - } 1298 - 1299 - class _FollowButton extends StatefulWidget { 1300 - const _FollowButton({required this.actor}); 1301 - 1302 - final ProfileView actor; 1303 - 1304 - @override 1305 - State<_FollowButton> createState() => _FollowButtonState(); 1306 - } 1307 - 1308 - class _FollowButtonState extends State<_FollowButton> { 1309 - late bool _isFollowing; 1310 - 1311 - @override 1312 - void initState() { 1313 - super.initState(); 1314 - _isFollowing = widget.actor.viewer?.following != null; 1315 - } 1316 - 1317 - @override 1318 - Widget build(BuildContext context) { 1319 - final isOffline = context.select<ConnectivityCubit, bool>((cubit) => cubit.state.isOffline); 1320 - if (_isFollowing) { 1321 - final button = OutlinedButton( 1322 - onPressed: isOffline ? null : _toggleFollow, 1323 - style: OutlinedButton.styleFrom( 1324 - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), 1325 - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), 1326 - ), 1327 - child: const Text('Following'), 1328 - ); 1329 - 1330 - return isOffline ? Tooltip(message: offlineActionMessage('change your follow state'), child: button) : button; 1331 - } 1332 - 1333 - final button = FilledButton.tonal( 1334 - onPressed: isOffline ? null : _toggleFollow, 1335 - style: FilledButton.styleFrom( 1336 - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), 1337 - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), 1338 - ), 1339 - child: const Text('Follow'), 1340 - ); 1341 - 1342 - return isOffline ? Tooltip(message: offlineActionMessage('follow this account'), child: button) : button; 1343 - } 1344 - 1345 - void _toggleFollow() { 1346 - setState(() => _isFollowing = !_isFollowing); 1347 1287 } 1348 1288 }
+56
lib/features/search/presentation/widgets/follow_button.dart
··· 1 + import 'package:bluesky/app_bsky_actor_defs.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 5 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 6 + 7 + class FollowButton extends StatefulWidget { 8 + const FollowButton({super.key, required this.actor}); 9 + 10 + final ProfileView actor; 11 + 12 + @override 13 + State<FollowButton> createState() => _FollowButtonState(); 14 + } 15 + 16 + class _FollowButtonState extends State<FollowButton> { 17 + late bool _isFollowing; 18 + 19 + @override 20 + void initState() { 21 + super.initState(); 22 + _isFollowing = widget.actor.viewer?.following != null; 23 + } 24 + 25 + @override 26 + Widget build(BuildContext context) { 27 + final isOffline = context.select<ConnectivityCubit, bool>((cubit) => cubit.state.isOffline); 28 + if (_isFollowing) { 29 + final button = OutlinedButton( 30 + onPressed: isOffline ? null : _toggleFollow, 31 + style: OutlinedButton.styleFrom( 32 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), 33 + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), 34 + ), 35 + child: const Text('Following'), 36 + ); 37 + 38 + return isOffline ? Tooltip(message: offlineActionMessage('change your follow state'), child: button) : button; 39 + } 40 + 41 + final button = FilledButton.tonal( 42 + onPressed: isOffline ? null : _toggleFollow, 43 + style: FilledButton.styleFrom( 44 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), 45 + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), 46 + ), 47 + child: const Text('Follow'), 48 + ); 49 + 50 + return isOffline ? Tooltip(message: offlineActionMessage('follow this account'), child: button) : button; 51 + } 52 + 53 + void _toggleFollow() { 54 + setState(() => _isFollowing = !_isFollowing); 55 + } 56 + }
+1
lib/features/typeahead/presentation/typeahead_text_field.dart
··· 188 188 189 189 void _onTapOutside(PointerDownEvent _) { 190 190 _dismissSuggestions(); 191 + _focusNode.unfocus(); 191 192 } 192 193 193 194 void _dismissSuggestions() {
+22 -19
lib/main.dart
··· 65 65 import 'package:lazurite/features/settings/data/video_repository.dart'; 66 66 import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 67 67 import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 68 + import 'package:lazurite/shared/presentation/widgets/global_tap_outside_unfocus.dart'; 68 69 69 70 Future<void> main() async { 70 71 WidgetsFlutterBinding.ensureInitialized(); ··· 367 368 darkTheme: darkTheme, 368 369 themeMode: themeMode, 369 370 routerConfig: _router, 370 - builder: (context, child) => Stack( 371 - children: [ 372 - ConnectivityBannerHost(child: child ?? const SizedBox.shrink()), 373 - if (_isSoftRestarting) 374 - const ColoredBox( 375 - color: Color(0xC0000000), 376 - child: Center( 377 - child: Card( 378 - child: Padding( 379 - padding: EdgeInsets.symmetric(horizontal: 20, vertical: 16), 380 - child: Row( 381 - mainAxisSize: MainAxisSize.min, 382 - children: [ 383 - SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2.5)), 384 - SizedBox(width: 12), 385 - Text('Applying provider change...'), 386 - ], 371 + builder: (context, child) => GlobalTapOutsideUnfocus( 372 + child: Stack( 373 + children: [ 374 + ConnectivityBannerHost(child: child ?? const SizedBox.shrink()), 375 + if (_isSoftRestarting) 376 + const ColoredBox( 377 + color: Color(0xC0000000), 378 + child: Center( 379 + child: Card( 380 + child: Padding( 381 + padding: EdgeInsets.symmetric(horizontal: 20, vertical: 16), 382 + child: Row( 383 + mainAxisSize: MainAxisSize.min, 384 + children: [ 385 + SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2.5)), 386 + SizedBox(width: 12), 387 + Text('Applying provider change...'), 388 + ], 389 + ), 387 390 ), 388 391 ), 389 392 ), 390 393 ), 391 - ), 392 - ], 394 + ], 395 + ), 393 396 ), 394 397 ); 395 398 },
+22
lib/shared/presentation/widgets/global_tap_outside_unfocus.dart
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + /// Ensures tapping outside a focused [EditableText] dismisses keyboard focus 4 + /// on touch devices as well. 5 + class GlobalTapOutsideUnfocus extends StatelessWidget { 6 + const GlobalTapOutsideUnfocus({super.key, required this.child}); 7 + 8 + final Widget child; 9 + 10 + @override 11 + Widget build(BuildContext context) => Actions( 12 + actions: <Type, Action<Intent>>{ 13 + EditableTextTapOutsideIntent: CallbackAction<EditableTextTapOutsideIntent>( 14 + onInvoke: (intent) { 15 + intent.focusNode.unfocus(); 16 + return null; 17 + }, 18 + ), 19 + }, 20 + child: child, 21 + ); 22 + }
+9
lib/shared/utils/format_utils.dart
··· 1 + import 'package:bluesky/app_bsky_feed_defs.dart'; 1 2 import 'package:intl/intl.dart'; 2 3 3 4 /// Returns up to two initials from a display value. ··· 58 59 final minute = time.minute.toString().padLeft(2, '0'); 59 60 return '${time.year}-$month-$day $hour:$minute'; 60 61 } 62 + 63 + String feedDisplayName(GeneratorView value) { 64 + final displayName = value.displayName.trim(); 65 + if (displayName.isNotEmpty) { 66 + return displayName; 67 + } 68 + return value.uri.rkey; 69 + }
+9 -1
test/features/typeahead/presentation/typeahead_text_field_test.dart
··· 57 57 58 58 testWidgets('tap outside dismisses overlay', (tester) async { 59 59 final controller = TextEditingController(); 60 + final focusNode = FocusNode(); 61 + addTearDown(focusNode.dispose); 60 62 final repository = _FakeTypeaheadRepository( 61 63 searchHandler: ({required String query, int limit = 10}) async { 62 64 return const [TypeaheadResult(did: 'did:plc:alice', handle: 'alice.bsky.social', displayName: 'Alice')]; 63 65 }, 64 66 ); 65 67 66 - await tester.pumpWidget(_buildSubject(controller: controller, repository: repository, onSelected: (_) {})); 68 + await tester.pumpWidget( 69 + _buildSubject(controller: controller, repository: repository, focusNode: focusNode, onSelected: (_) {}), 70 + ); 67 71 68 72 await tester.enterText(find.byType(TextFormField), 'alice'); 69 73 await tester.pump(const Duration(milliseconds: 20)); 70 74 await tester.pumpAndSettle(); 71 75 72 76 expect(find.text('Alice'), findsOneWidget); 77 + expect(focusNode.hasFocus, isTrue); 73 78 74 79 await tester.tapAt(const Offset(10, 500)); 75 80 await tester.pumpAndSettle(); 76 81 77 82 expect(find.text('Alice'), findsNothing); 83 + expect(focusNode.hasFocus, isFalse); 78 84 }); 79 85 80 86 testWidgets('does not query when input is shorter than minChars', (tester) async { ··· 123 129 required TextEditingController controller, 124 130 required TypeaheadRepository repository, 125 131 required ValueChanged<TypeaheadResult> onSelected, 132 + FocusNode? focusNode, 126 133 }) { 127 134 return MaterialApp( 128 135 home: Scaffold( ··· 132 139 children: [ 133 140 TypeaheadTextField( 134 141 controller: controller, 142 + focusNode: focusNode, 135 143 repository: repository, 136 144 onSelected: onSelected, 137 145 debounceMs: 1,
+34
test/shared/presentation/widgets/global_tap_outside_unfocus_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/shared/presentation/widgets/global_tap_outside_unfocus.dart'; 4 + 5 + void main() { 6 + testWidgets('tapping outside focused text input dismisses focus', (tester) async { 7 + final focusNode = FocusNode(debugLabel: 'global-focus-test'); 8 + addTearDown(focusNode.dispose); 9 + 10 + await tester.pumpWidget( 11 + MaterialApp( 12 + home: GlobalTapOutsideUnfocus( 13 + child: Scaffold( 14 + body: Column( 15 + children: [ 16 + TextField(focusNode: focusNode), 17 + const SizedBox(height: 200), 18 + const SizedBox(width: 120, height: 40, child: ColoredBox(color: Colors.red)), 19 + ], 20 + ), 21 + ), 22 + ), 23 + ), 24 + ); 25 + 26 + await tester.tap(find.byType(TextField)); 27 + await tester.pumpAndSettle(); 28 + expect(focusNode.hasFocus, isTrue); 29 + 30 + await tester.tapAt(const Offset(20, 260)); 31 + await tester.pumpAndSettle(); 32 + expect(focusNode.hasFocus, isFalse); 33 + }); 34 + }