···
import 'package:flutter/material.dart';
+
import 'package:provider/provider.dart';
import '../../constants/app_colors.dart';
+
import '../../models/community.dart';
+
import '../../models/post.dart';
+
import '../../providers/auth_provider.dart';
+
import '../../services/api_exceptions.dart';
+
import '../../services/coves_api_service.dart';
+
import '../compose/community_picker_screen.dart';
+
import 'post_detail_screen.dart';
+
/// Language options for posts
+
const Map<String, String> languages = {
+
/// Content limits from backend lexicon (social.coves.community.post)
+
/// Using grapheme limits as they are the user-facing character counts
+
const int kTitleMaxLength = 300;
+
const int kContentMaxLength = 10000;
+
/// Full-screen interface for creating a new post in a community.
+
/// - Community selector (required)
+
/// - Optional title, URL, thumbnail, and body fields
+
/// - Language dropdown and NSFW toggle
+
/// - Form validation (at least one of title/body/URL required)
+
/// - Loading states and error handling
+
/// - Keyboard handling with scroll support
+
class CreatePostScreen extends StatefulWidget {
+
const CreatePostScreen({this.onNavigateToFeed, super.key});
+
/// Callback to navigate to feed tab (used when in tab navigation)
+
final VoidCallback? onNavigateToFeed;
+
State<CreatePostScreen> createState() => _CreatePostScreenState();
+
class _CreatePostScreenState extends State<CreatePostScreen>
+
with WidgetsBindingObserver {
+
final TextEditingController _titleController = TextEditingController();
+
final TextEditingController _urlController = TextEditingController();
+
final TextEditingController _thumbnailController = TextEditingController();
+
final TextEditingController _bodyController = TextEditingController();
+
final ScrollController _scrollController = ScrollController();
+
final FocusNode _titleFocusNode = FocusNode();
+
final FocusNode _urlFocusNode = FocusNode();
+
final FocusNode _thumbnailFocusNode = FocusNode();
+
final FocusNode _bodyFocusNode = FocusNode();
+
double _lastKeyboardHeight = 0;
+
CommunityView? _selectedCommunity;
+
String _language = 'en';
+
bool _isSubmitting = false;
+
bool get _isFormValid {
+
return _selectedCommunity != null &&
+
(_titleController.text.trim().isNotEmpty ||
+
_bodyController.text.trim().isNotEmpty ||
+
_urlController.text.trim().isNotEmpty);
+
WidgetsBinding.instance.addObserver(this);
+
// Listen to text changes to update button state
+
_titleController.addListener(_onTextChanged);
+
_urlController.addListener(_onTextChanged);
+
_bodyController.addListener(_onTextChanged);
+
WidgetsBinding.instance.removeObserver(this);
+
_titleController.dispose();
+
_urlController.dispose();
+
_thumbnailController.dispose();
+
_bodyController.dispose();
+
_scrollController.dispose();
+
_titleFocusNode.dispose();
+
_urlFocusNode.dispose();
+
_thumbnailFocusNode.dispose();
+
_bodyFocusNode.dispose();
+
void didChangeMetrics() {
+
super.didChangeMetrics();
+
final keyboardHeight = View.of(context).viewInsets.bottom;
+
// Detect keyboard closing and unfocus all text fields
+
if (_lastKeyboardHeight > 0 && keyboardHeight == 0) {
+
FocusManager.instance.primaryFocus?.unfocus();
+
_lastKeyboardHeight = keyboardHeight;
+
void _onTextChanged() {
+
// Force rebuild to update Post button state
+
Future<void> _selectCommunity() async {
+
final result = await Navigator.push<CommunityView>(
+
builder: (context) => const CommunityPickerScreen(),
+
if (result != null && mounted) {
+
_selectedCommunity = result;
+
Future<void> _handleSubmit() async {
+
if (!_isFormValid || _isSubmitting) {
+
final authProvider = context.read<AuthProvider>();
+
// Create API service with auth
+
final apiService = CovesApiService(
+
tokenGetter: authProvider.getAccessToken,
+
tokenRefresher: authProvider.refreshToken,
+
signOutHandler: authProvider.signOut,
+
// Build embed if URL is provided
+
ExternalEmbedInput? embed;
+
final url = _urlController.text.trim();
+
final uri = Uri.tryParse(url);
+
(!uri.scheme.startsWith('http'))) {
+
ScaffoldMessenger.of(context).showSnackBar(
+
content: const Text('Please enter a valid URL (http or https)'),
+
backgroundColor: Colors.red[700],
+
behavior: SnackBarBehavior.floating,
+
embed = ExternalEmbedInput(
+
title: _titleController.text.trim().isNotEmpty
+
? _titleController.text.trim()
+
thumb: _thumbnailController.text.trim().isNotEmpty
+
? _thumbnailController.text.trim()
+
// Build labels if NSFW is enabled
+
labels = const SelfLabels(values: [SelfLabel(val: 'nsfw')]);
+
final response = await apiService.createPost(
+
community: _selectedCommunity!.did,
+
title: _titleController.text.trim().isNotEmpty
+
? _titleController.text.trim()
+
content: _bodyController.text.trim().isNotEmpty
+
? _bodyController.text.trim()
+
// Build optimistic post for immediate display
+
final optimisticPost = _buildOptimisticPost(
+
authProvider: authProvider,
+
// Navigate to post detail with optimistic data
+
builder: (context) => PostDetailScreen(
+
} on ApiException catch (e) {
+
ScaffoldMessenger.of(context).showSnackBar(
+
content: Text('Failed to create post: ${e.message}'),
+
backgroundColor: Colors.red[700],
+
behavior: SnackBarBehavior.floating,
+
} on Exception catch (e) {
+
ScaffoldMessenger.of(context).showSnackBar(
+
content: Text('Failed to create post: ${e.toString()}'),
+
backgroundColor: Colors.red[700],
+
behavior: SnackBarBehavior.floating,
+
_titleController.clear();
+
_urlController.clear();
+
_thumbnailController.clear();
+
_bodyController.clear();
+
_selectedCommunity = null;
+
/// Build optimistic post for immediate display after creation
+
FeedViewPost _buildOptimisticPost({
+
required CreatePostResponse response,
+
required AuthProvider authProvider,
+
// Extract rkey from AT-URI (at://did/collection/rkey)
+
final uriParts = response.uri.split('/');
+
final rkey = uriParts.isNotEmpty ? uriParts.last : '';
+
// Build embed if URL was provided
+
final url = _urlController.text.trim();
+
type: 'social.coves.embed.external',
+
external: ExternalEmbed(
+
title: _titleController.text.trim().isNotEmpty
+
? _titleController.text.trim()
+
thumb: _thumbnailController.text.trim().isNotEmpty
+
? _thumbnailController.text.trim()
+
r'$type': 'social.coves.embed.external',
+
if (_titleController.text.trim().isNotEmpty)
+
'title': _titleController.text.trim(),
+
if (_thumbnailController.text.trim().isNotEmpty)
+
'thumb': _thumbnailController.text.trim(),
+
final now = DateTime.now();
+
did: authProvider.did ?? '',
+
handle: authProvider.handle ?? 'unknown',
+
community: CommunityRef(
+
did: _selectedCommunity!.did,
+
name: _selectedCommunity!.name,
+
handle: _selectedCommunity!.handle,
+
avatar: _selectedCommunity!.avatar,
+
text: _bodyController.text.trim(),
+
title: _titleController.text.trim().isNotEmpty
+
? _titleController.text.trim()
Widget build(BuildContext context) {
+
final authProvider = context.watch<AuthProvider>();
+
final userHandle = authProvider.handle ?? 'Unknown';
+
canPop: widget.onNavigateToFeed == null,
+
onPopInvokedWithResult: (didPop, result) {
+
if (!didPop && widget.onNavigateToFeed != null) {
+
widget.onNavigateToFeed!();
+
backgroundColor: AppColors.background,
+
backgroundColor: AppColors.background,
+
surfaceTintColor: Colors.transparent,
+
foregroundColor: AppColors.textPrimary,
title: const Text('Create Post'),
automaticallyImplyLeading: false,
+
icon: const Icon(Icons.close),
+
// Use callback if available (tab navigation), otherwise pop
+
if (widget.onNavigateToFeed != null) {
+
widget.onNavigateToFeed!();
+
Navigator.pop(context);
+
padding: const EdgeInsets.only(right: 8),
+
onPressed: _isFormValid && !_isSubmitting ? _handleSubmit : null,
+
style: TextButton.styleFrom(
+
backgroundColor: _isFormValid && !_isSubmitting
+
: AppColors.textSecondary.withValues(alpha: 0.3),
+
foregroundColor: AppColors.textPrimary,
+
padding: const EdgeInsets.symmetric(
+
shape: RoundedRectangleBorder(
+
borderRadius: BorderRadius.circular(20),
+
child: CircularProgressIndicator(
+
valueColor: AlwaysStoppedAnimation<Color>(
+
child: SingleChildScrollView(
+
controller: _scrollController,
+
padding: const EdgeInsets.all(16),
+
crossAxisAlignment: CrossAxisAlignment.stretch,
+
_buildCommunitySelector(),
+
const SizedBox(height: 16),
+
_buildUserInfo(userHandle),
+
const SizedBox(height: 24),
+
controller: _titleController,
+
focusNode: _titleFocusNode,
+
maxLength: kTitleMaxLength,
+
const SizedBox(height: 16),
+
controller: _urlController,
+
focusNode: _urlFocusNode,
+
keyboardType: TextInputType.url,
+
// Thumbnail field (only visible when URL is filled)
+
if (_urlController.text.trim().isNotEmpty) ...[
+
const SizedBox(height: 16),
+
controller: _thumbnailController,
+
focusNode: _thumbnailFocusNode,
+
hintText: 'Thumbnail URL',
+
keyboardType: TextInputType.url,
+
const SizedBox(height: 16),
+
// Body field (multiline)
+
controller: _bodyController,
+
focusNode: _bodyFocusNode,
+
hintText: 'What are your thoughts?',
+
maxLength: kContentMaxLength,
+
const SizedBox(height: 24),
+
// Language dropdown and NSFW toggle
+
child: _buildLanguageDropdown(),
+
const SizedBox(width: 16),
+
child: _buildNsfwToggle(),
+
const SizedBox(height: 24),
+
Widget _buildCommunitySelector() {
+
color: Colors.transparent,
+
onTap: _selectCommunity,
+
borderRadius: BorderRadius.circular(12),
+
padding: const EdgeInsets.all(16),
+
decoration: BoxDecoration(
+
color: AppColors.backgroundSecondary,
+
border: Border.all(color: AppColors.border),
+
borderRadius: BorderRadius.circular(12),
+
Icons.workspaces_outlined,
+
color: AppColors.textSecondary,
+
const SizedBox(width: 12),
+
_selectedCommunity?.displayName ??
+
_selectedCommunity?.name ??
+
_selectedCommunity != null
+
? AppColors.textPrimary
+
: AppColors.textSecondary,
+
overflow: TextOverflow.ellipsis,
+
color: AppColors.textSecondary,
+
Widget _buildUserInfo(String handle) {
+
color: AppColors.textSecondary,
+
const SizedBox(width: 8),
+
style: const TextStyle(
+
color: AppColors.textSecondary,
+
Widget _buildTextField({
+
required TextEditingController controller,
+
required String hintText,
+
TextInputType? keyboardType,
+
TextInputAction? textInputAction,
+
// For multiline fields, use newline action and multiline keyboard
+
final isMultiline = minLines != null && minLines > 1;
+
final effectiveKeyboardType =
+
keyboardType ?? (isMultiline ? TextInputType.multiline : TextInputType.text);
+
final effectiveTextInputAction =
+
textInputAction ?? (isMultiline ? TextInputAction.newline : TextInputAction.next);
+
controller: controller,
+
keyboardType: effectiveKeyboardType,
+
textInputAction: effectiveTextInputAction,
+
textCapitalization: TextCapitalization.sentences,
+
style: const TextStyle(
+
color: AppColors.textPrimary,
+
decoration: InputDecoration(
+
hintStyle: const TextStyle(color: Color(0xFF5A6B7F)),
+
fillColor: const Color(0xFF1A2028),
+
counterStyle: const TextStyle(color: AppColors.textSecondary),
+
border: OutlineInputBorder(
+
borderRadius: BorderRadius.circular(12),
+
borderSide: const BorderSide(color: Color(0xFF2A3441)),
+
enabledBorder: OutlineInputBorder(
+
borderRadius: BorderRadius.circular(12),
+
borderSide: const BorderSide(color: Color(0xFF2A3441)),
+
focusedBorder: OutlineInputBorder(
+
borderRadius: BorderRadius.circular(12),
+
borderSide: const BorderSide(
+
color: AppColors.primary,
+
contentPadding: const EdgeInsets.all(16),
+
Widget _buildLanguageDropdown() {
+
padding: const EdgeInsets.symmetric(horizontal: 12),
+
decoration: BoxDecoration(
+
color: AppColors.backgroundSecondary,
+
border: Border.all(color: AppColors.border),
+
borderRadius: BorderRadius.circular(12),
+
child: DropdownButtonHideUnderline(
+
child: DropdownButton<String>(
+
dropdownColor: AppColors.backgroundSecondary,
+
style: const TextStyle(
+
color: AppColors.textPrimary,
+
color: AppColors.textSecondary,
+
languages.entries.map((entry) {
+
return DropdownMenuItem<String>(
+
child: Text(entry.value),
+
Widget _buildNsfwToggle() {
+
padding: const EdgeInsets.symmetric(horizontal: 12),
+
decoration: BoxDecoration(
+
color: AppColors.backgroundSecondary,
+
border: Border.all(color: AppColors.border),
+
borderRadius: BorderRadius.circular(12),
+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
+
color: AppColors.textPrimary,
+
child: Switch.adaptive(
+
activeTrackColor: AppColors.primary,