Main coves client
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}