[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: oauth typeahead

closes #114

+693 -126
+2 -2
lib/src/core/l10n/app_localizations.dart
··· 667 667 /// Already have an account button text 668 668 /// 669 669 /// In en, this message translates to: 670 - /// **'I already have an account'** 670 + /// **'I have an Atmosphere Account'** 671 671 String get buttonHaveAccount; 672 672 673 673 /// Open button text ··· 997 997 /// Message to enter handle for OAuth 998 998 /// 999 999 /// In en, this message translates to: 1000 - /// **'Enter your handle to continue with OAuth'** 1000 + /// **'Enter your Atmosphere Account handle to sign in.'** 1001 1001 String get messageEnterHandle; 1002 1002 1003 1003 /// Title for auth recovery page when a saved session could not be verified
+3 -2
lib/src/core/l10n/app_localizations_en.dart
··· 306 306 String get buttonGetStarted => 'Get Started'; 307 307 308 308 @override 309 - String get buttonHaveAccount => 'I already have an account'; 309 + String get buttonHaveAccount => 'I have an Atmosphere Account'; 310 310 311 311 @override 312 312 String get buttonOpen => 'Open'; ··· 485 485 String get pageTitleSignIn => 'Sign In'; 486 486 487 487 @override 488 - String get messageEnterHandle => 'Enter your handle to continue with OAuth'; 488 + String get messageEnterHandle => 489 + 'Enter your Atmosphere Account handle to sign in.'; 489 490 490 491 @override 491 492 String get pageTitleSignInAgain => 'Sign in again';
+2 -2
lib/src/core/l10n/intl_en.arb
··· 497 497 "description": "Get started button text" 498 498 }, 499 499 500 - "buttonHaveAccount": "I already have an account", 500 + "buttonHaveAccount": "I have an Atmosphere Account", 501 501 "@buttonHaveAccount": { 502 502 "description": "Already have an account button text" 503 503 }, ··· 792 792 "description": "Sign in page title" 793 793 }, 794 794 795 - "messageEnterHandle": "Enter your handle to continue with OAuth", 795 + "messageEnterHandle": "Enter your Atmosphere Account handle to sign in.", 796 796 "@messageEnterHandle": { 797 797 "description": "Message to enter handle for OAuth" 798 798 },
+39 -3
lib/src/core/network/atproto/data/repositories/actor_repository_impl.dart
··· 4 4 import 'package:bluesky/bluesky.dart' as bsky; 5 5 import 'package:get_it/get_it.dart'; 6 6 import 'package:http/http.dart' as http; 7 + import 'package:spark/src/core/config/app_config.dart'; 7 8 import 'package:spark/src/core/network/atproto/data/adapters/bsky/actor_adapter.dart'; 8 9 import 'package:spark/src/core/network/atproto/data/models/models.dart'; 9 10 import 'package:spark/src/core/network/atproto/data/repositories/actor_repository.dart'; ··· 121 122 }) async { 122 123 _logger.d('Searching actor typeahead with query: $query, limit: $limit'); 123 124 return _client.executeWithRetry(() async { 125 + final clampedLimit = limit.clamp(1, 100); 124 126 final atproto = _client.authRepository.atproto; 125 127 if (atproto == null) { 126 - _logger.e('AtProto not initialized'); 127 - throw Exception('AtProto not initialized'); 128 + return _searchActorsTypeaheadFromAppView(query, limit: clampedLimit); 128 129 } 129 130 130 - final clampedLimit = limit.clamp(1, 100); 131 131 final result = await atproto.get( 132 132 NSID.parse('so.sprk.actor.searchActorsTypeahead'), 133 133 parameters: {'q': query, 'limit': clampedLimit.toString()}, ··· 143 143 result.data as Map<String, dynamic>, 144 144 ); 145 145 }); 146 + } 147 + 148 + Future<SearchActorsTypeaheadResponse> _searchActorsTypeaheadFromAppView( 149 + String query, { 150 + required int limit, 151 + }) async { 152 + final uri = _appViewXrpcUri( 153 + 'so.sprk.actor.searchActorsTypeahead', 154 + queryParameters: {'q': query, 'limit': limit.toString()}, 155 + ); 156 + final response = await http.get(uri); 157 + 158 + if (response.statusCode != 200) { 159 + throw Exception( 160 + 'Actor typeahead failed with status ${response.statusCode}', 161 + ); 162 + } 163 + 164 + return SearchActorsTypeaheadResponse.fromJson( 165 + jsonDecode(response.body) as Map<String, dynamic>, 166 + ); 167 + } 168 + 169 + Uri _appViewXrpcUri( 170 + String nsid, { 171 + required Map<String, String> queryParameters, 172 + }) { 173 + final baseUri = Uri.parse(AppConfig.appViewUrl); 174 + final basePath = baseUri.path.endsWith('/') 175 + ? baseUri.path.substring(0, baseUri.path.length - 1) 176 + : baseUri.path; 177 + 178 + return baseUri.replace( 179 + path: '$basePath/xrpc/$nsid', 180 + queryParameters: queryParameters, 181 + ); 146 182 } 147 183 148 184 @override
+647 -117
lib/src/features/auth/ui/pages/login_page.dart
··· 1 + import 'dart:async'; 2 + 1 3 import 'package:auto_route/auto_route.dart'; 2 4 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 3 5 import 'package:flutter/material.dart'; ··· 8 10 import 'package:spark/src/core/design_system/components/atoms/buttons/long_button.dart'; 9 11 import 'package:spark/src/core/design_system/tokens/typography.dart'; 10 12 import 'package:spark/src/core/l10n/app_localizations.dart'; 13 + import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; 11 14 import 'package:spark/src/core/routing/app_router.dart'; 12 15 import 'package:spark/src/features/auth/providers/auth_providers.dart'; 13 16 import 'package:spark/src/features/auth/providers/onboarding_providers.dart'; 17 + import 'package:spark/src/features/search/providers/actor_typeahead_provider.dart'; 18 + import 'package:spark/src/features/search/providers/actor_typeahead_state.dart'; 14 19 import 'package:spark/src/features/settings/providers/settings_provider.dart'; 15 20 16 21 @RoutePage() ··· 25 30 final _handleController = TextEditingController(); 26 31 final _formKey = GlobalKey<FormState>(); 27 32 final _handleFocusNode = FocusNode(); 33 + final _handleFieldKey = GlobalKey(); 34 + final _handleSuggestionsLayerLink = LayerLink(); 35 + late final ActorTypeahead _actorTypeaheadNotifier; 28 36 bool _hasReceivedCallback = false; 29 37 bool _isCompletingOAuth = false; 38 + bool _showHandleSuggestions = false; 30 39 31 40 @override 32 41 void initState() { 33 42 super.initState(); 43 + _actorTypeaheadNotifier = ref.read(actorTypeaheadProvider.notifier); 44 + _handleController.addListener(_onHandleChanged); 45 + _handleFocusNode.addListener(_onHandleFocusChanged); 34 46 WidgetsBinding.instance.addPostFrameCallback((_) { 35 47 TextInput.ensureInitialized(); 36 48 }); ··· 38 50 39 51 @override 40 52 void dispose() { 53 + _actorTypeaheadNotifier.clear(); 54 + _handleController.removeListener(_onHandleChanged); 55 + _handleFocusNode.removeListener(_onHandleFocusChanged); 41 56 _handleController.dispose(); 42 57 _handleFocusNode.dispose(); 43 58 super.dispose(); 44 59 } 45 60 61 + void _onHandleChanged() { 62 + if (!_handleFocusNode.hasFocus || _hasReceivedCallback) { 63 + return; 64 + } 65 + 66 + final isLoading = ref.read(authProvider.select((state) => state.isLoading)); 67 + if (isLoading || _isCompletingOAuth) { 68 + _hideHandleSuggestions(clearTypeahead: true); 69 + return; 70 + } 71 + 72 + final query = _handleController.text.trim(); 73 + if (query.isEmpty) { 74 + _hideHandleSuggestions(clearTypeahead: true); 75 + return; 76 + } 77 + 78 + _actorTypeaheadNotifier.updateQuery(query); 79 + if (!_showHandleSuggestions) { 80 + setState(() { 81 + _showHandleSuggestions = true; 82 + }); 83 + } 84 + } 85 + 86 + void _onHandleFocusChanged() { 87 + if (!_handleFocusNode.hasFocus) { 88 + _hideHandleSuggestions(clearTypeahead: true); 89 + return; 90 + } 91 + 92 + _onHandleChanged(); 93 + } 94 + 95 + void _hideHandleSuggestions({required bool clearTypeahead}) { 96 + if (clearTypeahead) { 97 + _actorTypeaheadNotifier.clear(); 98 + } 99 + 100 + if (!_showHandleSuggestions) { 101 + return; 102 + } 103 + 104 + setState(() { 105 + _showHandleSuggestions = false; 106 + }); 107 + } 108 + 109 + Future<void> _onHandleSuggestionSelected(ProfileViewBasic actor) async { 110 + _handleController.text = actor.handle; 111 + _handleController.selection = TextSelection.collapsed( 112 + offset: actor.handle.length, 113 + ); 114 + _hideHandleSuggestions(clearTypeahead: true); 115 + _handleFocusNode.unfocus(); 116 + await _initiateOAuth(); 117 + } 118 + 46 119 Future<void> _initiateOAuth() async { 47 120 if (_formKey.currentState?.validate() ?? false) { 48 121 final authNotifier = ref.read(authProvider.notifier); 49 122 final handle = _handleController.text.trim(); 50 123 51 124 try { 125 + _hideHandleSuggestions(clearTypeahead: true); 126 + _handleFocusNode.unfocus(); 127 + 52 128 // Initiate OAuth flow - this returns the authorization URL 53 129 final authUrl = await authNotifier.initiateOAuth(handle); 54 130 ··· 138 214 authProvider.select((state) => state.isLoading), 139 215 ); 140 216 final error = ref.watch(authProvider.select((state) => state.error)); 217 + final typeaheadState = ref.watch(actorTypeaheadProvider); 141 218 final theme = Theme.of(context); 142 219 final colorScheme = theme.colorScheme; 220 + final showHandleSuggestions = 221 + !isLoading && 222 + !_hasReceivedCallback && 223 + !_isCompletingOAuth && 224 + _showHandleSuggestions && 225 + typeaheadState.results.isNotEmpty; 226 + final reduceMotion = MediaQuery.of(context).disableAnimations; 227 + final handleFieldWidth = MediaQuery.sizeOf(context).width - 48; 143 228 144 229 return Scaffold( 145 230 backgroundColor: colorScheme.surface, 146 231 body: Stack( 147 232 children: [ 148 233 SafeArea( 149 - child: Center( 150 - child: SingleChildScrollView( 151 - padding: const EdgeInsets.all(24), 152 - child: Form( 153 - key: _formKey, 154 - child: Column( 155 - mainAxisAlignment: MainAxisAlignment.center, 156 - crossAxisAlignment: CrossAxisAlignment.stretch, 157 - children: [ 158 - if (!_hasReceivedCallback) ...[ 159 - Text( 160 - l10n.pageTitleSignIn, 161 - style: AppTypography.displaySmallBold.copyWith( 162 - color: colorScheme.onSurface, 163 - ), 164 - textAlign: TextAlign.center, 165 - ), 166 - const SizedBox(height: 8), 167 - Text( 168 - l10n.messageEnterHandle, 169 - style: AppTypography.textMediumMedium.copyWith( 170 - color: colorScheme.onSurfaceVariant, 171 - ), 172 - textAlign: TextAlign.center, 173 - ), 174 - const SizedBox(height: 32), 175 - ], 176 - 177 - if (!_hasReceivedCallback) ...[ 178 - TextFormField( 179 - controller: _handleController, 180 - focusNode: _handleFocusNode, 181 - enabled: !isLoading, 182 - decoration: InputDecoration( 183 - hintText: 'jerry.sprk.so', 184 - prefixIcon: Icon( 185 - FluentIcons.person_24_regular, 186 - color: colorScheme.primary, 187 - ), 188 - filled: true, 189 - fillColor: colorScheme.surface, 190 - contentPadding: const EdgeInsets.symmetric( 191 - horizontal: 16, 192 - vertical: 12, 193 - ), 194 - enabledBorder: OutlineInputBorder( 195 - borderRadius: BorderRadius.circular(8), 196 - borderSide: BorderSide( 197 - color: colorScheme.outline, 234 + child: Column( 235 + children: [ 236 + Expanded( 237 + child: Center( 238 + child: SingleChildScrollView( 239 + padding: const EdgeInsets.fromLTRB(24, 24, 24, 16), 240 + child: Form( 241 + key: _formKey, 242 + child: Column( 243 + mainAxisAlignment: MainAxisAlignment.center, 244 + crossAxisAlignment: CrossAxisAlignment.stretch, 245 + children: [ 246 + if (!_hasReceivedCallback) ...[ 247 + Text( 248 + l10n.pageTitleSignIn, 249 + style: AppTypography.displaySmallBold.copyWith( 250 + color: colorScheme.onSurface, 251 + ), 252 + textAlign: TextAlign.center, 198 253 ), 199 - ), 200 - focusedBorder: OutlineInputBorder( 201 - borderRadius: BorderRadius.circular(8), 202 - borderSide: BorderSide( 203 - color: colorScheme.primary, 254 + const SizedBox(height: 8), 255 + Text( 256 + l10n.messageEnterHandle, 257 + style: AppTypography.textMediumMedium.copyWith( 258 + color: colorScheme.onSurfaceVariant, 259 + ), 260 + textAlign: TextAlign.center, 204 261 ), 205 - ), 206 - errorBorder: OutlineInputBorder( 207 - borderRadius: BorderRadius.circular(8), 208 - borderSide: BorderSide(color: colorScheme.error), 209 - ), 210 - focusedErrorBorder: OutlineInputBorder( 211 - borderRadius: BorderRadius.circular(8), 212 - borderSide: BorderSide(color: colorScheme.error), 213 - ), 214 - ), 215 - style: AppTypography.textMediumMedium.copyWith( 216 - color: colorScheme.onSurface, 217 - ), 218 - textInputAction: TextInputAction.done, 219 - keyboardType: TextInputType.emailAddress, 220 - autofillHints: const [ 221 - AutofillHints.username, 222 - AutofillHints.email, 223 - ], 224 - onEditingComplete: _initiateOAuth, 225 - ), 226 - const SizedBox(height: 24), 227 - ], 262 + const SizedBox(height: 32), 263 + ], 228 264 229 - if (error != null) 230 - Padding( 231 - padding: const EdgeInsets.only(bottom: 16), 232 - child: Text( 233 - switch (error) { 234 - final String e 235 - when e.contains('must be a valid handle') => 236 - l10n.errorInvalidHandle, 237 - final String e 238 - when e.contains('Failed to resolve') => 239 - l10n.errorHandleNotFound, 240 - _ => error, 241 - }, 242 - style: AppTypography.textSmallMedium.copyWith( 243 - color: colorScheme.error, 244 - ), 245 - textAlign: TextAlign.center, 246 - ), 247 - ), 265 + if (!_hasReceivedCallback) ...[ 266 + CompositedTransformTarget( 267 + link: _handleSuggestionsLayerLink, 268 + child: TextFormField( 269 + key: _handleFieldKey, 270 + controller: _handleController, 271 + focusNode: _handleFocusNode, 272 + enabled: !isLoading, 273 + decoration: InputDecoration( 274 + hintText: 'jerry.sprk.so', 275 + prefixIcon: Icon( 276 + FluentIcons.person_24_regular, 277 + color: colorScheme.primary, 278 + ), 279 + filled: true, 280 + fillColor: colorScheme.surface, 281 + contentPadding: const EdgeInsets.symmetric( 282 + horizontal: 16, 283 + vertical: 12, 284 + ), 285 + enabledBorder: OutlineInputBorder( 286 + borderRadius: BorderRadius.circular(8), 287 + borderSide: BorderSide( 288 + color: colorScheme.outline, 289 + ), 290 + ), 291 + focusedBorder: OutlineInputBorder( 292 + borderRadius: BorderRadius.circular(8), 293 + borderSide: BorderSide( 294 + color: colorScheme.primary, 295 + ), 296 + ), 297 + errorBorder: OutlineInputBorder( 298 + borderRadius: BorderRadius.circular(8), 299 + borderSide: BorderSide( 300 + color: colorScheme.error, 301 + ), 302 + ), 303 + focusedErrorBorder: OutlineInputBorder( 304 + borderRadius: BorderRadius.circular(8), 305 + borderSide: BorderSide( 306 + color: colorScheme.error, 307 + ), 308 + ), 309 + ), 310 + style: AppTypography.textMediumMedium 311 + .copyWith(color: colorScheme.onSurface), 312 + textInputAction: TextInputAction.done, 313 + keyboardType: TextInputType.emailAddress, 314 + autofillHints: const [ 315 + AutofillHints.username, 316 + AutofillHints.email, 317 + ], 318 + onEditingComplete: _initiateOAuth, 319 + ), 320 + ), 321 + ], 322 + 323 + if (error != null) 324 + Padding( 325 + padding: const EdgeInsets.only(top: 16), 326 + child: Text( 327 + switch (error) { 328 + final String e 329 + when e.contains( 330 + 'must be a valid handle', 331 + ) => 332 + l10n.errorInvalidHandle, 333 + final String e 334 + when e.contains('Failed to resolve') => 335 + l10n.errorHandleNotFound, 336 + _ => error, 337 + }, 338 + style: AppTypography.textSmallMedium.copyWith( 339 + color: colorScheme.error, 340 + ), 341 + textAlign: TextAlign.center, 342 + ), 343 + ), 248 344 249 - if (_isCompletingOAuth) 250 - Column( 251 - children: [ 252 - const CircularProgressIndicator(), 253 - const SizedBox(height: 16), 254 - Text( 255 - l10n.errorCompletingSignIn, 256 - style: AppTypography.textMediumMedium.copyWith( 257 - color: colorScheme.onSurfaceVariant, 345 + if (_isCompletingOAuth) 346 + Padding( 347 + padding: const EdgeInsets.only(top: 24), 348 + child: Column( 349 + children: [ 350 + const CircularProgressIndicator(), 351 + const SizedBox(height: 16), 352 + Text( 353 + l10n.errorCompletingSignIn, 354 + style: AppTypography.textMediumMedium 355 + .copyWith( 356 + color: colorScheme.onSurfaceVariant, 357 + ), 358 + textAlign: TextAlign.center, 359 + ), 360 + ], 361 + ), 258 362 ), 259 - textAlign: TextAlign.center, 260 - ), 261 363 ], 262 - ) 263 - else if (!_hasReceivedCallback) 264 - Opacity( 265 - opacity: isLoading ? 0.5 : 1.0, 266 - child: LongButton( 267 - label: l10n.buttonContinue, 268 - onPressed: isLoading ? null : _initiateOAuth, 269 - ), 270 364 ), 271 - ], 365 + ), 366 + ), 272 367 ), 273 368 ), 274 - ), 369 + if (!_hasReceivedCallback && !_isCompletingOAuth) 370 + Padding( 371 + padding: const EdgeInsets.fromLTRB(24, 12, 24, 24), 372 + child: AnimatedOpacity( 373 + duration: reduceMotion 374 + ? Duration.zero 375 + : const Duration(milliseconds: 140), 376 + opacity: isLoading ? 0.5 : 1.0, 377 + child: LongButton( 378 + label: l10n.buttonContinue, 379 + onPressed: isLoading ? null : _initiateOAuth, 380 + ), 381 + ), 382 + ), 383 + ], 275 384 ), 276 385 ), 277 386 // Back button in top-left corner ··· 280 389 left: 0, 281 390 child: AppOverlayBackButton(color: colorScheme.onSurface), 282 391 ), 392 + if (showHandleSuggestions) 393 + Positioned.fill( 394 + child: CompositedTransformFollower( 395 + link: _handleSuggestionsLayerLink, 396 + showWhenUnlinked: false, 397 + targetAnchor: Alignment.bottomLeft, 398 + followerAnchor: Alignment.topLeft, 399 + offset: const Offset(0, 10), 400 + child: Align( 401 + alignment: Alignment.topLeft, 402 + child: SizedBox( 403 + width: handleFieldWidth, 404 + child: _LoginHandleSuggestions( 405 + state: typeaheadState, 406 + reduceMotion: reduceMotion, 407 + onSuggestionSelected: _onHandleSuggestionSelected, 408 + ), 409 + ), 410 + ), 411 + ), 412 + ), 283 413 ], 284 414 ), 285 415 ); 286 416 } 287 417 } 418 + 419 + class _LoginHandleSuggestions extends StatefulWidget { 420 + const _LoginHandleSuggestions({ 421 + required this.state, 422 + required this.reduceMotion, 423 + required this.onSuggestionSelected, 424 + }); 425 + 426 + final ActorTypeaheadState state; 427 + final bool reduceMotion; 428 + final ValueChanged<ProfileViewBasic> onSuggestionSelected; 429 + 430 + @override 431 + State<_LoginHandleSuggestions> createState() => 432 + _LoginHandleSuggestionsState(); 433 + } 434 + 435 + class _LoginHandleSuggestionsState extends State<_LoginHandleSuggestions> { 436 + static const _rowMorphDuration = Duration(milliseconds: 180); 437 + 438 + int _nextSlotId = 0; 439 + late List<_SuggestionSlot> _slots = _createSlots(widget.state.results); 440 + Timer? _enteringTimer; 441 + 442 + @override 443 + void initState() { 444 + super.initState(); 445 + _clearEnteringSoon(); 446 + } 447 + 448 + @override 449 + void didUpdateWidget(covariant _LoginHandleSuggestions oldWidget) { 450 + super.didUpdateWidget(oldWidget); 451 + 452 + final nextActors = widget.state.results; 453 + if (_sameActors(_slots.map((slot) => slot.actor).toList(), nextActors)) { 454 + return; 455 + } 456 + 457 + setState(() { 458 + for (var index = 0; index < nextActors.length; index++) { 459 + if (index < _slots.length) { 460 + _slots[index] = _slots[index].copyWith( 461 + actor: nextActors[index], 462 + isEntering: false, 463 + isRemoving: false, 464 + ); 465 + continue; 466 + } 467 + 468 + _slots.add( 469 + _SuggestionSlot( 470 + id: _nextSlotId++, 471 + actor: nextActors[index], 472 + isEntering: true, 473 + ), 474 + ); 475 + } 476 + 477 + for (var index = nextActors.length; index < _slots.length; index++) { 478 + _slots[index] = _slots[index].copyWith( 479 + isEntering: false, 480 + isRemoving: true, 481 + ); 482 + } 483 + }); 484 + 485 + if (widget.reduceMotion) { 486 + _pruneRemovedSlots(); 487 + return; 488 + } 489 + 490 + _clearEnteringSoon(); 491 + } 492 + 493 + @override 494 + void dispose() { 495 + _enteringTimer?.cancel(); 496 + super.dispose(); 497 + } 498 + 499 + List<_SuggestionSlot> _createSlots(List<ProfileViewBasic> actors) { 500 + return actors 501 + .map( 502 + (actor) => _SuggestionSlot( 503 + id: _nextSlotId++, 504 + actor: actor, 505 + isEntering: true, 506 + ), 507 + ) 508 + .toList(); 509 + } 510 + 511 + bool _sameActors( 512 + List<ProfileViewBasic> previous, 513 + List<ProfileViewBasic> next, 514 + ) { 515 + if (previous.length != next.length) { 516 + return false; 517 + } 518 + 519 + for (var index = 0; index < previous.length; index++) { 520 + if (previous[index].did != next[index].did) { 521 + return false; 522 + } 523 + } 524 + 525 + return true; 526 + } 527 + 528 + void _clearEnteringSoon() { 529 + _enteringTimer?.cancel(); 530 + if (widget.reduceMotion) { 531 + return; 532 + } 533 + 534 + _enteringTimer = Timer(const Duration(milliseconds: 260), () { 535 + if (!mounted) { 536 + return; 537 + } 538 + 539 + setState(() { 540 + _slots = [ 541 + for (final slot in _slots) 542 + if (!slot.isRemoving) slot.copyWith(isEntering: false), 543 + ]; 544 + }); 545 + }); 546 + } 547 + 548 + void _pruneRemovedSlots() { 549 + if (!mounted || !_slots.any((slot) => slot.isRemoving)) { 550 + return; 551 + } 552 + 553 + setState(() { 554 + _slots = [ 555 + for (final slot in _slots) 556 + if (!slot.isRemoving) slot, 557 + ]; 558 + }); 559 + } 560 + 561 + @override 562 + Widget build(BuildContext context) { 563 + final theme = Theme.of(context); 564 + final colorScheme = theme.colorScheme; 565 + 566 + if (widget.state.query.isEmpty) { 567 + return const SizedBox.shrink(); 568 + } 569 + 570 + if (_slots.isEmpty) { 571 + return const SizedBox.shrink(); 572 + } 573 + 574 + final panel = AnimatedSize( 575 + duration: widget.reduceMotion 576 + ? Duration.zero 577 + : const Duration(milliseconds: 190), 578 + curve: Curves.easeOutCubic, 579 + alignment: Alignment.topCenter, 580 + child: Container( 581 + constraints: const BoxConstraints(maxHeight: 252), 582 + clipBehavior: Clip.antiAlias, 583 + decoration: BoxDecoration( 584 + color: colorScheme.surface, 585 + borderRadius: BorderRadius.circular(12), 586 + border: Border.all(color: colorScheme.outline.withAlpha(96)), 587 + boxShadow: [ 588 + BoxShadow( 589 + color: colorScheme.shadow.withAlpha(18), 590 + blurRadius: 18, 591 + offset: const Offset(0, 8), 592 + ), 593 + ], 594 + ), 595 + child: ListView.separated( 596 + shrinkWrap: true, 597 + padding: const EdgeInsets.symmetric(vertical: 6), 598 + itemCount: _slots.length, 599 + separatorBuilder: (context, index) => 600 + Divider(height: 1, color: colorScheme.outline.withAlpha(48)), 601 + itemBuilder: (context, index) { 602 + final slot = _slots[index]; 603 + return _AnimatedSuggestionSlot( 604 + key: ValueKey(slot.id), 605 + actor: slot.actor, 606 + isEntering: slot.isEntering, 607 + isRemoving: slot.isRemoving, 608 + reduceMotion: widget.reduceMotion, 609 + onRemoved: _pruneRemovedSlots, 610 + onTap: () => widget.onSuggestionSelected(slot.actor), 611 + ); 612 + }, 613 + ), 614 + ), 615 + ); 616 + 617 + if (widget.reduceMotion) { 618 + return panel; 619 + } 620 + 621 + return TweenAnimationBuilder<double>( 622 + duration: const Duration(milliseconds: 180), 623 + curve: Curves.easeOutCubic, 624 + tween: Tween<double>(begin: 0, end: 1), 625 + builder: (context, value, child) { 626 + return Opacity( 627 + opacity: value, 628 + child: Transform.scale( 629 + alignment: Alignment.topCenter, 630 + scaleY: 0.96 + (0.04 * value), 631 + child: Transform.translate( 632 + offset: Offset(0, (value - 1) * 4), 633 + child: child, 634 + ), 635 + ), 636 + ); 637 + }, 638 + child: panel, 639 + ); 640 + } 641 + } 642 + 643 + class _SuggestionSlot { 644 + const _SuggestionSlot({ 645 + required this.id, 646 + required this.actor, 647 + this.isEntering = false, 648 + this.isRemoving = false, 649 + }); 650 + 651 + final int id; 652 + final ProfileViewBasic actor; 653 + final bool isEntering; 654 + final bool isRemoving; 655 + 656 + _SuggestionSlot copyWith({ 657 + ProfileViewBasic? actor, 658 + bool? isEntering, 659 + bool? isRemoving, 660 + }) { 661 + return _SuggestionSlot( 662 + id: id, 663 + actor: actor ?? this.actor, 664 + isEntering: isEntering ?? this.isEntering, 665 + isRemoving: isRemoving ?? this.isRemoving, 666 + ); 667 + } 668 + } 669 + 670 + class _AnimatedSuggestionSlot extends StatelessWidget { 671 + const _AnimatedSuggestionSlot({ 672 + required this.actor, 673 + required this.isEntering, 674 + required this.isRemoving, 675 + required this.reduceMotion, 676 + required this.onRemoved, 677 + required this.onTap, 678 + super.key, 679 + }); 680 + 681 + final ProfileViewBasic actor; 682 + final bool isEntering; 683 + final bool isRemoving; 684 + final bool reduceMotion; 685 + final VoidCallback onRemoved; 686 + final VoidCallback onTap; 687 + 688 + @override 689 + Widget build(BuildContext context) { 690 + final tile = AnimatedSwitcher( 691 + duration: reduceMotion 692 + ? Duration.zero 693 + : _LoginHandleSuggestionsState._rowMorphDuration, 694 + switchInCurve: Curves.easeOutCubic, 695 + switchOutCurve: Curves.easeOutCubic, 696 + layoutBuilder: (currentChild, previousChildren) { 697 + return Stack( 698 + alignment: Alignment.centerLeft, 699 + children: [...previousChildren, ?currentChild], 700 + ); 701 + }, 702 + transitionBuilder: (child, animation) { 703 + return FadeTransition(opacity: animation, child: child); 704 + }, 705 + child: _LoginHandleSuggestionTile( 706 + key: ValueKey(actor.did), 707 + actor: actor, 708 + onTap: onTap, 709 + ), 710 + ); 711 + 712 + if (reduceMotion) { 713 + return isRemoving ? const SizedBox.shrink() : tile; 714 + } 715 + 716 + return TweenAnimationBuilder<double>( 717 + duration: _LoginHandleSuggestionsState._rowMorphDuration, 718 + curve: Curves.easeOutCubic, 719 + tween: Tween<double>(begin: 0, end: isRemoving ? 0 : 1), 720 + onEnd: isRemoving ? onRemoved : null, 721 + builder: (context, value, child) { 722 + final enteringOffset = isEntering ? (value - 1) * 8 : 0.0; 723 + final removingOffset = isRemoving ? (1 - value) * -8 : 0.0; 724 + 725 + return ClipRect( 726 + child: Align( 727 + alignment: Alignment.topCenter, 728 + heightFactor: value.clamp(0.0, 1.0), 729 + child: Opacity( 730 + opacity: value, 731 + child: Transform.translate( 732 + offset: Offset(0, enteringOffset + removingOffset), 733 + child: child, 734 + ), 735 + ), 736 + ), 737 + ); 738 + }, 739 + child: tile, 740 + ); 741 + } 742 + } 743 + 744 + class _LoginHandleSuggestionTile extends StatelessWidget { 745 + const _LoginHandleSuggestionTile({ 746 + required this.actor, 747 + required this.onTap, 748 + super.key, 749 + }); 750 + 751 + final ProfileViewBasic actor; 752 + final VoidCallback onTap; 753 + 754 + @override 755 + Widget build(BuildContext context) { 756 + final colorScheme = Theme.of(context).colorScheme; 757 + 758 + return Material( 759 + color: Colors.transparent, 760 + child: InkWell( 761 + onTap: onTap, 762 + child: Padding( 763 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), 764 + child: Row( 765 + children: [ 766 + CircleAvatar( 767 + radius: 18, 768 + backgroundColor: colorScheme.surfaceContainerHighest, 769 + backgroundImage: actor.avatar != null 770 + ? NetworkImage(actor.avatar.toString()) 771 + : null, 772 + child: actor.avatar == null 773 + ? Icon( 774 + FluentIcons.person_24_regular, 775 + size: 18, 776 + color: colorScheme.onSurfaceVariant, 777 + ) 778 + : null, 779 + ), 780 + const SizedBox(width: 12), 781 + Expanded( 782 + child: Column( 783 + crossAxisAlignment: CrossAxisAlignment.start, 784 + mainAxisSize: MainAxisSize.min, 785 + children: [ 786 + Text( 787 + actor.displayName ?? actor.handle, 788 + maxLines: 1, 789 + overflow: TextOverflow.ellipsis, 790 + style: AppTypography.textMediumMedium.copyWith( 791 + color: colorScheme.onSurface, 792 + ), 793 + ), 794 + const SizedBox(height: 2), 795 + Text( 796 + '@${actor.handle}', 797 + maxLines: 1, 798 + overflow: TextOverflow.ellipsis, 799 + style: AppTypography.textSmallMedium.copyWith( 800 + color: colorScheme.onSurfaceVariant, 801 + ), 802 + ), 803 + ], 804 + ), 805 + ), 806 + Icon( 807 + FluentIcons.arrow_enter_20_regular, 808 + size: 18, 809 + color: colorScheme.onSurfaceVariant.withAlpha(180), 810 + ), 811 + ], 812 + ), 813 + ), 814 + ), 815 + ); 816 + } 817 + }