at main 21 kB view raw
1import 'package:flutter/material.dart'; 2import 'package:provider/provider.dart'; 3 4import '../../constants/app_colors.dart'; 5import '../../models/community.dart'; 6import '../../models/post.dart'; 7import '../../providers/auth_provider.dart'; 8import '../../services/api_exceptions.dart'; 9import '../../services/coves_api_service.dart'; 10import '../compose/community_picker_screen.dart'; 11import 'post_detail_screen.dart'; 12 13/// Language options for posts 14const 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 27const int kTitleMaxLength = 300; 28const 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 41class 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 51class _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 } 352 353 @override 354 Widget build(BuildContext context) { 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, 371 title: const Text('Create Post'), 372 elevation: 0, 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 ], 419 ), 420 body: SafeArea( 421 child: SingleChildScrollView( 422 controller: _scrollController, 423 padding: const EdgeInsets.all(16), 424 child: Column( 425 crossAxisAlignment: CrossAxisAlignment.stretch, 426 children: [ 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, 455 ), 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, 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, 479 ), 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 ], 498 ), 499 500 const SizedBox(height: 24), 501 ], 502 ), 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 ], 705 ), 706 ); 707 } 708}