[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.

Search users (#20)

* add user search impl

* tab thing on user search

* better search input

* tap to open user profile

* handle follow thing

* FIxed the bug on follow

---------

Co-authored-by: C3B <joaodavisn@gmail.com>

authored by

Davi Rodrigues
C3B
and committed by
GitHub
4b69cfa1 6f17f8d9

+283 -238
+212 -230
lib/screens/search_screen.dart
··· 1 + import 'dart:async'; 2 + 1 3 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 2 4 import 'package:flutter/material.dart'; 3 5 import 'package:provider/provider.dart'; 4 6 7 + import '../screens/profile_screen.dart'; 8 + import '../services/actions_service.dart'; 9 + import '../services/auth_service.dart'; 10 + import '../services/sprk_client.dart'; 5 11 import '../utils/app_colors.dart'; 6 12 import '../utils/app_theme.dart'; 7 - import '../widgets/common/development_overlay.dart'; 8 - import '../widgets/search/category_chip.dart'; 9 - import '../widgets/search/section_header.dart'; 10 - import '../widgets/search/sound_card.dart'; 11 - import '../widgets/search/story_circle.dart'; 12 13 import '../widgets/search/suggested_account_card.dart'; 13 - import '../widgets/search/trending_video_card.dart'; 14 - import '../services/actions_service.dart'; 15 - import '../services/profile_service.dart'; 16 - import '../screens/profile_screen.dart'; 17 14 18 15 class SearchScreen extends StatefulWidget { 19 16 const SearchScreen({super.key}); ··· 24 21 25 22 class _SearchScreenState extends State<SearchScreen> { 26 23 final TextEditingController _searchController = TextEditingController(); 24 + Timer? _debounce; 25 + List<dynamic> _searchResults = []; 26 + bool _isLoading = false; 27 + String _error = ''; 27 28 28 - final List<Map<String, dynamic>> _stories = [ 29 - {'username': 'Your Story', 'imageUrl': 'https://randomuser.me/api/portraits/men/32.jpg', 'isYourStory': true}, 30 - {'username': 'Michelle', 'imageUrl': 'https://randomuser.me/api/portraits/women/44.jpg', 'isLive': true}, 31 - {'username': 'Frank Koo', 'imageUrl': 'https://randomuser.me/api/portraits/men/86.jpg'}, 32 - { 33 - 'username': 'itsdoggo', 34 - 'imageUrl': 'https://images.unsplash.com/photo-1552053831-71594a27632d?q=80&w=162&auto=format&fit=crop', 35 - }, 36 - { 37 - 'username': 'catmeows', 38 - 'imageUrl': 'https://images.unsplash.com/photo-1573865526739-10659fec78a5?q=80&w=150&auto=format&fit=crop', 39 - }, 40 - ]; 29 + Future<void> _searchUsers(String query) async { 30 + if (query.isEmpty) { 31 + setState(() { 32 + _searchResults = []; 33 + _error = ''; 34 + }); 35 + return; 36 + } 37 + setState(() { 38 + _isLoading = true; 39 + _error = ''; 40 + }); 41 + try { 42 + final authService = Provider.of<AuthService>(context, listen: false); 43 + final sprkClient = SprkClient(authService); 44 + final response = await sprkClient.actor.searchActors(query); 45 + final actors = response.data['actors'] as List<dynamic>?; 46 + setState(() { 47 + _searchResults = actors ?? []; 48 + _isLoading = false; 49 + }); 50 + } catch (e) { 51 + setState(() { 52 + _error = 'Failed to search users'; 53 + _isLoading = false; 54 + }); 55 + } 56 + } 57 + 58 + void _onSearchChanged(String query) { 59 + if (_debounce?.isActive ?? false) _debounce!.cancel(); 60 + _debounce = Timer(const Duration(milliseconds: 500), () { 61 + _searchUsers(query); 62 + }); 63 + } 64 + 65 + // final List<Map<String, dynamic>> _stories = [ 66 + // {'username': 'Your Story', 'imageUrl': 'https://randomuser.me/api/portraits/men/32.jpg', 'isYourStory': true}, 67 + // {'username': 'Michelle', 'imageUrl': 'https://randomuser.me/api/portraits/women/44.jpg', 'isLive': true}, 68 + // {'username': 'Frank Koo', 'imageUrl': 'https://randomuser.me/api/portraits/men/86.jpg'}, 69 + // { 70 + // 'username': 'itsdoggo', 71 + // 'imageUrl': 'https://images.unsplash.com/photo-1552053831-71594a27632d?q=80&w=162&auto=format&fit=crop', 72 + // }, 73 + // { 74 + // 'username': 'catmeows', 75 + // 'imageUrl': 'https://images.unsplash.com/photo-1573865526739-10659fec78a5?q=80&w=150&auto=format&fit=crop', 76 + // }, 77 + // ]; 41 78 42 - final List<Map<String, dynamic>> _trendingVideos = [ 43 - { 44 - 'thumbnailUrl': 'https://images.unsplash.com/photo-1492684223066-81342ee5ff30?q=80&w=200&auto=format&fit=crop', 45 - 'viewCount': 12000000, 46 - }, 47 - { 48 - 'thumbnailUrl': 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?q=80&w=200&auto=format&fit=crop', 49 - 'viewCount': 13000000, 50 - }, 51 - { 52 - 'thumbnailUrl': 'https://images.unsplash.com/photo-1522529599102-193c0d76b5b6?q=80&w=200&auto=format&fit=crop', 53 - 'viewCount': 5000000, 54 - }, 55 - ]; 79 + // final List<Map<String, dynamic>> _trendingVideos = [ 80 + // { 81 + // 'thumbnailUrl': 'https://images.unsplash.com/photo-1492684223066-81342ee5ff30?q=80&w=200&auto=format&fit=crop', 82 + // 'viewCount': 12000000, 83 + // }, 84 + // { 85 + // 'thumbnailUrl': 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?q=80&w=200&auto=format&fit=crop', 86 + // 'viewCount': 13000000, 87 + // }, 88 + // { 89 + // 'thumbnailUrl': 'https://images.unsplash.com/photo-1522529599102-193c0d76b5b6?q=80&w=200&auto=format&fit=crop', 90 + // 'viewCount': 5000000, 91 + // }, 92 + // ]; 56 93 57 - final List<Map<String, dynamic>> _sounds = [ 58 - {'title': 'ANXIETY', 'artist': 'Sleepy Hallow', 'imageUrl': 'https://randomuser.me/api/portraits/men/40.jpg'}, 59 - {'title': 'Somebody', 'artist': 'feat. Kimbra', 'imageUrl': 'https://i.pravatar.cc/150?img=20'}, 60 - {'title': 'Good Luck, Babe!', 'artist': 'Chappell Roan', 'imageUrl': 'https://randomuser.me/api/portraits/women/25.jpg'}, 61 - {'title': 'Dancing Queen', 'artist': 'Sleepy Hallow', 'imageUrl': 'https://i.pravatar.cc/150?img=33'}, 62 - ]; 94 + // final List<Map<String, dynamic>> _sounds = [ 95 + // {'title': 'ANXIETY', 'artist': 'Sleepy Hallow', 'imageUrl': 'https://randomuser.me/api/portraits/men/40.jpg'}, 96 + // {'title': 'Somebody', 'artist': 'feat. Kimbra', 'imageUrl': 'https://i.pravatar.cc/150?img=20'}, 97 + // {'title': 'Good Luck, Babe!', 'artist': 'Chappell Roan', 'imageUrl': 'https://randomuser.me/api/portraits/women/25.jpg'}, 98 + // {'title': 'Dancing Queen', 'artist': 'Sleepy Hallow', 'imageUrl': 'https://i.pravatar.cc/150?img=33'}, 99 + // ]; 63 100 64 - final List<String> _categories = ['Sports', 'Video Games', 'Anime', 'HopeCorp', 'CoreCore', 'Fashion', 'BookSpark', 'STEM']; 101 + // final List<String> _categories = ['Sports', 'Video Games', 'Anime', 'HopeCorp', 'CoreCore', 'Fashion', 'BookSpark', 'STEM']; 65 102 66 - final List<Map<String, dynamic>> _suggestedAccounts = [ 67 - { 68 - 'username': 'Arlene McCoy', 69 - 'handle': '@yayformccoy.sprk.so', 70 - 'avatarUrl': 'https://randomuser.me/api/portraits/women/12.jpg', 71 - }, 72 - { 73 - 'username': 'Esther Howard', 74 - 'handle': '@estherhoward.sprk.so', 75 - 'avatarUrl': 'https://randomuser.me/api/portraits/women/86.jpg', 76 - }, 77 - {'username': 'Savannah Nguyen', 'handle': '@snguyen.sprk.so', 'avatarUrl': 'https://randomuser.me/api/portraits/men/54.jpg'}, 78 - {'username': 'Floyd Miles', 'handle': '@floydm.sprk.so', 'avatarUrl': 'https://randomuser.me/api/portraits/men/91.jpg'}, 79 - ]; 103 + // final List<Map<String, dynamic>> _suggestedAccounts = [ 104 + // { 105 + // 'username': 'Arlene McCoy', 106 + // 'handle': '@yayformccoy.sprk.so', 107 + // 'avatarUrl': 'https://randomuser.me/api/portraits/women/12.jpg', 108 + // }, 109 + // { 110 + // 'username': 'Esther Howard', 111 + // 'handle': '@estherhoward.sprk.so', 112 + // 'avatarUrl': 'https://randomuser.me/api/portraits/women/86.jpg', 113 + // }, 114 + // {'username': 'Savannah Nguyen', 'handle': '@snguyen.sprk.so', 'avatarUrl': 'https://randomuser.me/api/portraits/men/54.jpg'}, 115 + // {'username': 'Floyd Miles', 'handle': '@floydm.sprk.so', 'avatarUrl': 'https://randomuser.me/api/portraits/men/91.jpg'}, 116 + // ]; 80 117 81 118 @override 82 119 void dispose() { 120 + _debounce?.cancel(); 83 121 _searchController.dispose(); 84 122 super.dispose(); 85 123 } 86 124 87 - Future<void> _handleFollow(String did) async { 88 - if (!mounted) return; 89 - 90 - final actionsService = Provider.of<ActionsService>(context, listen: false); 91 - final profileService = Provider.of<ProfileService>(context, listen: false); 92 - 93 - try { 94 - // Get the current profile data to check if we're already following 95 - final currentProfile = await profileService.getProfile(did); 96 - if (currentProfile == null) { 97 - throw Exception('Could not fetch profile data'); 98 - } 99 - 100 - // Get the follow URI if we're following 101 - final followUri = currentProfile.followUri; 102 - 103 - // Toggle follow status 104 - final newFollowUri = await actionsService.toggleFollow(did, followUri); 105 - 106 - if (!mounted) return; 107 - 108 - // Show success message 109 - ScaffoldMessenger.of(context).showSnackBar( 110 - SnackBar( 111 - content: Text(newFollowUri != null ? 'Followed successfully' : 'Unfollowed successfully'), 112 - backgroundColor: Colors.green, 113 - ), 114 - ); 115 - } catch (e) { 116 - if (!mounted) return; 117 - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: ${e.toString()}'), backgroundColor: Colors.red)); 118 - } 119 - } 120 - 121 125 @override 122 126 Widget build(BuildContext context) { 123 127 final brightness = MediaQuery.of(context).platformBrightness; 124 128 final isDarkMode = brightness == Brightness.dark; 125 129 126 - return Scaffold( 127 - backgroundColor: isDarkMode ? Colors.black : AppTheme.getBackgroundColor(context), 128 - body: SafeArea( 129 - child: Stack( 130 - children: [ 131 - SingleChildScrollView( 132 - child: Column( 133 - crossAxisAlignment: CrossAxisAlignment.start, 134 - children: [ 135 - Padding( 136 - padding: const EdgeInsets.all(16.0), 137 - child: SearchBar( 138 - controller: _searchController, 139 - hintText: 'Explore', 140 - leading: Icon(FluentIcons.search_24_regular, color: AppTheme.getSecondaryTextColor(context)), 141 - padding: WidgetStateProperty.all(const EdgeInsets.symmetric(horizontal: 16.0)), 142 - elevation: WidgetStateProperty.all(0), 143 - backgroundColor: WidgetStateProperty.all( 144 - isDarkMode ? Colors.grey[900] : AppColors.lightLavender.withAlpha(50), 145 - ), 146 - onChanged: (value) {}, 147 - ), 148 - ), 149 - 150 - SizedBox( 151 - height: 105, 152 - child: ListView.builder( 153 - scrollDirection: Axis.horizontal, 154 - itemCount: _stories.length, 155 - padding: const EdgeInsets.symmetric(horizontal: 8), 156 - itemBuilder: (context, index) { 157 - final story = _stories[index]; 158 - return StoryCircle( 159 - username: story['username'], 160 - imageUrl: story['imageUrl'], 161 - isLive: story['isLive'] ?? false, 162 - isYourStory: story['isYourStory'] ?? false, 163 - onTap: () {}, 164 - ); 165 - }, 130 + return DefaultTabController( 131 + length: 1, 132 + child: Scaffold( 133 + backgroundColor: isDarkMode ? Colors.black : AppTheme.getBackgroundColor(context), 134 + body: SafeArea( 135 + child: Column( 136 + crossAxisAlignment: CrossAxisAlignment.start, 137 + children: [ 138 + Padding( 139 + padding: const EdgeInsets.all(16.0), 140 + child: TextField( 141 + controller: _searchController, 142 + onChanged: (value) => _onSearchChanged(value.trim()), 143 + decoration: InputDecoration( 144 + hintText: 'Search users', 145 + prefixIcon: Icon(FluentIcons.search_24_regular, color: AppTheme.getSecondaryTextColor(context)), 146 + filled: true, 147 + fillColor: isDarkMode ? Colors.grey[900] : AppColors.lightLavender.withAlpha(50), 148 + enabledBorder: OutlineInputBorder( 149 + borderRadius: BorderRadius.circular(8), 150 + borderSide: BorderSide(color: AppColors.border), 166 151 ), 167 - ), 168 - 169 - SectionHeader(title: 'Trending', icon: FluentIcons.data_trending_24_regular, onViewAllTap: () {}), 170 - 171 - SizedBox( 172 - height: 180, 173 - child: ListView.builder( 174 - scrollDirection: Axis.horizontal, 175 - itemCount: _trendingVideos.length, 176 - padding: const EdgeInsets.symmetric(horizontal: 16), 177 - itemBuilder: (context, index) { 178 - final video = _trendingVideos[index]; 179 - return Container( 180 - width: 160, 181 - margin: const EdgeInsets.only(right: 10), 182 - child: TrendingVideoCard( 183 - thumbnailUrl: video['thumbnailUrl'], 184 - viewCount: video['viewCount'], 185 - onTap: () {}, 186 - ), 187 - ); 188 - }, 152 + border: OutlineInputBorder( 153 + borderRadius: BorderRadius.circular(8), 154 + borderSide: BorderSide(color: AppColors.border), 189 155 ), 156 + contentPadding: const EdgeInsets.symmetric(horizontal: 16.0), 190 157 ), 158 + ), 159 + ), 160 + Theme( 161 + data: Theme.of(context).copyWith(tabBarTheme: const TabBarTheme(dividerColor: Colors.transparent)), 162 + child: TabBar( 163 + tabs: const [Tab(text: 'Users')], 164 + indicatorColor: AppColors.pink, 165 + labelColor: AppTheme.getTextColor(context), 166 + unselectedLabelColor: AppTheme.getSecondaryTextColor(context), 167 + ), 168 + ), 169 + Expanded(child: TabBarView(children: [_buildUserResults()])), 170 + ], 171 + ), 172 + ), 173 + ), 174 + ); 175 + } 191 176 192 - const SizedBox(height: 16), 177 + Widget _buildUserResults() { 178 + if (_isLoading) { 179 + return const Center(child: CircularProgressIndicator()); 180 + } 181 + if (_error.isNotEmpty) { 182 + return Center(child: Text(_error, style: const TextStyle(color: Colors.red))); 183 + } 184 + if (_searchController.text.isEmpty) { 185 + return const SizedBox.shrink(); 186 + } 187 + return ListView.builder( 188 + itemCount: _searchResults.length, 189 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 190 + itemBuilder: (context, index) { 191 + final user = _searchResults[index]; 192 + final authService = Provider.of<AuthService>(context, listen: false); 193 + final actionsService = Provider.of<ActionsService>(context, listen: false); 194 + final currentDid = authService.session?.did; 195 + final userDid = user['did']; 196 + final isCurrentUser = userDid == currentDid; 197 + final viewer = user['viewer'] != null ? Map<String, dynamic>.from(user['viewer']) : null; 198 + final followUri = viewer != null ? viewer['following'] as String? : null; 199 + final isFollowing = followUri != null && followUri.isNotEmpty; 193 200 194 - SectionHeader(title: 'Sounds', icon: FluentIcons.music_note_2_24_regular, onViewAllTap: () {}), 201 + Future<void> handleFollow() async { 202 + try { 203 + final newFollowUri = await actionsService.toggleFollow(userDid, null); 204 + setState(() { 205 + if (_searchResults[index]['viewer'] == null) { 206 + _searchResults[index]['viewer'] = <String, dynamic>{}; 207 + } 208 + _searchResults[index]['viewer']['following'] = newFollowUri; 209 + }); 210 + } catch (e) { 211 + if (!context.mounted) return; 212 + ScaffoldMessenger.of( 213 + context, 214 + ).showSnackBar(SnackBar(content: Text('Failed to follow: $e'), backgroundColor: Colors.red)); 215 + } 216 + } 195 217 196 - SizedBox( 197 - height: 85, 198 - child: ListView.builder( 199 - scrollDirection: Axis.horizontal, 200 - itemCount: _sounds.length, 201 - padding: const EdgeInsets.symmetric(horizontal: 16), 202 - itemBuilder: (context, index) { 203 - final sound = _sounds[index]; 204 - return Container( 205 - width: 240, 206 - margin: const EdgeInsets.only(right: 10), 207 - child: SoundCard( 208 - title: sound['title'], 209 - artist: sound['artist'], 210 - imageUrl: sound['imageUrl'], 211 - onTap: () {}, 212 - ), 213 - ); 214 - }, 215 - ), 216 - ), 217 - 218 - const SizedBox(height: 16), 219 - 220 - SectionHeader(title: 'Recommended Feeds', icon: FluentIcons.star_24_regular, onViewAllTap: () {}), 221 - 222 - SizedBox( 223 - height: 40, 224 - child: ListView.builder( 225 - scrollDirection: Axis.horizontal, 226 - itemCount: _categories.length, 227 - padding: const EdgeInsets.symmetric(horizontal: 16), 228 - itemBuilder: (context, index) { 229 - return Padding( 230 - padding: const EdgeInsets.only(right: 8), 231 - child: CategoryChip(label: _categories[index], onTap: () {}), 232 - ); 233 - }, 234 - ), 235 - ), 236 - 237 - const SizedBox(height: 16), 238 - 239 - SectionHeader(title: 'Suggested Accounts', icon: FluentIcons.person_24_regular, onViewAllTap: () {}), 240 - 241 - ListView.builder( 242 - physics: const NeverScrollableScrollPhysics(), 243 - shrinkWrap: true, 244 - itemCount: _suggestedAccounts.length, 245 - padding: const EdgeInsets.symmetric(horizontal: 16), 246 - itemBuilder: (context, index) { 247 - final account = _suggestedAccounts[index]; 248 - return Padding( 249 - padding: const EdgeInsets.only(bottom: 8), 250 - child: SuggestedAccountCard( 251 - username: account['username'], 252 - handle: account['handle'], 253 - avatarUrl: account['avatarUrl'], 254 - onTap: () { 255 - // Navigate to profile 256 - Navigator.push(context, MaterialPageRoute(builder: (context) => ProfileScreen(did: account['did']))); 257 - }, 258 - onFollowTap: () => _handleFollow(account['did']), 259 - ), 260 - ); 261 - }, 262 - ), 218 + Future<void> handleUnfollow() async { 219 + try { 220 + await actionsService.toggleFollow(userDid, followUri); 221 + setState(() { 222 + if (_searchResults[index]['viewer'] == null) { 223 + _searchResults[index]['viewer'] = <String, dynamic>{}; 224 + } 225 + _searchResults[index]['viewer']['following'] = null; 226 + }); 227 + } catch (e) { 228 + if (!context.mounted) return; 229 + ScaffoldMessenger.of( 230 + context, 231 + ).showSnackBar(SnackBar(content: Text('Failed to unfollow: $e'), backgroundColor: Colors.red)); 232 + } 233 + } 263 234 264 - const SizedBox(height: 20), 265 - ], 266 - ), 267 - ), 268 - const DevelopmentOverlay(), 269 - ], 270 - ), 271 - ), 235 + return Padding( 236 + padding: const EdgeInsets.only(bottom: 8), 237 + child: SuggestedAccountCard( 238 + username: user['displayName'] ?? user['handle'] ?? '', 239 + handle: '@${user['handle'] ?? ''}', 240 + avatarUrl: user['avatar'] ?? '', 241 + description: user['description'] ?? '', 242 + onTap: () { 243 + if (userDid != null && userDid.isNotEmpty) { 244 + Navigator.of(context).push(MaterialPageRoute(builder: (context) => ProfileScreen(did: userDid))); 245 + } 246 + }, 247 + showFollowButton: !isCurrentUser, 248 + isFollowing: isFollowing, 249 + onFollowTap: handleFollow, 250 + onUnfollowTap: handleUnfollow, 251 + ), 252 + ); 253 + }, 272 254 ); 273 255 } 274 256 }
+24
lib/services/sprk_client.dart
··· 187 187 ); 188 188 }); 189 189 } 190 + 191 + /// Search actors by query string. 192 + /// 193 + /// [query] The search query. 194 + Future<dynamic> searchActors(String query) async { 195 + return _client._executeWithRetry(() async { 196 + if (!_client._authService.isAuthenticated) { 197 + throw Exception('Not authenticated'); 198 + } 199 + 200 + final atproto = _client._authService.atproto; 201 + if (atproto == null) { 202 + throw Exception('AtProto not initialized'); 203 + } 204 + 205 + return await atproto.get( 206 + NSID.parse('so.sprk.actor.searchActors'), 207 + parameters: {'q': query}, 208 + headers: {'atproto-proxy': _client._sprkDid}, 209 + to: (jsonMap) => jsonMap, 210 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), 211 + ); 212 + }); 213 + } 190 214 } 191 215 192 216 /// Graph-related API endpoints
+47 -8
lib/widgets/search/suggested_account_card.dart
··· 8 8 final String username; 9 9 final String handle; 10 10 final String avatarUrl; 11 + final String? description; 11 12 final VoidCallback? onTap; 12 13 final VoidCallback? onFollowTap; 14 + final VoidCallback? onUnfollowTap; 15 + final bool showFollowButton; 16 + final bool isFollowing; 13 17 14 18 const SuggestedAccountCard({ 15 19 super.key, 16 20 required this.username, 17 21 required this.handle, 18 22 required this.avatarUrl, 23 + this.description, 19 24 this.onTap, 20 25 this.onFollowTap, 26 + this.onUnfollowTap, 27 + this.showFollowButton = true, 28 + this.isFollowing = false, 21 29 }); 22 30 23 31 @override ··· 59 67 maxLines: 1, 60 68 overflow: TextOverflow.ellipsis, 61 69 ), 70 + if (description != null && description!.isNotEmpty) 71 + Padding( 72 + padding: const EdgeInsets.only(top: 2.0), 73 + child: Text( 74 + description!, 75 + style: TextStyle(fontSize: 13, color: AppTheme.getSecondaryTextColor(context)), 76 + maxLines: 2, 77 + overflow: TextOverflow.ellipsis, 78 + ), 79 + ), 62 80 ], 63 81 ), 64 82 ), 65 83 66 - GestureDetector( 67 - onTap: onFollowTap, 68 - child: Container( 69 - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), 70 - decoration: BoxDecoration(color: AppColors.pink, borderRadius: BorderRadius.circular(24)), 71 - child: const Text('Follow', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 14)), 72 - ), 73 - ), 84 + if (showFollowButton) 85 + isFollowing 86 + ? GestureDetector( 87 + onTap: onUnfollowTap, 88 + child: AnimatedContainer( 89 + duration: const Duration(milliseconds: 200), 90 + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), 91 + decoration: BoxDecoration( 92 + color: Colors.transparent, 93 + border: Border.all(color: AppColors.pink, width: 2), 94 + borderRadius: BorderRadius.circular(24), 95 + ), 96 + child: const Text( 97 + 'Following', 98 + style: TextStyle(color: AppColors.pink, fontWeight: FontWeight.w600, fontSize: 14), 99 + ), 100 + ), 101 + ) 102 + : GestureDetector( 103 + onTap: onFollowTap, 104 + child: Container( 105 + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), 106 + decoration: BoxDecoration(color: AppColors.pink, borderRadius: BorderRadius.circular(24)), 107 + child: const Text( 108 + 'Follow', 109 + style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 14), 110 + ), 111 + ), 112 + ), 74 113 ], 75 114 ), 76 115 ),