Main coves client
1import 'dart:async';
2
3import 'package:cached_network_image/cached_network_image.dart';
4import 'package:flutter/material.dart';
5import 'package:provider/provider.dart';
6
7import '../../constants/app_colors.dart';
8import '../../models/community.dart';
9import '../../providers/auth_provider.dart';
10import '../../services/api_exceptions.dart';
11import '../../services/coves_api_service.dart';
12
13/// Community Picker Screen
14///
15/// Full-screen interface for selecting a community when creating a post.
16///
17/// Features:
18/// - Search bar with 300ms debounce for client-side filtering
19/// - Scroll pagination - loads more communities when near bottom
20/// - Loading, error, and empty states
21/// - Returns selected community on tap via Navigator.pop
22///
23/// Design:
24/// - Header: "Post to" with X close button
25/// - Search bar: "Search for a community" with search icon
26/// - List of communities showing:
27/// - Avatar (CircleAvatar with first letter fallback)
28/// - Community name (bold)
29/// - Member count + optional description
30class CommunityPickerScreen extends StatefulWidget {
31 const CommunityPickerScreen({super.key});
32
33 @override
34 State<CommunityPickerScreen> createState() => _CommunityPickerScreenState();
35}
36
37class _CommunityPickerScreenState extends State<CommunityPickerScreen> {
38 final TextEditingController _searchController = TextEditingController();
39 final ScrollController _scrollController = ScrollController();
40
41 List<CommunityView> _communities = [];
42 List<CommunityView> _filteredCommunities = [];
43 bool _isLoading = false;
44 bool _isLoadingMore = false;
45 String? _error;
46 String? _cursor;
47 bool _hasMore = true;
48 Timer? _searchDebounce;
49 CovesApiService? _apiService;
50
51 @override
52 void initState() {
53 super.initState();
54 _searchController.addListener(_onSearchChanged);
55 _scrollController.addListener(_onScroll);
56 // Defer API initialization to first frame to access context
57 WidgetsBinding.instance.addPostFrameCallback((_) {
58 _initApiService();
59 _loadCommunities();
60 });
61 }
62
63 void _initApiService() {
64 final authProvider = context.read<AuthProvider>();
65 _apiService = CovesApiService(
66 tokenGetter: authProvider.getAccessToken,
67 tokenRefresher: authProvider.refreshToken,
68 signOutHandler: authProvider.signOut,
69 );
70 }
71
72 @override
73 void dispose() {
74 _searchController.dispose();
75 _scrollController.dispose();
76 _searchDebounce?.cancel();
77 _apiService?.dispose();
78 super.dispose();
79 }
80
81 void _onSearchChanged() {
82 // Cancel previous debounce timer
83 _searchDebounce?.cancel();
84
85 // Start new debounce timer (300ms)
86 _searchDebounce = Timer(const Duration(milliseconds: 300), _filterCommunities);
87 }
88
89 void _filterCommunities() {
90 final query = _searchController.text.trim().toLowerCase();
91
92 if (query.isEmpty) {
93 setState(() {
94 _filteredCommunities = _communities;
95 });
96 return;
97 }
98
99 setState(() {
100 _filteredCommunities = _communities.where((community) {
101 final name = community.name.toLowerCase();
102 final displayName = community.displayName?.toLowerCase() ?? '';
103 final description = community.description?.toLowerCase() ?? '';
104
105 return name.contains(query) ||
106 displayName.contains(query) ||
107 description.contains(query);
108 }).toList();
109 });
110 }
111
112 void _onScroll() {
113 // Load more when near bottom (80% scrolled)
114 if (_scrollController.position.pixels >=
115 _scrollController.position.maxScrollExtent * 0.8) {
116 if (!_isLoadingMore && _hasMore && !_isLoading) {
117 _loadMoreCommunities();
118 }
119 }
120 }
121
122 Future<void> _loadCommunities() async {
123 if (_isLoading || _apiService == null) {
124 return;
125 }
126
127 setState(() {
128 _isLoading = true;
129 _error = null;
130 });
131
132 try {
133 final response = await _apiService!.listCommunities(
134 limit: 50,
135 );
136
137 if (mounted) {
138 setState(() {
139 _communities = response.communities;
140 _filteredCommunities = response.communities;
141 _cursor = response.cursor;
142 _hasMore = response.cursor != null && response.cursor!.isNotEmpty;
143 _isLoading = false;
144 });
145 }
146 } on ApiException catch (e) {
147 if (mounted) {
148 setState(() {
149 _error = e.message;
150 _isLoading = false;
151 });
152 }
153 } on Exception catch (e) {
154 if (mounted) {
155 setState(() {
156 _error = 'Failed to load communities: ${e.toString()}';
157 _isLoading = false;
158 });
159 }
160 }
161 }
162
163 Future<void> _loadMoreCommunities() async {
164 if (_isLoadingMore || !_hasMore || _cursor == null || _apiService == null) {
165 return;
166 }
167
168 setState(() {
169 _isLoadingMore = true;
170 });
171
172 try {
173 final response = await _apiService!.listCommunities(
174 limit: 50,
175 cursor: _cursor,
176 );
177
178 if (mounted) {
179 setState(() {
180 _communities.addAll(response.communities);
181 _cursor = response.cursor;
182 _hasMore = response.cursor != null && response.cursor!.isNotEmpty;
183 _isLoadingMore = false;
184
185 // Re-apply search filter if active
186 _filterCommunities();
187 });
188 }
189 } on ApiException catch (e) {
190 if (mounted) {
191 setState(() {
192 _error = e.message;
193 _isLoadingMore = false;
194 });
195 }
196 } on Exception {
197 if (mounted) {
198 setState(() {
199 _isLoadingMore = false;
200 });
201 }
202 }
203 }
204
205 void _onCommunityTap(CommunityView community) {
206 Navigator.pop(context, community);
207 }
208
209 @override
210 Widget build(BuildContext context) {
211 return Scaffold(
212 backgroundColor: AppColors.background,
213 appBar: AppBar(
214 backgroundColor: AppColors.background,
215 foregroundColor: Colors.white,
216 title: const Text('Post to'),
217 elevation: 0,
218 leading: IconButton(
219 icon: const Icon(Icons.close),
220 onPressed: () => Navigator.pop(context),
221 ),
222 ),
223 body: SafeArea(
224 child: Column(
225 children: [
226 // Search bar
227 Padding(
228 padding: const EdgeInsets.all(16),
229 child: TextField(
230 controller: _searchController,
231 style: const TextStyle(color: Colors.white),
232 decoration: InputDecoration(
233 hintText: 'Search for a community',
234 hintStyle: const TextStyle(color: Color(0xFF5A6B7F)),
235 filled: true,
236 fillColor: const Color(0xFF1A2028),
237 border: OutlineInputBorder(
238 borderRadius: BorderRadius.circular(12),
239 borderSide: BorderSide.none,
240 ),
241 enabledBorder: OutlineInputBorder(
242 borderRadius: BorderRadius.circular(12),
243 borderSide: BorderSide.none,
244 ),
245 focusedBorder: OutlineInputBorder(
246 borderRadius: BorderRadius.circular(12),
247 borderSide: const BorderSide(
248 color: AppColors.primary,
249 width: 2,
250 ),
251 ),
252 prefixIcon: const Icon(
253 Icons.search,
254 color: Color(0xFF5A6B7F),
255 ),
256 contentPadding: const EdgeInsets.symmetric(
257 horizontal: 16,
258 vertical: 12,
259 ),
260 ),
261 ),
262 ),
263
264 // Community list
265 Expanded(
266 child: _buildBody(),
267 ),
268 ],
269 ),
270 ),
271 );
272 }
273
274 Widget _buildBody() {
275 // Loading state (initial load)
276 if (_isLoading) {
277 return const Center(
278 child: CircularProgressIndicator(
279 color: AppColors.primary,
280 ),
281 );
282 }
283
284 // Error state
285 if (_error != null) {
286 return Center(
287 child: Padding(
288 padding: const EdgeInsets.all(24),
289 child: Column(
290 mainAxisAlignment: MainAxisAlignment.center,
291 children: [
292 const Icon(
293 Icons.error_outline,
294 size: 48,
295 color: Color(0xFF5A6B7F),
296 ),
297 const SizedBox(height: 16),
298 Text(
299 _error!,
300 style: const TextStyle(
301 color: Color(0xFFB6C2D2),
302 fontSize: 16,
303 ),
304 textAlign: TextAlign.center,
305 ),
306 const SizedBox(height: 24),
307 ElevatedButton(
308 onPressed: _loadCommunities,
309 style: ElevatedButton.styleFrom(
310 backgroundColor: AppColors.primary,
311 foregroundColor: Colors.white,
312 padding: const EdgeInsets.symmetric(
313 horizontal: 24,
314 vertical: 12,
315 ),
316 shape: RoundedRectangleBorder(
317 borderRadius: BorderRadius.circular(8),
318 ),
319 ),
320 child: const Text('Retry'),
321 ),
322 ],
323 ),
324 ),
325 );
326 }
327
328 // Empty state
329 if (_filteredCommunities.isEmpty) {
330 return Center(
331 child: Padding(
332 padding: const EdgeInsets.all(24),
333 child: Column(
334 mainAxisAlignment: MainAxisAlignment.center,
335 children: [
336 const Icon(
337 Icons.search_off,
338 size: 48,
339 color: Color(0xFF5A6B7F),
340 ),
341 const SizedBox(height: 16),
342 Text(
343 _searchController.text.trim().isEmpty
344 ? 'No communities found'
345 : 'No communities match your search',
346 style: const TextStyle(
347 color: Color(0xFFB6C2D2),
348 fontSize: 16,
349 ),
350 textAlign: TextAlign.center,
351 ),
352 ],
353 ),
354 ),
355 );
356 }
357
358 // Community list
359 return ListView.builder(
360 controller: _scrollController,
361 itemCount: _filteredCommunities.length + (_isLoadingMore ? 1 : 0),
362 itemBuilder: (context, index) {
363 // Loading indicator at bottom
364 if (index == _filteredCommunities.length) {
365 return const Padding(
366 padding: EdgeInsets.all(16),
367 child: Center(
368 child: CircularProgressIndicator(
369 color: AppColors.primary,
370 ),
371 ),
372 );
373 }
374
375 final community = _filteredCommunities[index];
376 return _buildCommunityTile(community);
377 },
378 );
379 }
380
381 Widget _buildCommunityAvatar(CommunityView community) {
382 final fallbackChild = CircleAvatar(
383 radius: 20,
384 backgroundColor: AppColors.backgroundSecondary,
385 foregroundColor: Colors.white,
386 child: Text(
387 community.name.isNotEmpty ? community.name[0].toUpperCase() : '?',
388 style: const TextStyle(
389 fontSize: 16,
390 fontWeight: FontWeight.bold,
391 ),
392 ),
393 );
394
395 if (community.avatar == null) {
396 return fallbackChild;
397 }
398
399 return CachedNetworkImage(
400 imageUrl: community.avatar!,
401 imageBuilder: (context, imageProvider) => CircleAvatar(
402 radius: 20,
403 backgroundColor: AppColors.backgroundSecondary,
404 backgroundImage: imageProvider,
405 ),
406 placeholder: (context, url) => CircleAvatar(
407 radius: 20,
408 backgroundColor: AppColors.backgroundSecondary,
409 child: const SizedBox(
410 width: 16,
411 height: 16,
412 child: CircularProgressIndicator(
413 strokeWidth: 2,
414 color: AppColors.primary,
415 ),
416 ),
417 ),
418 errorWidget: (context, url, error) => fallbackChild,
419 );
420 }
421
422 Widget _buildCommunityTile(CommunityView community) {
423 // Format member count
424 String formatCount(int? count) {
425 if (count == null) {
426 return '0';
427 }
428 if (count >= 1000000) {
429 return '${(count / 1000000).toStringAsFixed(1)}M';
430 } else if (count >= 1000) {
431 return '${(count / 1000).toStringAsFixed(1)}K';
432 }
433 return count.toString();
434 }
435
436 final memberCount = formatCount(community.memberCount);
437 final subscriberCount = formatCount(community.subscriberCount);
438
439 // Build description line
440 var descriptionLine = '';
441 if (community.memberCount != null && community.memberCount! > 0) {
442 descriptionLine = '$memberCount members';
443 if (community.subscriberCount != null &&
444 community.subscriberCount! > 0) {
445 descriptionLine += ' · $subscriberCount subscribers';
446 }
447 } else if (community.subscriberCount != null &&
448 community.subscriberCount! > 0) {
449 descriptionLine = '$subscriberCount subscribers';
450 }
451
452 if (community.description != null && community.description!.isNotEmpty) {
453 if (descriptionLine.isNotEmpty) {
454 descriptionLine += ' · ';
455 }
456 descriptionLine += community.description!;
457 }
458
459 return Material(
460 color: Colors.transparent,
461 child: InkWell(
462 onTap: () => _onCommunityTap(community),
463 child: Container(
464 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
465 decoration: const BoxDecoration(
466 border: Border(
467 bottom: BorderSide(
468 color: Color(0xFF2A3441),
469 width: 1,
470 ),
471 ),
472 ),
473 child: Row(
474 children: [
475 // Avatar
476 _buildCommunityAvatar(community),
477 const SizedBox(width: 12),
478
479 // Community info
480 Expanded(
481 child: Column(
482 crossAxisAlignment: CrossAxisAlignment.start,
483 children: [
484 // Community name
485 Text(
486 community.displayName ?? community.name,
487 style: const TextStyle(
488 color: Colors.white,
489 fontSize: 16,
490 fontWeight: FontWeight.bold,
491 ),
492 maxLines: 1,
493 overflow: TextOverflow.ellipsis,
494 ),
495
496 // Description line
497 if (descriptionLine.isNotEmpty) ...[
498 const SizedBox(height: 4),
499 Text(
500 descriptionLine,
501 style: const TextStyle(
502 color: Color(0xFFB6C2D2),
503 fontSize: 14,
504 ),
505 maxLines: 2,
506 overflow: TextOverflow.ellipsis,
507 ),
508 ],
509 ],
510 ),
511 ),
512 ],
513 ),
514 ),
515 ),
516 );
517 }
518}