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: typeahead widget

+431 -10
+2 -9
docs/tasks/typeahead.md
··· 23 23 24 24 ## M3 - TypeaheadTextField Widget 25 25 26 - - [ ] Create `lib/features/typeahead/presentation/typeahead_text_field.dart` 27 - - Wraps `TextFormField` + `OverlayEntry` for suggestions dropdown 28 - - Uses `LayerLink` + `CompositedTransformFollower` for positioning 29 - - Props: `controller`, `repository`, `onSelected`, `decoration`, `debounceMs`, `minChars`, `limit` 30 - - Each result row: avatar (CircleAvatar), display name, @handle 31 - - Keyboard-aware: overlay repositions when keyboard shows/hides 32 - - Tap result → calls `onSelected`, clears overlay 33 - - Tap outside → dismisses overlay 34 - - [ ] Widget tests: overlay appears on input, results render, tap selects, tap-outside dismisses 26 + - [x] Create `lib/features/typeahead/presentation/typeahead_text_field.dart` 27 + - [x] Widget tests: overlay appears on input, results render, tap selects, tap-outside dismisses 35 28 36 29 ## M4 - Login Integration 37 30
+4 -1
lib/features/typeahead/cubit/typeahead_cubit.dart
··· 8 8 TypeaheadCubit({ 9 9 required TypeaheadRepository repository, 10 10 Duration debounceDuration = const Duration(milliseconds: 300), 11 + int searchLimit = 10, 11 12 }) : _repository = repository, 12 13 _debounceDuration = debounceDuration, 14 + _searchLimit = searchLimit, 13 15 super(const TypeaheadState()); 14 16 15 17 final TypeaheadRepository _repository; 16 18 final Duration _debounceDuration; 19 + final int _searchLimit; 17 20 18 21 Timer? _debounceTimer; 19 22 int _requestId = 0; ··· 50 53 emit(state.copyWith(isLoading: true, error: null)); 51 54 52 55 try { 53 - final results = await _repository.search(query: query); 56 + final results = await _repository.search(query: query, limit: _searchLimit); 54 57 if (!_isActiveRequest(requestId)) { 55 58 return; 56 59 }
+288
lib/features/typeahead/presentation/typeahead_text_field.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:flutter/material.dart'; 4 + import 'package:lazurite/features/typeahead/cubit/typeahead_cubit.dart'; 5 + import 'package:lazurite/features/typeahead/cubit/typeahead_state.dart'; 6 + import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 7 + import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; 8 + 9 + class TypeaheadTextField extends StatefulWidget { 10 + const TypeaheadTextField({ 11 + required this.controller, 12 + required this.repository, 13 + required this.onSelected, 14 + this.decoration, 15 + this.debounceMs = 300, 16 + this.minChars = 2, 17 + this.limit = 10, 18 + super.key, 19 + }); 20 + 21 + final TextEditingController controller; 22 + final TypeaheadRepository repository; 23 + final ValueChanged<TypeaheadResult> onSelected; 24 + final InputDecoration? decoration; 25 + final int debounceMs; 26 + final int minChars; 27 + final int limit; 28 + 29 + @override 30 + State<TypeaheadTextField> createState() => _TypeaheadTextFieldState(); 31 + } 32 + 33 + class _TypeaheadTextFieldState extends State<TypeaheadTextField> with WidgetsBindingObserver { 34 + final LayerLink _layerLink = LayerLink(); 35 + final GlobalKey _fieldKey = GlobalKey(); 36 + final FocusNode _focusNode = FocusNode(); 37 + final Object _tapRegionGroup = Object(); 38 + 39 + TypeaheadCubit? _cubit; 40 + StreamSubscription<TypeaheadState>? _stateSubscription; 41 + OverlayEntry? _overlayEntry; 42 + 43 + @override 44 + void initState() { 45 + super.initState(); 46 + WidgetsBinding.instance.addObserver(this); 47 + _focusNode.addListener(_handleFocusChange); 48 + _createCubit(); 49 + } 50 + 51 + @override 52 + void didUpdateWidget(TypeaheadTextField oldWidget) { 53 + super.didUpdateWidget(oldWidget); 54 + 55 + final shouldRecreateCubit = 56 + oldWidget.repository != widget.repository || 57 + oldWidget.debounceMs != widget.debounceMs || 58 + oldWidget.limit != widget.limit; 59 + 60 + if (shouldRecreateCubit) { 61 + _stateSubscription?.cancel(); 62 + _cubit?.close(); 63 + _createCubit(); 64 + _runQuery(widget.controller.text); 65 + } 66 + 67 + _overlayEntry?.markNeedsBuild(); 68 + } 69 + 70 + @override 71 + void didChangeMetrics() { 72 + _overlayEntry?.markNeedsBuild(); 73 + } 74 + 75 + @override 76 + void dispose() { 77 + WidgetsBinding.instance.removeObserver(this); 78 + _focusNode.removeListener(_handleFocusChange); 79 + _focusNode.dispose(); 80 + 81 + _removeOverlay(); 82 + _stateSubscription?.cancel(); 83 + _cubit?.close(); 84 + super.dispose(); 85 + } 86 + 87 + void _createCubit() { 88 + final cubit = TypeaheadCubit( 89 + repository: widget.repository, 90 + debounceDuration: Duration(milliseconds: widget.debounceMs), 91 + searchLimit: widget.limit, 92 + ); 93 + 94 + _cubit = cubit; 95 + _stateSubscription = cubit.stream.listen(_onStateChanged); 96 + } 97 + 98 + void _onStateChanged(TypeaheadState state) { 99 + if (!mounted) { 100 + return; 101 + } 102 + 103 + if (!_focusNode.hasFocus || widget.controller.text.trim().length < widget.minChars) { 104 + _removeOverlay(); 105 + return; 106 + } 107 + 108 + if (state.isLoading || state.error != null || state.results.isNotEmpty) { 109 + _showOrUpdateOverlay(); 110 + return; 111 + } 112 + 113 + _removeOverlay(); 114 + } 115 + 116 + void _handleFocusChange() { 117 + if (!_focusNode.hasFocus) { 118 + _dismissSuggestions(); 119 + return; 120 + } 121 + 122 + final cubit = _cubit; 123 + if (cubit == null) { 124 + return; 125 + } 126 + 127 + final state = cubit.state; 128 + if (state.isLoading || state.error != null || state.results.isNotEmpty) { 129 + _showOrUpdateOverlay(); 130 + } 131 + } 132 + 133 + void _runQuery(String value) { 134 + final normalizedQuery = value.trim(); 135 + final cubit = _cubit; 136 + if (cubit == null) { 137 + return; 138 + } 139 + 140 + if (normalizedQuery.length < widget.minChars) { 141 + cubit.clear(); 142 + return; 143 + } 144 + 145 + cubit.onQueryChanged(normalizedQuery); 146 + } 147 + 148 + void _onTapOutside(PointerDownEvent _) { 149 + _dismissSuggestions(); 150 + } 151 + 152 + void _dismissSuggestions() { 153 + _removeOverlay(); 154 + _cubit?.clear(); 155 + } 156 + 157 + void _showOrUpdateOverlay() { 158 + if (_overlayEntry != null) { 159 + _overlayEntry!.markNeedsBuild(); 160 + return; 161 + } 162 + 163 + final overlay = Overlay.of(context); 164 + _overlayEntry = OverlayEntry(builder: (context) => _buildOverlay(context)); 165 + overlay.insert(_overlayEntry!); 166 + } 167 + 168 + void _removeOverlay() { 169 + _overlayEntry?.remove(); 170 + _overlayEntry = null; 171 + } 172 + 173 + Widget _buildOverlay(BuildContext context) { 174 + final fieldContext = _fieldKey.currentContext; 175 + if (fieldContext == null) { 176 + return const SizedBox.shrink(); 177 + } 178 + 179 + final renderBox = fieldContext.findRenderObject() as RenderBox?; 180 + if (renderBox == null || !renderBox.hasSize) { 181 + return const SizedBox.shrink(); 182 + } 183 + 184 + final fieldSize = renderBox.size; 185 + final state = _cubit?.state ?? const TypeaheadState(); 186 + 187 + return Positioned.fill( 188 + child: CompositedTransformFollower( 189 + link: _layerLink, 190 + showWhenUnlinked: false, 191 + offset: Offset(0, fieldSize.height + 4), 192 + child: Align( 193 + alignment: Alignment.topLeft, 194 + child: TapRegion( 195 + groupId: _tapRegionGroup, 196 + child: Material( 197 + elevation: 6, 198 + borderRadius: BorderRadius.circular(12), 199 + clipBehavior: Clip.antiAlias, 200 + child: SizedBox( 201 + width: fieldSize.width, 202 + child: _SuggestionList( 203 + state: state, 204 + onSelected: (result) { 205 + widget.onSelected(result); 206 + _dismissSuggestions(); 207 + _focusNode.unfocus(); 208 + }, 209 + ), 210 + ), 211 + ), 212 + ), 213 + ), 214 + ), 215 + ); 216 + } 217 + 218 + @override 219 + Widget build(BuildContext context) { 220 + return TapRegion( 221 + groupId: _tapRegionGroup, 222 + onTapOutside: _onTapOutside, 223 + child: CompositedTransformTarget( 224 + link: _layerLink, 225 + child: TextFormField( 226 + key: _fieldKey, 227 + controller: widget.controller, 228 + focusNode: _focusNode, 229 + decoration: widget.decoration, 230 + onChanged: _runQuery, 231 + ), 232 + ), 233 + ); 234 + } 235 + } 236 + 237 + class _SuggestionList extends StatelessWidget { 238 + const _SuggestionList({required this.state, required this.onSelected}); 239 + 240 + final TypeaheadState state; 241 + final ValueChanged<TypeaheadResult> onSelected; 242 + 243 + @override 244 + Widget build(BuildContext context) { 245 + if (state.error != null) { 246 + return ListTile(dense: true, leading: const Icon(Icons.error_outline), title: Text(state.error!)); 247 + } 248 + 249 + if (state.isLoading && state.results.isEmpty) { 250 + return const Padding( 251 + padding: EdgeInsets.all(12), 252 + child: Center(child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))), 253 + ); 254 + } 255 + 256 + return ConstrainedBox( 257 + constraints: const BoxConstraints(maxHeight: 280), 258 + child: ListView.separated( 259 + padding: EdgeInsets.zero, 260 + shrinkWrap: true, 261 + itemCount: state.results.length, 262 + separatorBuilder: (_, _) => const Divider(height: 1), 263 + itemBuilder: (context, index) { 264 + final result = state.results[index]; 265 + return ListTile( 266 + key: ValueKey<String>('typeahead-result-${result.did}'), 267 + dense: true, 268 + leading: CircleAvatar( 269 + backgroundImage: result.avatarUrl != null ? NetworkImage(result.avatarUrl!) : null, 270 + child: result.avatarUrl == null ? Text(_avatarInitial(result.displayName ?? result.handle)) : null, 271 + ), 272 + title: Text(result.displayName ?? result.handle, maxLines: 1, overflow: TextOverflow.ellipsis), 273 + subtitle: Text('@${result.handle}', maxLines: 1, overflow: TextOverflow.ellipsis), 274 + onTap: () => onSelected(result), 275 + ); 276 + }, 277 + ), 278 + ); 279 + } 280 + 281 + static String _avatarInitial(String value) { 282 + if (value.isEmpty) { 283 + return '?'; 284 + } 285 + 286 + return value.substring(0, 1).toUpperCase(); 287 + } 288 + }
+137
test/features/typeahead/presentation/typeahead_text_field_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 4 + import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; 5 + import 'package:lazurite/features/typeahead/presentation/typeahead_text_field.dart'; 6 + 7 + void main() { 8 + group('TypeaheadTextField', () { 9 + testWidgets('overlay appears after typing and renders results', (tester) async { 10 + final controller = TextEditingController(); 11 + final repository = _FakeTypeaheadRepository( 12 + searchHandler: ({required String query, int limit = 10}) async { 13 + expect(query, 'alice'); 14 + expect(limit, 8); 15 + return const [TypeaheadResult(did: 'did:plc:alice', handle: 'alice.bsky.social', displayName: 'Alice')]; 16 + }, 17 + ); 18 + 19 + await tester.pumpWidget(_buildSubject(controller: controller, repository: repository, onSelected: (_) {})); 20 + 21 + await tester.enterText(find.byType(TextFormField), 'alice'); 22 + await tester.pump(const Duration(milliseconds: 20)); 23 + await tester.pumpAndSettle(); 24 + 25 + expect(find.text('Alice'), findsOneWidget); 26 + expect(find.text('@alice.bsky.social'), findsOneWidget); 27 + expect(find.byType(CircleAvatar), findsOneWidget); 28 + }); 29 + 30 + testWidgets('tap result calls onSelected and dismisses overlay', (tester) async { 31 + final controller = TextEditingController(); 32 + TypeaheadResult? selected; 33 + 34 + final repository = _FakeTypeaheadRepository( 35 + searchHandler: ({required String query, int limit = 10}) async { 36 + return const [TypeaheadResult(did: 'did:plc:alice', handle: 'alice.bsky.social', displayName: 'Alice')]; 37 + }, 38 + ); 39 + 40 + await tester.pumpWidget( 41 + _buildSubject(controller: controller, repository: repository, onSelected: (result) => selected = result), 42 + ); 43 + 44 + await tester.enterText(find.byType(TextFormField), 'alice'); 45 + await tester.pump(const Duration(milliseconds: 20)); 46 + await tester.pumpAndSettle(); 47 + 48 + await tester.tap(find.text('Alice')); 49 + await tester.pumpAndSettle(); 50 + 51 + expect(selected, isNotNull); 52 + expect(selected!.did, 'did:plc:alice'); 53 + expect(find.text('Alice'), findsNothing); 54 + }); 55 + 56 + testWidgets('tap outside dismisses overlay', (tester) async { 57 + final controller = TextEditingController(); 58 + final repository = _FakeTypeaheadRepository( 59 + searchHandler: ({required String query, int limit = 10}) async { 60 + return const [TypeaheadResult(did: 'did:plc:alice', handle: 'alice.bsky.social', displayName: 'Alice')]; 61 + }, 62 + ); 63 + 64 + await tester.pumpWidget(_buildSubject(controller: controller, repository: repository, onSelected: (_) {})); 65 + 66 + await tester.enterText(find.byType(TextFormField), 'alice'); 67 + await tester.pump(const Duration(milliseconds: 20)); 68 + await tester.pumpAndSettle(); 69 + 70 + expect(find.text('Alice'), findsOneWidget); 71 + 72 + await tester.tapAt(const Offset(10, 500)); 73 + await tester.pumpAndSettle(); 74 + 75 + expect(find.text('Alice'), findsNothing); 76 + }); 77 + 78 + testWidgets('does not query when input is shorter than minChars', (tester) async { 79 + final controller = TextEditingController(); 80 + var queryCount = 0; 81 + 82 + final repository = _FakeTypeaheadRepository( 83 + searchHandler: ({required String query, int limit = 10}) async { 84 + queryCount += 1; 85 + return const []; 86 + }, 87 + ); 88 + 89 + await tester.pumpWidget(_buildSubject(controller: controller, repository: repository, onSelected: (_) {})); 90 + 91 + await tester.enterText(find.byType(TextFormField), 'a'); 92 + await tester.pump(const Duration(milliseconds: 30)); 93 + await tester.pumpAndSettle(); 94 + 95 + expect(queryCount, 0); 96 + }); 97 + }); 98 + } 99 + 100 + Widget _buildSubject({ 101 + required TextEditingController controller, 102 + required TypeaheadRepository repository, 103 + required ValueChanged<TypeaheadResult> onSelected, 104 + }) { 105 + return MaterialApp( 106 + home: Scaffold( 107 + body: Padding( 108 + padding: const EdgeInsets.all(16), 109 + child: Column( 110 + children: [ 111 + TypeaheadTextField( 112 + controller: controller, 113 + repository: repository, 114 + onSelected: onSelected, 115 + debounceMs: 1, 116 + minChars: 2, 117 + limit: 8, 118 + decoration: const InputDecoration(labelText: 'Handle'), 119 + ), 120 + const Spacer(), 121 + ], 122 + ), 123 + ), 124 + ), 125 + ); 126 + } 127 + 128 + class _FakeTypeaheadRepository extends TypeaheadRepository { 129 + _FakeTypeaheadRepository({required this.searchHandler}) : super(provider: TypeaheadRepository.communityProvider); 130 + 131 + final Future<List<TypeaheadResult>> Function({required String query, int limit}) searchHandler; 132 + 133 + @override 134 + Future<List<TypeaheadResult>> search({required String query, int limit = 10}) { 135 + return searchHandler(query: query, limit: limit); 136 + } 137 + }