Main coves client
1
fork

Configure Feed

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

feat(compose): add community picker screen

Full-screen community selection interface for post creation:
- Search with debounced client-side filtering
- Infinite scroll pagination
- Cached avatar images with error fallback
- Proper authenticated API service with disposal
- Member/subscriber count formatting (K/M suffixes)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+518
+518
lib/screens/compose/community_picker_screen.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:cached_network_image/cached_network_image.dart'; 4 + import 'package:flutter/material.dart'; 5 + import 'package:provider/provider.dart'; 6 + 7 + import '../../constants/app_colors.dart'; 8 + import '../../models/community.dart'; 9 + import '../../providers/auth_provider.dart'; 10 + import '../../services/api_exceptions.dart'; 11 + import '../../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 30 + class CommunityPickerScreen extends StatefulWidget { 31 + const CommunityPickerScreen({super.key}); 32 + 33 + @override 34 + State<CommunityPickerScreen> createState() => _CommunityPickerScreenState(); 35 + } 36 + 37 + class _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 + }