at main 15 kB view raw
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}