feat(post): add create post screen with validation and limits

Create post screen improvements:
- Community picker integration
- URL validation (http/https only)
- Input length limits from backend lexicon (title: 300, content: 10000)
- NSFW toggle with self-labels
- Language selection dropdown
- Navigate to feed after successful post creation

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

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

Changed files
+692 -28
lib
+685 -27
lib/screens/home/create_post_screen.dart
··· 1 1 import 'package:flutter/material.dart'; 2 + import 'package:provider/provider.dart'; 2 3 3 4 import '../../constants/app_colors.dart'; 5 + import '../../models/community.dart'; 6 + import '../../models/post.dart'; 7 + import '../../providers/auth_provider.dart'; 8 + import '../../services/api_exceptions.dart'; 9 + import '../../services/coves_api_service.dart'; 10 + import '../compose/community_picker_screen.dart'; 11 + import 'post_detail_screen.dart'; 4 12 5 - class CreatePostScreen extends StatelessWidget { 6 - const CreatePostScreen({super.key}); 13 + /// Language options for posts 14 + const Map<String, String> languages = { 15 + 'en': 'English', 16 + 'es': 'Spanish', 17 + 'pt': 'Portuguese', 18 + 'de': 'German', 19 + 'fr': 'French', 20 + 'ja': 'Japanese', 21 + 'ko': 'Korean', 22 + 'zh': 'Chinese', 23 + }; 24 + 25 + /// Content limits from backend lexicon (social.coves.community.post) 26 + /// Using grapheme limits as they are the user-facing character counts 27 + const int kTitleMaxLength = 300; 28 + const int kContentMaxLength = 10000; 29 + 30 + /// Create Post Screen 31 + /// 32 + /// Full-screen interface for creating a new post in a community. 33 + /// 34 + /// Features: 35 + /// - Community selector (required) 36 + /// - Optional title, URL, thumbnail, and body fields 37 + /// - Language dropdown and NSFW toggle 38 + /// - Form validation (at least one of title/body/URL required) 39 + /// - Loading states and error handling 40 + /// - Keyboard handling with scroll support 41 + class CreatePostScreen extends StatefulWidget { 42 + const CreatePostScreen({this.onNavigateToFeed, super.key}); 43 + 44 + /// Callback to navigate to feed tab (used when in tab navigation) 45 + final VoidCallback? onNavigateToFeed; 46 + 47 + @override 48 + State<CreatePostScreen> createState() => _CreatePostScreenState(); 49 + } 50 + 51 + class _CreatePostScreenState extends State<CreatePostScreen> 52 + with WidgetsBindingObserver { 53 + // Text controllers 54 + final TextEditingController _titleController = TextEditingController(); 55 + final TextEditingController _urlController = TextEditingController(); 56 + final TextEditingController _thumbnailController = TextEditingController(); 57 + final TextEditingController _bodyController = TextEditingController(); 58 + 59 + // Scroll and focus 60 + final ScrollController _scrollController = ScrollController(); 61 + final FocusNode _titleFocusNode = FocusNode(); 62 + final FocusNode _urlFocusNode = FocusNode(); 63 + final FocusNode _thumbnailFocusNode = FocusNode(); 64 + final FocusNode _bodyFocusNode = FocusNode(); 65 + double _lastKeyboardHeight = 0; 66 + 67 + // Form state 68 + CommunityView? _selectedCommunity; 69 + String _language = 'en'; 70 + bool _isNsfw = false; 71 + bool _isSubmitting = false; 72 + 73 + // Computed state 74 + bool get _isFormValid { 75 + return _selectedCommunity != null && 76 + (_titleController.text.trim().isNotEmpty || 77 + _bodyController.text.trim().isNotEmpty || 78 + _urlController.text.trim().isNotEmpty); 79 + } 80 + 81 + @override 82 + void initState() { 83 + super.initState(); 84 + WidgetsBinding.instance.addObserver(this); 85 + // Listen to text changes to update button state 86 + _titleController.addListener(_onTextChanged); 87 + _urlController.addListener(_onTextChanged); 88 + _bodyController.addListener(_onTextChanged); 89 + } 90 + 91 + @override 92 + void dispose() { 93 + WidgetsBinding.instance.removeObserver(this); 94 + _titleController.dispose(); 95 + _urlController.dispose(); 96 + _thumbnailController.dispose(); 97 + _bodyController.dispose(); 98 + _scrollController.dispose(); 99 + _titleFocusNode.dispose(); 100 + _urlFocusNode.dispose(); 101 + _thumbnailFocusNode.dispose(); 102 + _bodyFocusNode.dispose(); 103 + super.dispose(); 104 + } 105 + 106 + @override 107 + void didChangeMetrics() { 108 + super.didChangeMetrics(); 109 + if (!mounted) { 110 + return; 111 + } 112 + 113 + final keyboardHeight = View.of(context).viewInsets.bottom; 114 + 115 + // Detect keyboard closing and unfocus all text fields 116 + if (_lastKeyboardHeight > 0 && keyboardHeight == 0) { 117 + FocusManager.instance.primaryFocus?.unfocus(); 118 + } 119 + 120 + _lastKeyboardHeight = keyboardHeight; 121 + } 122 + 123 + void _onTextChanged() { 124 + // Force rebuild to update Post button state 125 + setState(() {}); 126 + } 127 + 128 + Future<void> _selectCommunity() async { 129 + final result = await Navigator.push<CommunityView>( 130 + context, 131 + MaterialPageRoute( 132 + builder: (context) => const CommunityPickerScreen(), 133 + ), 134 + ); 135 + 136 + if (result != null && mounted) { 137 + setState(() { 138 + _selectedCommunity = result; 139 + }); 140 + } 141 + } 142 + 143 + Future<void> _handleSubmit() async { 144 + if (!_isFormValid || _isSubmitting) { 145 + return; 146 + } 147 + 148 + setState(() { 149 + _isSubmitting = true; 150 + }); 151 + 152 + try { 153 + final authProvider = context.read<AuthProvider>(); 154 + 155 + // Create API service with auth 156 + final apiService = CovesApiService( 157 + tokenGetter: authProvider.getAccessToken, 158 + tokenRefresher: authProvider.refreshToken, 159 + signOutHandler: authProvider.signOut, 160 + ); 161 + 162 + // Build embed if URL is provided 163 + ExternalEmbedInput? embed; 164 + final url = _urlController.text.trim(); 165 + if (url.isNotEmpty) { 166 + // Validate URL 167 + final uri = Uri.tryParse(url); 168 + if (uri == null || 169 + !uri.hasScheme || 170 + (!uri.scheme.startsWith('http'))) { 171 + if (mounted) { 172 + ScaffoldMessenger.of(context).showSnackBar( 173 + SnackBar( 174 + content: const Text('Please enter a valid URL (http or https)'), 175 + backgroundColor: Colors.red[700], 176 + behavior: SnackBarBehavior.floating, 177 + ), 178 + ); 179 + } 180 + setState(() { 181 + _isSubmitting = false; 182 + }); 183 + return; 184 + } 185 + 186 + embed = ExternalEmbedInput( 187 + uri: url, 188 + title: _titleController.text.trim().isNotEmpty 189 + ? _titleController.text.trim() 190 + : null, 191 + thumb: _thumbnailController.text.trim().isNotEmpty 192 + ? _thumbnailController.text.trim() 193 + : null, 194 + ); 195 + } 196 + 197 + // Build labels if NSFW is enabled 198 + SelfLabels? labels; 199 + if (_isNsfw) { 200 + labels = const SelfLabels(values: [SelfLabel(val: 'nsfw')]); 201 + } 202 + 203 + // Create post 204 + final response = await apiService.createPost( 205 + community: _selectedCommunity!.did, 206 + title: _titleController.text.trim().isNotEmpty 207 + ? _titleController.text.trim() 208 + : null, 209 + content: _bodyController.text.trim().isNotEmpty 210 + ? _bodyController.text.trim() 211 + : null, 212 + embed: embed, 213 + langs: [_language], 214 + labels: labels, 215 + ); 216 + 217 + if (mounted) { 218 + // Build optimistic post for immediate display 219 + final optimisticPost = _buildOptimisticPost( 220 + response: response, 221 + authProvider: authProvider, 222 + ); 223 + 224 + // Reset form first 225 + _resetForm(); 226 + 227 + // Navigate to post detail with optimistic data 228 + await Navigator.push( 229 + context, 230 + MaterialPageRoute( 231 + builder: (context) => PostDetailScreen( 232 + post: optimisticPost, 233 + isOptimistic: true, 234 + ), 235 + ), 236 + ); 237 + } 238 + } on ApiException catch (e) { 239 + if (mounted) { 240 + ScaffoldMessenger.of(context).showSnackBar( 241 + SnackBar( 242 + content: Text('Failed to create post: ${e.message}'), 243 + backgroundColor: Colors.red[700], 244 + behavior: SnackBarBehavior.floating, 245 + ), 246 + ); 247 + } 248 + } on Exception catch (e) { 249 + if (mounted) { 250 + ScaffoldMessenger.of(context).showSnackBar( 251 + SnackBar( 252 + content: Text('Failed to create post: ${e.toString()}'), 253 + backgroundColor: Colors.red[700], 254 + behavior: SnackBarBehavior.floating, 255 + ), 256 + ); 257 + } 258 + } finally { 259 + if (mounted) { 260 + setState(() { 261 + _isSubmitting = false; 262 + }); 263 + } 264 + } 265 + } 266 + 267 + void _resetForm() { 268 + setState(() { 269 + _titleController.clear(); 270 + _urlController.clear(); 271 + _thumbnailController.clear(); 272 + _bodyController.clear(); 273 + _selectedCommunity = null; 274 + _language = 'en'; 275 + _isNsfw = false; 276 + }); 277 + } 278 + 279 + /// Build optimistic post for immediate display after creation 280 + FeedViewPost _buildOptimisticPost({ 281 + required CreatePostResponse response, 282 + required AuthProvider authProvider, 283 + }) { 284 + // Extract rkey from AT-URI (at://did/collection/rkey) 285 + final uriParts = response.uri.split('/'); 286 + final rkey = uriParts.isNotEmpty ? uriParts.last : ''; 287 + 288 + // Build embed if URL was provided 289 + PostEmbed? embed; 290 + final url = _urlController.text.trim(); 291 + if (url.isNotEmpty) { 292 + embed = PostEmbed( 293 + type: 'social.coves.embed.external', 294 + external: ExternalEmbed( 295 + uri: url, 296 + title: _titleController.text.trim().isNotEmpty 297 + ? _titleController.text.trim() 298 + : null, 299 + thumb: _thumbnailController.text.trim().isNotEmpty 300 + ? _thumbnailController.text.trim() 301 + : null, 302 + ), 303 + data: { 304 + r'$type': 'social.coves.embed.external', 305 + 'external': { 306 + 'uri': url, 307 + if (_titleController.text.trim().isNotEmpty) 308 + 'title': _titleController.text.trim(), 309 + if (_thumbnailController.text.trim().isNotEmpty) 310 + 'thumb': _thumbnailController.text.trim(), 311 + }, 312 + }, 313 + ); 314 + } 315 + 316 + final now = DateTime.now(); 317 + 318 + return FeedViewPost( 319 + post: PostView( 320 + uri: response.uri, 321 + cid: response.cid, 322 + rkey: rkey, 323 + author: AuthorView( 324 + did: authProvider.did ?? '', 325 + handle: authProvider.handle ?? 'unknown', 326 + displayName: null, 327 + avatar: null, 328 + ), 329 + community: CommunityRef( 330 + did: _selectedCommunity!.did, 331 + name: _selectedCommunity!.name, 332 + handle: _selectedCommunity!.handle, 333 + avatar: _selectedCommunity!.avatar, 334 + ), 335 + createdAt: now, 336 + indexedAt: now, 337 + text: _bodyController.text.trim(), 338 + title: _titleController.text.trim().isNotEmpty 339 + ? _titleController.text.trim() 340 + : null, 341 + stats: PostStats( 342 + upvotes: 0, 343 + downvotes: 0, 344 + score: 0, 345 + commentCount: 0, 346 + ), 347 + embed: embed, 348 + viewer: ViewerState(), 349 + ), 350 + ); 351 + } 7 352 8 353 @override 9 354 Widget build(BuildContext context) { 10 - return Scaffold( 11 - backgroundColor: const Color(0xFF0B0F14), 12 - appBar: AppBar( 13 - backgroundColor: const Color(0xFF0B0F14), 14 - foregroundColor: Colors.white, 355 + final authProvider = context.watch<AuthProvider>(); 356 + final userHandle = authProvider.handle ?? 'Unknown'; 357 + 358 + return PopScope( 359 + canPop: widget.onNavigateToFeed == null, 360 + onPopInvokedWithResult: (didPop, result) { 361 + if (!didPop && widget.onNavigateToFeed != null) { 362 + widget.onNavigateToFeed!(); 363 + } 364 + }, 365 + child: Scaffold( 366 + backgroundColor: AppColors.background, 367 + appBar: AppBar( 368 + backgroundColor: AppColors.background, 369 + surfaceTintColor: Colors.transparent, 370 + foregroundColor: AppColors.textPrimary, 15 371 title: const Text('Create Post'), 372 + elevation: 0, 16 373 automaticallyImplyLeading: false, 374 + leading: IconButton( 375 + icon: const Icon(Icons.close), 376 + onPressed: () { 377 + // Use callback if available (tab navigation), otherwise pop 378 + if (widget.onNavigateToFeed != null) { 379 + widget.onNavigateToFeed!(); 380 + } else { 381 + Navigator.pop(context); 382 + } 383 + }, 384 + ), 385 + actions: [ 386 + Padding( 387 + padding: const EdgeInsets.only(right: 8), 388 + child: TextButton( 389 + onPressed: _isFormValid && !_isSubmitting ? _handleSubmit : null, 390 + style: TextButton.styleFrom( 391 + backgroundColor: _isFormValid && !_isSubmitting 392 + ? AppColors.primary 393 + : AppColors.textSecondary.withValues(alpha: 0.3), 394 + foregroundColor: AppColors.textPrimary, 395 + padding: const EdgeInsets.symmetric( 396 + horizontal: 16, 397 + vertical: 8, 398 + ), 399 + shape: RoundedRectangleBorder( 400 + borderRadius: BorderRadius.circular(20), 401 + ), 402 + ), 403 + child: 404 + _isSubmitting 405 + ? const SizedBox( 406 + width: 16, 407 + height: 16, 408 + child: CircularProgressIndicator( 409 + strokeWidth: 2, 410 + valueColor: AlwaysStoppedAnimation<Color>( 411 + AppColors.textPrimary, 412 + ), 413 + ), 414 + ) 415 + : const Text('Post'), 416 + ), 417 + ), 418 + ], 17 419 ), 18 - body: const Center( 19 - child: Padding( 20 - padding: EdgeInsets.all(24), 420 + body: SafeArea( 421 + child: SingleChildScrollView( 422 + controller: _scrollController, 423 + padding: const EdgeInsets.all(16), 21 424 child: Column( 22 - mainAxisAlignment: MainAxisAlignment.center, 425 + crossAxisAlignment: CrossAxisAlignment.stretch, 23 426 children: [ 24 - Icon( 25 - Icons.add_circle_outline, 26 - size: 64, 27 - color: AppColors.primary, 427 + // Community selector 428 + _buildCommunitySelector(), 429 + 430 + const SizedBox(height: 16), 431 + 432 + // User info row 433 + _buildUserInfo(userHandle), 434 + 435 + const SizedBox(height: 24), 436 + 437 + // Title field 438 + _buildTextField( 439 + controller: _titleController, 440 + focusNode: _titleFocusNode, 441 + hintText: 'Title', 442 + maxLines: 1, 443 + maxLength: kTitleMaxLength, 444 + ), 445 + 446 + const SizedBox(height: 16), 447 + 448 + // URL field 449 + _buildTextField( 450 + controller: _urlController, 451 + focusNode: _urlFocusNode, 452 + hintText: 'URL', 453 + maxLines: 1, 454 + keyboardType: TextInputType.url, 28 455 ), 29 - SizedBox(height: 24), 30 - Text( 31 - 'Create Post', 32 - style: TextStyle( 33 - fontSize: 28, 34 - color: Colors.white, 35 - fontWeight: FontWeight.bold, 456 + 457 + // Thumbnail field (only visible when URL is filled) 458 + if (_urlController.text.trim().isNotEmpty) ...[ 459 + const SizedBox(height: 16), 460 + _buildTextField( 461 + controller: _thumbnailController, 462 + focusNode: _thumbnailFocusNode, 463 + hintText: 'Thumbnail URL', 464 + maxLines: 1, 465 + keyboardType: TextInputType.url, 36 466 ), 467 + ], 468 + 469 + const SizedBox(height: 16), 470 + 471 + // Body field (multiline) 472 + _buildTextField( 473 + controller: _bodyController, 474 + focusNode: _bodyFocusNode, 475 + hintText: 'What are your thoughts?', 476 + minLines: 8, 477 + maxLines: null, 478 + maxLength: kContentMaxLength, 37 479 ), 38 - SizedBox(height: 16), 39 - Text( 40 - 'Share your thoughts with the community', 41 - style: TextStyle(fontSize: 16, color: Color(0xFFB6C2D2)), 42 - textAlign: TextAlign.center, 480 + 481 + const SizedBox(height: 24), 482 + 483 + // Language dropdown and NSFW toggle 484 + Row( 485 + children: [ 486 + // Language dropdown 487 + Expanded( 488 + child: _buildLanguageDropdown(), 489 + ), 490 + 491 + const SizedBox(width: 16), 492 + 493 + // NSFW toggle 494 + Expanded( 495 + child: _buildNsfwToggle(), 496 + ), 497 + ], 43 498 ), 499 + 500 + const SizedBox(height: 24), 44 501 ], 45 502 ), 46 503 ), 504 + ), 505 + ), 506 + ); 507 + } 508 + 509 + Widget _buildCommunitySelector() { 510 + return Material( 511 + color: Colors.transparent, 512 + child: InkWell( 513 + onTap: _selectCommunity, 514 + borderRadius: BorderRadius.circular(12), 515 + child: Container( 516 + padding: const EdgeInsets.all(16), 517 + decoration: BoxDecoration( 518 + color: AppColors.backgroundSecondary, 519 + border: Border.all(color: AppColors.border), 520 + borderRadius: BorderRadius.circular(12), 521 + ), 522 + child: Row( 523 + children: [ 524 + const Icon( 525 + Icons.workspaces_outlined, 526 + color: AppColors.textSecondary, 527 + size: 20, 528 + ), 529 + const SizedBox(width: 12), 530 + Expanded( 531 + child: Text( 532 + _selectedCommunity?.displayName ?? 533 + _selectedCommunity?.name ?? 534 + 'Select a community', 535 + style: 536 + TextStyle( 537 + color: 538 + _selectedCommunity != null 539 + ? AppColors.textPrimary 540 + : AppColors.textSecondary, 541 + fontSize: 16, 542 + ), 543 + maxLines: 1, 544 + overflow: TextOverflow.ellipsis, 545 + ), 546 + ), 547 + const Icon( 548 + Icons.chevron_right, 549 + color: AppColors.textSecondary, 550 + size: 20, 551 + ), 552 + ], 553 + ), 554 + ), 555 + ), 556 + ); 557 + } 558 + 559 + Widget _buildUserInfo(String handle) { 560 + return Row( 561 + children: [ 562 + const Icon( 563 + Icons.person, 564 + color: AppColors.textSecondary, 565 + size: 16, 566 + ), 567 + const SizedBox(width: 8), 568 + Text( 569 + '@$handle', 570 + style: const TextStyle( 571 + color: AppColors.textSecondary, 572 + fontSize: 14, 573 + ), 574 + ), 575 + ], 576 + ); 577 + } 578 + 579 + Widget _buildTextField({ 580 + required TextEditingController controller, 581 + required String hintText, 582 + FocusNode? focusNode, 583 + int? maxLines, 584 + int? minLines, 585 + int? maxLength, 586 + TextInputType? keyboardType, 587 + TextInputAction? textInputAction, 588 + }) { 589 + // For multiline fields, use newline action and multiline keyboard 590 + final isMultiline = minLines != null && minLines > 1; 591 + final effectiveKeyboardType = 592 + keyboardType ?? (isMultiline ? TextInputType.multiline : TextInputType.text); 593 + final effectiveTextInputAction = 594 + textInputAction ?? (isMultiline ? TextInputAction.newline : TextInputAction.next); 595 + 596 + return TextField( 597 + controller: controller, 598 + focusNode: focusNode, 599 + maxLines: maxLines, 600 + minLines: minLines, 601 + maxLength: maxLength, 602 + keyboardType: effectiveKeyboardType, 603 + textInputAction: effectiveTextInputAction, 604 + textCapitalization: TextCapitalization.sentences, 605 + style: const TextStyle( 606 + color: AppColors.textPrimary, 607 + fontSize: 16, 608 + ), 609 + decoration: InputDecoration( 610 + hintText: hintText, 611 + hintStyle: const TextStyle(color: Color(0xFF5A6B7F)), 612 + filled: true, 613 + fillColor: const Color(0xFF1A2028), 614 + counterStyle: const TextStyle(color: AppColors.textSecondary), 615 + border: OutlineInputBorder( 616 + borderRadius: BorderRadius.circular(12), 617 + borderSide: const BorderSide(color: Color(0xFF2A3441)), 618 + ), 619 + enabledBorder: OutlineInputBorder( 620 + borderRadius: BorderRadius.circular(12), 621 + borderSide: const BorderSide(color: Color(0xFF2A3441)), 622 + ), 623 + focusedBorder: OutlineInputBorder( 624 + borderRadius: BorderRadius.circular(12), 625 + borderSide: const BorderSide( 626 + color: AppColors.primary, 627 + width: 2, 628 + ), 629 + ), 630 + contentPadding: const EdgeInsets.all(16), 631 + ), 632 + ); 633 + } 634 + 635 + Widget _buildLanguageDropdown() { 636 + return Container( 637 + padding: const EdgeInsets.symmetric(horizontal: 12), 638 + decoration: BoxDecoration( 639 + color: AppColors.backgroundSecondary, 640 + border: Border.all(color: AppColors.border), 641 + borderRadius: BorderRadius.circular(12), 642 + ), 643 + child: DropdownButtonHideUnderline( 644 + child: DropdownButton<String>( 645 + value: _language, 646 + dropdownColor: AppColors.backgroundSecondary, 647 + style: const TextStyle( 648 + color: AppColors.textPrimary, 649 + fontSize: 16, 650 + ), 651 + icon: const Icon( 652 + Icons.arrow_drop_down, 653 + color: AppColors.textSecondary, 654 + ), 655 + items: 656 + languages.entries.map((entry) { 657 + return DropdownMenuItem<String>( 658 + value: entry.key, 659 + child: Text(entry.value), 660 + ); 661 + }).toList(), 662 + onChanged: (value) { 663 + if (value != null) { 664 + setState(() { 665 + _language = value; 666 + }); 667 + } 668 + }, 669 + ), 670 + ), 671 + ); 672 + } 673 + 674 + Widget _buildNsfwToggle() { 675 + return Container( 676 + padding: const EdgeInsets.symmetric(horizontal: 12), 677 + decoration: BoxDecoration( 678 + color: AppColors.backgroundSecondary, 679 + border: Border.all(color: AppColors.border), 680 + borderRadius: BorderRadius.circular(12), 681 + ), 682 + child: Row( 683 + mainAxisAlignment: MainAxisAlignment.spaceBetween, 684 + children: [ 685 + const Text( 686 + 'NSFW', 687 + style: TextStyle( 688 + color: AppColors.textPrimary, 689 + fontSize: 16, 690 + ), 691 + ), 692 + Transform.scale( 693 + scale: 0.8, 694 + child: Switch.adaptive( 695 + value: _isNsfw, 696 + activeTrackColor: AppColors.primary, 697 + onChanged: (value) { 698 + setState(() { 699 + _isNsfw = value; 700 + }); 701 + }, 702 + ), 703 + ), 704 + ], 47 705 ), 48 706 ); 49 707 }
+7 -1
lib/screens/home/main_shell_screen.dart
··· 30 30 }); 31 31 } 32 32 33 + void _onNavigateToFeed() { 34 + setState(() { 35 + _selectedIndex = 0; // Switch to feed tab 36 + }); 37 + } 38 + 33 39 @override 34 40 Widget build(BuildContext context) { 35 41 return Scaffold( ··· 38 44 children: [ 39 45 FeedScreen(onSearchTap: _onCommunitiesTap), 40 46 const CommunitiesScreen(), 41 - const CreatePostScreen(), 47 + CreatePostScreen(onNavigateToFeed: _onNavigateToFeed), 42 48 const NotificationsScreen(), 43 49 const ProfileScreen(), 44 50 ],