this repo has no description

refactor: convert ExplorePage to StatefulWidget and implement search functionality with debounce

+187 -83
+187 -83
lib/screens/explore_page.dart
··· 4 4 import 'package:grain/models/profile.dart'; 5 5 import 'profile_page.dart'; 6 6 7 - class ExplorePage extends StatelessWidget { 7 + class ExplorePage extends StatefulWidget { 8 8 const ExplorePage({super.key}); 9 9 10 - Future<List<Profile>> _delayedSearch(String query) async { 11 - await Future.delayed(const Duration(milliseconds: 500)); 12 - return apiService.searchActors(query); 10 + @override 11 + State<ExplorePage> createState() => _ExplorePageState(); 12 + } 13 + 14 + class _ExplorePageState extends State<ExplorePage> 15 + with SingleTickerProviderStateMixin { 16 + final TextEditingController _controller = TextEditingController(); 17 + List<Profile> _results = []; 18 + bool _loading = false; 19 + bool _searched = false; 20 + Timer? _debounce; 21 + TabController? _tabController; 22 + 23 + @override 24 + void initState() { 25 + super.initState(); 26 + _tabController = TabController(length: 2, vsync: this); // 2 tabs: All, People 27 + } 28 + 29 + void _onSearchChanged(String value) { 30 + if (_debounce?.isActive ?? false) _debounce!.cancel(); 31 + if (value.isEmpty) { 32 + setState(() { 33 + _results = []; 34 + _searched = false; 35 + _loading = false; 36 + }); 37 + return; 38 + } 39 + setState(() { 40 + _loading = true; 41 + _searched = true; 42 + }); 43 + _debounce = Timer(const Duration(milliseconds: 500), () async { 44 + try { 45 + final results = await apiService.searchActors(value); 46 + if (mounted) { 47 + setState(() { 48 + _results = results; 49 + _loading = false; 50 + }); 51 + } 52 + } catch (e) { 53 + if (mounted) { 54 + setState(() { 55 + _results = []; 56 + _loading = false; 57 + }); 58 + } 59 + } 60 + }); 61 + } 62 + 63 + @override 64 + void dispose() { 65 + _controller.dispose(); 66 + _debounce?.cancel(); 67 + _tabController?.dispose(); 68 + super.dispose(); 13 69 } 14 70 15 71 @override 16 72 Widget build(BuildContext context) { 17 - return Padding( 18 - padding: const EdgeInsets.all(16.0), 19 - child: SearchAnchor.bar( 20 - barHintText: 'Search for users', 21 - barShape: WidgetStateProperty.all( 22 - RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), 23 - ), 24 - barElevation: WidgetStateProperty.all(0), 25 - suggestionsBuilder: (context, controller) { 26 - if (controller.text.isEmpty) { 27 - return []; 28 - } 29 - return [ 30 - FutureBuilder<List<Profile>>( 31 - future: _delayedSearch(controller.text), 32 - builder: (context, snapshot) { 33 - if (snapshot.connectionState == ConnectionState.waiting) { 34 - return const ListTile( 35 - title: Text('Searching...'), 36 - leading: SizedBox( 37 - width: 20, 38 - height: 20, 39 - child: CircularProgressIndicator(strokeWidth: 2), 40 - ), 41 - ); 42 - } 43 - if (snapshot.hasError) { 44 - return const ListTile(title: Text('Error searching users')); 45 - } 46 - final results = snapshot.data ?? []; 47 - if (results.isEmpty) { 48 - return const ListTile(title: Text('No users found')); 49 - } 50 - return Column( 51 - mainAxisSize: MainAxisSize.min, 52 - children: results.map((profile) { 53 - return ListTile( 54 - leading: profile.avatar.isNotEmpty 55 - ? CircleAvatar( 56 - backgroundImage: NetworkImage(profile.avatar), 57 - radius: 16, 58 - ) 59 - : const CircleAvatar( 60 - radius: 16, 61 - child: Icon( 62 - Icons.account_circle, 63 - color: Colors.grey, 64 - ), 65 - ), 66 - title: Text( 67 - profile.displayName.isNotEmpty 68 - ? profile.displayName 69 - : '@${profile.handle}', 70 - ), 71 - subtitle: profile.handle.isNotEmpty 72 - ? Text('@${profile.handle}') 73 - : null, 74 - onTap: () async { 75 - // Navigate to the profile page for the selected user 76 - final loadedProfile = await apiService.fetchProfile( 77 - did: profile.did, 78 - ); 79 - if (context.mounted) { 80 - Navigator.of(context).push( 81 - MaterialPageRoute( 82 - builder: (context) => ProfilePage( 83 - profile: loadedProfile, 84 - showAppBar: true, 85 - ), 86 - ), 87 - ); 88 - } 73 + return Column( 74 + children: [ 75 + Padding( 76 + padding: const EdgeInsets.all(16.0), 77 + child: TextField( 78 + controller: _controller, 79 + decoration: InputDecoration( 80 + hintText: 'Search for users', 81 + filled: true, 82 + fillColor: Colors.grey[200], // light grey background 83 + border: OutlineInputBorder( 84 + borderRadius: BorderRadius.circular(8), 85 + borderSide: BorderSide.none, 86 + ), 87 + focusedBorder: OutlineInputBorder( 88 + borderRadius: BorderRadius.circular(8), 89 + borderSide: const BorderSide( 90 + color: Color(0xFF0ea5e9), // Tailwind sky-500 91 + width: 2, 92 + ), 93 + ), 94 + suffixIcon: _controller.text.isNotEmpty 95 + ? IconButton( 96 + icon: const Icon(Icons.clear), 97 + onPressed: () { 98 + _controller.clear(); 99 + setState(() { 100 + _results = []; 101 + _searched = false; 102 + }); 89 103 }, 90 - ); 91 - }).toList(), 92 - ); 93 - }, 104 + ) 105 + : null, 94 106 ), 95 - ]; 96 - }, 97 - ), 107 + onChanged: _onSearchChanged, 108 + ), 109 + ), 110 + if (_tabController != null) 111 + Padding( 112 + padding: const EdgeInsets.symmetric(horizontal: 16.0), 113 + child: TabBar( 114 + controller: _tabController, 115 + tabs: const [ 116 + Tab(text: 'All'), 117 + Tab(text: 'People'), 118 + ], 119 + labelColor: Colors.black, 120 + unselectedLabelColor: Colors.black54, 121 + indicator: const UnderlineTabIndicator( 122 + borderSide: BorderSide( 123 + color: Color(0xFF0ea5e9), // Tailwind sky-500 124 + width: 3, 125 + ), 126 + insets: EdgeInsets.symmetric(horizontal: 0), // full width 127 + ), 128 + indicatorSize: TabBarIndicatorSize.tab, 129 + ), 130 + ), 131 + Expanded( 132 + child: (_tabController != null) 133 + ? TabBarView( 134 + controller: _tabController, 135 + children: [ 136 + _buildResultsList(_results), 137 + _buildResultsList(_results), // You can filter differently for 'People' tab 138 + ], 139 + ) 140 + : Container(), 141 + ), 142 + ], 143 + ); 144 + } 145 + 146 + Widget _buildResultsList(List<Profile> results) { 147 + if (_loading) { 148 + return const ListTile( 149 + title: Text('Searching...'), 150 + leading: SizedBox( 151 + width: 20, 152 + height: 20, 153 + child: CircularProgressIndicator(strokeWidth: 2), 154 + ), 155 + ); 156 + } else if (_searched && results.isEmpty) { 157 + return const ListTile(title: Text('No users found')); 158 + } 159 + return ListView.separated( 160 + itemCount: results.length, 161 + separatorBuilder: (context, index) => const Divider(height: 1, thickness: 1), 162 + itemBuilder: (context, index) { 163 + final profile = results[index]; 164 + return ListTile( 165 + leading: profile.avatar.isNotEmpty 166 + ? CircleAvatar( 167 + backgroundImage: NetworkImage(profile.avatar), 168 + radius: 16, 169 + ) 170 + : const CircleAvatar( 171 + radius: 16, 172 + child: Icon( 173 + Icons.account_circle, 174 + color: Colors.grey, 175 + ), 176 + ), 177 + title: Text( 178 + profile.displayName.isNotEmpty 179 + ? profile.displayName 180 + : '@${profile.handle}', 181 + ), 182 + subtitle: profile.handle.isNotEmpty 183 + ? Text('@${profile.handle}') 184 + : null, 185 + onTap: () async { 186 + final loadedProfile = await apiService.fetchProfile( 187 + did: profile.did, 188 + ); 189 + if (context.mounted) { 190 + Navigator.of(context).push( 191 + MaterialPageRoute( 192 + builder: (context) => ProfilePage( 193 + profile: loadedProfile, 194 + showAppBar: true, 195 + ), 196 + ), 197 + ); 198 + } 199 + }, 200 + ); 201 + }, 98 202 ); 99 203 } 100 204 }