···
import 'package:flutter/material.dart';
2
+
import 'package:provider/provider.dart';
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';
5
-
class CreatePostScreen extends StatelessWidget {
6
-
const CreatePostScreen({super.key});
13
+
/// Language options for posts
14
+
const Map<String, String> languages = {
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;
30
+
/// Create Post Screen
32
+
/// Full-screen interface for creating a new post in a community.
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});
44
+
/// Callback to navigate to feed tab (used when in tab navigation)
45
+
final VoidCallback? onNavigateToFeed;
48
+
State<CreatePostScreen> createState() => _CreatePostScreenState();
51
+
class _CreatePostScreenState extends State<CreatePostScreen>
52
+
with WidgetsBindingObserver {
54
+
final TextEditingController _titleController = TextEditingController();
55
+
final TextEditingController _urlController = TextEditingController();
56
+
final TextEditingController _thumbnailController = TextEditingController();
57
+
final TextEditingController _bodyController = TextEditingController();
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;
68
+
CommunityView? _selectedCommunity;
69
+
String _language = 'en';
70
+
bool _isNsfw = false;
71
+
bool _isSubmitting = false;
74
+
bool get _isFormValid {
75
+
return _selectedCommunity != null &&
76
+
(_titleController.text.trim().isNotEmpty ||
77
+
_bodyController.text.trim().isNotEmpty ||
78
+
_urlController.text.trim().isNotEmpty);
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);
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();
107
+
void didChangeMetrics() {
108
+
super.didChangeMetrics();
113
+
final keyboardHeight = View.of(context).viewInsets.bottom;
115
+
// Detect keyboard closing and unfocus all text fields
116
+
if (_lastKeyboardHeight > 0 && keyboardHeight == 0) {
117
+
FocusManager.instance.primaryFocus?.unfocus();
120
+
_lastKeyboardHeight = keyboardHeight;
123
+
void _onTextChanged() {
124
+
// Force rebuild to update Post button state
128
+
Future<void> _selectCommunity() async {
129
+
final result = await Navigator.push<CommunityView>(
132
+
builder: (context) => const CommunityPickerScreen(),
136
+
if (result != null && mounted) {
138
+
_selectedCommunity = result;
143
+
Future<void> _handleSubmit() async {
144
+
if (!_isFormValid || _isSubmitting) {
149
+
_isSubmitting = true;
153
+
final authProvider = context.read<AuthProvider>();
155
+
// Create API service with auth
156
+
final apiService = CovesApiService(
157
+
tokenGetter: authProvider.getAccessToken,
158
+
tokenRefresher: authProvider.refreshToken,
159
+
signOutHandler: authProvider.signOut,
162
+
// Build embed if URL is provided
163
+
ExternalEmbedInput? embed;
164
+
final url = _urlController.text.trim();
165
+
if (url.isNotEmpty) {
167
+
final uri = Uri.tryParse(url);
170
+
(!uri.scheme.startsWith('http'))) {
172
+
ScaffoldMessenger.of(context).showSnackBar(
174
+
content: const Text('Please enter a valid URL (http or https)'),
175
+
backgroundColor: Colors.red[700],
176
+
behavior: SnackBarBehavior.floating,
181
+
_isSubmitting = false;
186
+
embed = ExternalEmbedInput(
188
+
title: _titleController.text.trim().isNotEmpty
189
+
? _titleController.text.trim()
191
+
thumb: _thumbnailController.text.trim().isNotEmpty
192
+
? _thumbnailController.text.trim()
197
+
// Build labels if NSFW is enabled
198
+
SelfLabels? labels;
200
+
labels = const SelfLabels(values: [SelfLabel(val: 'nsfw')]);
204
+
final response = await apiService.createPost(
205
+
community: _selectedCommunity!.did,
206
+
title: _titleController.text.trim().isNotEmpty
207
+
? _titleController.text.trim()
209
+
content: _bodyController.text.trim().isNotEmpty
210
+
? _bodyController.text.trim()
213
+
langs: [_language],
218
+
// Build optimistic post for immediate display
219
+
final optimisticPost = _buildOptimisticPost(
220
+
response: response,
221
+
authProvider: authProvider,
224
+
// Reset form first
227
+
// Navigate to post detail with optimistic data
228
+
await Navigator.push(
231
+
builder: (context) => PostDetailScreen(
232
+
post: optimisticPost,
233
+
isOptimistic: true,
238
+
} on ApiException catch (e) {
240
+
ScaffoldMessenger.of(context).showSnackBar(
242
+
content: Text('Failed to create post: ${e.message}'),
243
+
backgroundColor: Colors.red[700],
244
+
behavior: SnackBarBehavior.floating,
248
+
} on Exception catch (e) {
250
+
ScaffoldMessenger.of(context).showSnackBar(
252
+
content: Text('Failed to create post: ${e.toString()}'),
253
+
backgroundColor: Colors.red[700],
254
+
behavior: SnackBarBehavior.floating,
261
+
_isSubmitting = false;
267
+
void _resetForm() {
269
+
_titleController.clear();
270
+
_urlController.clear();
271
+
_thumbnailController.clear();
272
+
_bodyController.clear();
273
+
_selectedCommunity = null;
279
+
/// Build optimistic post for immediate display after creation
280
+
FeedViewPost _buildOptimisticPost({
281
+
required CreatePostResponse response,
282
+
required AuthProvider authProvider,
284
+
// Extract rkey from AT-URI (at://did/collection/rkey)
285
+
final uriParts = response.uri.split('/');
286
+
final rkey = uriParts.isNotEmpty ? uriParts.last : '';
288
+
// Build embed if URL was provided
290
+
final url = _urlController.text.trim();
291
+
if (url.isNotEmpty) {
293
+
type: 'social.coves.embed.external',
294
+
external: ExternalEmbed(
296
+
title: _titleController.text.trim().isNotEmpty
297
+
? _titleController.text.trim()
299
+
thumb: _thumbnailController.text.trim().isNotEmpty
300
+
? _thumbnailController.text.trim()
304
+
r'$type': 'social.coves.embed.external',
307
+
if (_titleController.text.trim().isNotEmpty)
308
+
'title': _titleController.text.trim(),
309
+
if (_thumbnailController.text.trim().isNotEmpty)
310
+
'thumb': _thumbnailController.text.trim(),
316
+
final now = DateTime.now();
318
+
return FeedViewPost(
323
+
author: AuthorView(
324
+
did: authProvider.did ?? '',
325
+
handle: authProvider.handle ?? 'unknown',
329
+
community: CommunityRef(
330
+
did: _selectedCommunity!.did,
331
+
name: _selectedCommunity!.name,
332
+
handle: _selectedCommunity!.handle,
333
+
avatar: _selectedCommunity!.avatar,
337
+
text: _bodyController.text.trim(),
338
+
title: _titleController.text.trim().isNotEmpty
339
+
? _titleController.text.trim()
348
+
viewer: ViewerState(),
Widget build(BuildContext context) {
11
-
backgroundColor: const Color(0xFF0B0F14),
13
-
backgroundColor: const Color(0xFF0B0F14),
14
-
foregroundColor: Colors.white,
355
+
final authProvider = context.watch<AuthProvider>();
356
+
final userHandle = authProvider.handle ?? 'Unknown';
359
+
canPop: widget.onNavigateToFeed == null,
360
+
onPopInvokedWithResult: (didPop, result) {
361
+
if (!didPop && widget.onNavigateToFeed != null) {
362
+
widget.onNavigateToFeed!();
366
+
backgroundColor: AppColors.background,
368
+
backgroundColor: AppColors.background,
369
+
surfaceTintColor: Colors.transparent,
370
+
foregroundColor: AppColors.textPrimary,
title: const Text('Create Post'),
automaticallyImplyLeading: false,
374
+
leading: IconButton(
375
+
icon: const Icon(Icons.close),
377
+
// Use callback if available (tab navigation), otherwise pop
378
+
if (widget.onNavigateToFeed != null) {
379
+
widget.onNavigateToFeed!();
381
+
Navigator.pop(context);
387
+
padding: const EdgeInsets.only(right: 8),
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(
399
+
shape: RoundedRectangleBorder(
400
+
borderRadius: BorderRadius.circular(20),
408
+
child: CircularProgressIndicator(
410
+
valueColor: AlwaysStoppedAnimation<Color>(
411
+
AppColors.textPrimary,
415
+
: const Text('Post'),
20
-
padding: EdgeInsets.all(24),
421
+
child: SingleChildScrollView(
422
+
controller: _scrollController,
423
+
padding: const EdgeInsets.all(16),
22
-
mainAxisAlignment: MainAxisAlignment.center,
425
+
crossAxisAlignment: CrossAxisAlignment.stretch,
25
-
Icons.add_circle_outline,
27
-
color: AppColors.primary,
427
+
// Community selector
428
+
_buildCommunitySelector(),
430
+
const SizedBox(height: 16),
433
+
_buildUserInfo(userHandle),
435
+
const SizedBox(height: 24),
439
+
controller: _titleController,
440
+
focusNode: _titleFocusNode,
443
+
maxLength: kTitleMaxLength,
446
+
const SizedBox(height: 16),
450
+
controller: _urlController,
451
+
focusNode: _urlFocusNode,
454
+
keyboardType: TextInputType.url,
29
-
SizedBox(height: 24),
34
-
color: Colors.white,
35
-
fontWeight: FontWeight.bold,
457
+
// Thumbnail field (only visible when URL is filled)
458
+
if (_urlController.text.trim().isNotEmpty) ...[
459
+
const SizedBox(height: 16),
461
+
controller: _thumbnailController,
462
+
focusNode: _thumbnailFocusNode,
463
+
hintText: 'Thumbnail URL',
465
+
keyboardType: TextInputType.url,
469
+
const SizedBox(height: 16),
471
+
// Body field (multiline)
473
+
controller: _bodyController,
474
+
focusNode: _bodyFocusNode,
475
+
hintText: 'What are your thoughts?',
478
+
maxLength: kContentMaxLength,
38
-
SizedBox(height: 16),
40
-
'Share your thoughts with the community',
41
-
style: TextStyle(fontSize: 16, color: Color(0xFFB6C2D2)),
42
-
textAlign: TextAlign.center,
481
+
const SizedBox(height: 24),
483
+
// Language dropdown and NSFW toggle
486
+
// Language dropdown
488
+
child: _buildLanguageDropdown(),
491
+
const SizedBox(width: 16),
495
+
child: _buildNsfwToggle(),
500
+
const SizedBox(height: 24),
509
+
Widget _buildCommunitySelector() {
511
+
color: Colors.transparent,
513
+
onTap: _selectCommunity,
514
+
borderRadius: BorderRadius.circular(12),
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),
525
+
Icons.workspaces_outlined,
526
+
color: AppColors.textSecondary,
529
+
const SizedBox(width: 12),
532
+
_selectedCommunity?.displayName ??
533
+
_selectedCommunity?.name ??
534
+
'Select a community',
538
+
_selectedCommunity != null
539
+
? AppColors.textPrimary
540
+
: AppColors.textSecondary,
544
+
overflow: TextOverflow.ellipsis,
548
+
Icons.chevron_right,
549
+
color: AppColors.textSecondary,
559
+
Widget _buildUserInfo(String handle) {
564
+
color: AppColors.textSecondary,
567
+
const SizedBox(width: 8),
570
+
style: const TextStyle(
571
+
color: AppColors.textSecondary,
579
+
Widget _buildTextField({
580
+
required TextEditingController controller,
581
+
required String hintText,
582
+
FocusNode? focusNode,
586
+
TextInputType? keyboardType,
587
+
TextInputAction? textInputAction,
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);
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,
609
+
decoration: InputDecoration(
610
+
hintText: hintText,
611
+
hintStyle: const TextStyle(color: Color(0xFF5A6B7F)),
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)),
619
+
enabledBorder: OutlineInputBorder(
620
+
borderRadius: BorderRadius.circular(12),
621
+
borderSide: const BorderSide(color: Color(0xFF2A3441)),
623
+
focusedBorder: OutlineInputBorder(
624
+
borderRadius: BorderRadius.circular(12),
625
+
borderSide: const BorderSide(
626
+
color: AppColors.primary,
630
+
contentPadding: const EdgeInsets.all(16),
635
+
Widget _buildLanguageDropdown() {
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),
643
+
child: DropdownButtonHideUnderline(
644
+
child: DropdownButton<String>(
646
+
dropdownColor: AppColors.backgroundSecondary,
647
+
style: const TextStyle(
648
+
color: AppColors.textPrimary,
652
+
Icons.arrow_drop_down,
653
+
color: AppColors.textSecondary,
656
+
languages.entries.map((entry) {
657
+
return DropdownMenuItem<String>(
659
+
child: Text(entry.value),
662
+
onChanged: (value) {
663
+
if (value != null) {
674
+
Widget _buildNsfwToggle() {
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),
683
+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
688
+
color: AppColors.textPrimary,
694
+
child: Switch.adaptive(
696
+
activeTrackColor: AppColors.primary,
697
+
onChanged: (value) {