import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'dart:async'; import '../../models/image_filter.dart'; import '../../models/post.dart'; import '../../models/sojorn_media_result.dart'; import '../../models/tone_analysis.dart'; import '../../providers/api_provider.dart'; import '../../providers/feed_refresh_provider.dart'; import '../../services/image_upload_service.dart'; import '../../theme/app_theme.dart'; import '../../widgets/composer/composer_toolbar.dart'; import '../../widgets/sojorn_snackbar.dart'; import 'image_editor_screen.dart'; import '../quips/create/quip_studio_screen.dart'; // Added import import '../quips/create/quip_editor_screen.dart'; // Added import class ComposeScreen extends ConsumerStatefulWidget { final Post? chainParentPost; const ComposeScreen({super.key, this.chainParentPost}); @override ConsumerState createState() => _ComposeScreenState(); } class _ComposeScreenState extends ConsumerState { final _bodyController = TextEditingController(); final _charCountNotifier = ValueNotifier(0); final _bodyFocusNode = FocusNode(); final ImageUploadService _imageUploadService = ImageUploadService(); bool _isLoading = false; bool _isUploadingImage = false; String? _errorMessage; final int _maxCharacters = 500; bool _allowChain = true; bool _isBold = false; bool _isItalic = false; int? _ttlHoursOverride; File? _selectedImageFile; Uint8List? _selectedImageBytes; String? _selectedImageName; ImageFilter? _selectedFilter; final ImagePicker _imagePicker = ImagePicker(); static const double _editorFontSize = 18; List _tagSuggestions = []; Timer? _hashtagDebounce; static const Map _ttlOptions = { null: 'Use default', 0: 'Forever', 12: '12 Hours', 24: '24 Hours', 72: '3 Days', 168: '1 Week', }; @override void initState() { super.initState(); _allowChain = true; _bodyController.addListener(() { _charCountNotifier.value = _bodyController.text.length; _handleHashtagSuggestions(); }); WidgetsBinding.instance.addPostFrameCallback((_) { _bodyFocusNode.requestFocus(); }); } @override void dispose() { _hashtagDebounce?.cancel(); _charCountNotifier.dispose(); _bodyFocusNode.dispose(); _bodyController.dispose(); super.dispose(); } // Refactored _pickMedia to support Video > QuipEditorScreen flow Future _pickMedia() async { final choice = await showModalBottomSheet( context: context, backgroundColor: AppTheme.cardSurface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: Icon(Icons.image, color: AppTheme.navyBlue), title: const Text('Image'), onTap: () => Navigator.pop(context, 'image'), ), ListTile( leading: Icon(Icons.videocam, color: AppTheme.navyBlue), title: const Text('Video'), onTap: () => Navigator.pop(context, 'video'), ), ], ), ), ); if (!mounted || choice == null) return; if (choice == 'image') { await _pickImage(); } else if (choice == 'video') { // Direct Record-to-Edit flow: Route immediately to QuipEditorScreen if (kIsWeb) { sojornSnackbar.showError( context: context, message: 'Video editing is not supported on web yet', ); return; } try { final XFile? pickedFile = await _imagePicker.pickVideo( source: ImageSource.gallery, ); if (pickedFile != null && mounted) { // Navigate directly to QuipStudioScreen (ProVideoEditor) // Note: using QuipStudioScreen as defined in quip_studio_screen.dart final result = await Navigator.push( context, MaterialPageRoute( builder: (context) => QuipStudioScreen( videoFile: File(pickedFile.path), ), ), ); // If video was posted successfully (returned true), close compose screen if (result == true && mounted) { Navigator.of(context).pop(true); } } } catch (e) { sojornSnackbar.showError( context: context, message: 'Failed to select video: ${e.toString()}', ); } } } Future _pickImage() async { try { final XFile? pickedFile = await _imagePicker.pickImage( source: ImageSource.gallery, maxWidth: 2048, maxHeight: 2048, imageQuality: 85, ); if (pickedFile != null && mounted) { final pickedBytes = kIsWeb ? await pickedFile.readAsBytes() : null; if (kIsWeb && (pickedBytes == null || pickedBytes.isEmpty)) { sojornSnackbar.showError( context: context, message: 'Failed to load image bytes for web', ); return; } final editorResult = await Navigator.push( context, MaterialPageRoute( builder: (context) => sojornImageEditor( imagePath: kIsWeb ? null : pickedFile.path, imageBytes: pickedBytes, imageName: pickedFile.name, ), ), ); if (editorResult != null && mounted) { setState(() { _selectedImageFile = editorResult.filePath != null ? File(editorResult.filePath!) : null; _selectedImageBytes = editorResult.bytes; _selectedImageName = editorResult.name ?? pickedFile.name; }); } } } catch (e) { sojornSnackbar.showError( context: context, message: 'Failed to select image: ${e.toString()}', ); } } void _removeImage() { setState(() { _selectedImageFile = null; _selectedImageBytes = null; _selectedImageName = null; _selectedFilter = null; }); } void _toggleBold() { setState(() { _isBold = !_isBold; }); } void _toggleItalic() { setState(() { _isItalic = !_isItalic; }); } void _toggleChain() { setState(() { _allowChain = !_allowChain; }); } Future _openTtlSelector() async { final choice = await showModalBottomSheet<_TtlChoice>( context: context, builder: (context) { return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: _ttlOptions.entries.map((entry) { final isSelected = entry.key == _ttlHoursOverride; return ListTile( title: Text(entry.value), trailing: isSelected ? Icon(Icons.check, color: AppTheme.brightNavy) : null, onTap: () => Navigator.pop(context, _TtlChoice(entry.key)), ); }).toList(), ), ); }, ); if (choice == null) return; setState(() => _ttlHoursOverride = choice.hours); } String? _activeHashtag() { final cursor = _bodyController.selection.baseOffset; if (cursor < 0 || cursor > _bodyController.text.length) return null; final prefix = _bodyController.text.substring(0, cursor); final match = RegExp(r'#([A-Za-z0-9_]{1,50})$').firstMatch(prefix); if (match != null) { return match.group(1)?.toLowerCase(); } return null; } void _handleHashtagSuggestions() { final tag = _activeHashtag(); _hashtagDebounce?.cancel(); if (tag == null || tag.isEmpty) { setState(() => _tagSuggestions = []); return; } _hashtagDebounce = Timer(const Duration(milliseconds: 200), () async { try { final apiService = ref.read(apiServiceProvider); final results = await apiService.search(tag); final suggestions = results.tags .map((t) => t.tag.toLowerCase()) .where((t) => t.startsWith(tag)) .toSet() .toList(); if (mounted) { setState(() { _tagSuggestions = suggestions; }); } } catch (_) { if (mounted) setState(() => _tagSuggestions = []); } }); } void _insertHashtag(String tag) { final cursor = _bodyController.selection.baseOffset; if (cursor < 0) return; final text = _bodyController.text; final prefix = text.substring(0, cursor); final suffix = text.substring(cursor); final match = RegExp(r'#([A-Za-z0-9_]{1,50})$').firstMatch(prefix); if (match == null) return; final start = match.start; final newText = prefix.substring(0, start) + '#$tag ' + suffix; _bodyController.value = TextEditingValue( text: newText, selection: TextSelection.collapsed(offset: start + tag.length + 2), ); setState(() { _tagSuggestions = []; }); } Future _publish() async { if (_bodyController.text.trim().isEmpty) { sojornSnackbar.showError( context: context, message: 'Post cannot be empty', ); return; } setState(() { _isLoading = true; _errorMessage = null; }); try { String? imageUrl; if (_selectedImageFile != null || _selectedImageBytes != null) { setState(() { _isUploadingImage = true; }); try { if (_selectedImageBytes != null) { imageUrl = await _imageUploadService.uploadImageBytes( _selectedImageBytes!, fileName: _selectedImageName, filter: _selectedFilter, ); } else if (_selectedImageFile != null) { imageUrl = await _imageUploadService.uploadImage( _selectedImageFile!, filter: _selectedFilter, ); } } catch (e) { throw Exception( 'Image upload failed: ${e.toString().replaceAll('Exception: ', '')}'); } finally { setState(() { _isUploadingImage = false; }); } } final apiService = ref.read(apiServiceProvider); final analysis = await apiService.checkTone( _bodyController.text.trim(), imageUrl: imageUrl, ); bool userWarned = false; if (!analysis.isAllowed) { if (mounted) { final shouldProceed = await _showWarningDialog(analysis); if (!shouldProceed) { setState(() => _isLoading = false); return; } userWarned = true; } } await apiService.publishPost( body: _bodyController.text.trim(), bodyFormat: 'plain', allowChain: _allowChain, chainParentId: widget.chainParentPost?.id, imageUrl: imageUrl, ttlHours: _ttlHoursOverride, userWarned: userWarned, ); if (mounted) { ref.read(feedRefreshProvider.notifier).state++; Navigator.of(context).pop(true); sojornSnackbar.showSuccess( context: context, message: 'Post published', ); } } on ToneCheckException { setState(() { _errorMessage = 'Content verification temporarily unavailable. Please try again.'; }); } catch (e) { setState(() { _errorMessage = e.toString().replaceAll('Exception: ', ''); }); } finally { if (mounted) { setState(() { _isLoading = false; }); } } } Future _showWarningDialog(ToneCheckResult analysis) async { final categoryLabel = analysis.categoryLabel; final message = 'Hold on. Our system detected this may contain $categoryLabel content. ' 'If you post this and it violates our guidelines, you will receive a Strike.'; final result = await showDialog( context: context, barrierDismissible: false, builder: (context) => AlertDialog( title: const Text('Hold on'), content: Text(message), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Edit'), ), ElevatedButton( onPressed: () => Navigator.pop(context, true), child: const Text('Post Anyway'), ), ], ), ); return result ?? false; } bool get _canPublish { return _bodyController.text.trim().isNotEmpty && _bodyController.text.trim().length <= _maxCharacters && !_isLoading && !_isUploadingImage; } @override Widget build(BuildContext context) { final bool isKeyboardOpen = MediaQuery.of(context).viewInsets.bottom > 0; return Scaffold( backgroundColor: AppTheme.scaffoldBg, appBar: PreferredSize( preferredSize: const Size.fromHeight(52), child: ValueListenableBuilder( valueListenable: _charCountNotifier, builder: (_, __, ___) => ComposeAppBar( isLoading: _isLoading, canPublish: _canPublish, postAction: _publish, replyTitle: widget.chainParentPost != null ? 'Reply to ${widget.chainParentPost!.author?.displayName ?? 'Anonymous'}' : null, ), ), ), body: SafeArea( child: Column( children: [ if (_errorMessage != null) Container( width: double.infinity, padding: const EdgeInsets.symmetric( horizontal: AppTheme.spacingMd, vertical: AppTheme.spacingSm, ), color: AppTheme.error.withValues(alpha: 0.08), child: Text( _errorMessage!, style: AppTheme.textTheme.labelSmall?.copyWith( color: AppTheme.error, ), ), ), // Reply context indicator if (widget.chainParentPost != null) Container( width: double.infinity, padding: const EdgeInsets.symmetric( horizontal: AppTheme.spacingMd, vertical: AppTheme.spacingSm, ), margin: const EdgeInsets.only(bottom: AppTheme.spacingSm), decoration: BoxDecoration( color: AppTheme.navyBlue.withValues(alpha: 0.05), borderRadius: BorderRadius.circular(8), border: Border.all( color: AppTheme.navyBlue.withValues(alpha: 0.2), width: 1, ), ), child: Row( children: [ Icon( Icons.reply, size: 16, color: AppTheme.navyBlue, ), const SizedBox(width: 8), Expanded( child: Text( 'Replying to ${widget.chainParentPost!.author?.displayName ?? 'Anonymous'}', style: AppTheme.labelSmall?.copyWith( color: AppTheme.navyBlue, fontWeight: FontWeight.w500, ), overflow: TextOverflow.ellipsis, ), ), ], ), ), Expanded( child: ComposeBody( controller: _bodyController, focusNode: _bodyFocusNode, isBold: _isBold, isItalic: _isItalic, suggestions: _tagSuggestions, onSelectSuggestion: _insertHashtag, imageWidget: (_selectedImageFile != null || _selectedImageBytes != null) && !isKeyboardOpen ? _buildImagePreview() : null, ), ), ], ), ), bottomNavigationBar: ValueListenableBuilder( valueListenable: _charCountNotifier, builder: (_, count, __) { final remaining = _maxCharacters - count; return AnimatedPadding( duration: const Duration(milliseconds: 150), padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom), child: ComposeBottomBar( onAddMedia: _pickMedia, onToggleBold: _toggleBold, onToggleItalic: _toggleItalic, onToggleChain: _toggleChain, onSelectTtl: _openTtlSelector, ttlOverrideActive: _ttlHoursOverride != null, ttlLabel: _ttlOptions[_ttlHoursOverride] ?? 'Use default', isBold: _isBold, isItalic: _isItalic, allowChain: _allowChain, characterCount: count, maxCharacters: _maxCharacters, isUploadingImage: _isUploadingImage, remainingChars: remaining, ), ); }, ), ); } Widget _buildImagePreview() { return Padding( padding: const EdgeInsets.symmetric( horizontal: AppTheme.spacingLg, vertical: AppTheme.spacingSm), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: Stack( children: [ AspectRatio( aspectRatio: 4 / 3, child: _selectedImageBytes != null ? Image.memory( _selectedImageBytes!, width: double.infinity, fit: BoxFit.cover, ) : Image.file( _selectedImageFile!, width: double.infinity, fit: BoxFit.cover, ), ), Positioned( top: 8, right: 8, child: Material( color: Colors.black.withOpacity(0.6), shape: const CircleBorder(), child: InkWell( onTap: _removeImage, customBorder: const CircleBorder(), child: const Padding( padding: EdgeInsets.all(6), child: Icon(Icons.close, color: Colors.white, size: 18), ), ), ), ), ], ), ), ); } } /// Minimal top bar with Cancel + Post pill class ComposeAppBar extends StatelessWidget { final bool isLoading; final bool canPublish; final VoidCallback postAction; final String? replyTitle; const ComposeAppBar({ super.key, required this.isLoading, required this.canPublish, required this.postAction, this.replyTitle, }); @override Widget build(BuildContext context) { final bool disabled = !canPublish || isLoading; return AppBar( elevation: 0, backgroundColor: AppTheme.scaffoldBg, leadingWidth: 80, leading: TextButton( onPressed: isLoading ? null : () => Navigator.of(context).pop(), child: Text( 'Cancel', style: TextStyle( color: AppTheme.egyptianBlue, fontWeight: FontWeight.w600, ), ), ), title: replyTitle != null ? Text( replyTitle!, style: AppTheme.labelSmall?.copyWith( color: AppTheme.navyText, fontSize: 14, ), ) : const SizedBox.shrink(), actions: [ Padding( padding: const EdgeInsets.only(right: AppTheme.spacingMd), child: AnimatedOpacity( duration: const Duration(milliseconds: 150), opacity: disabled ? 0.5 : 1.0, child: ElevatedButton( onPressed: disabled ? null : postAction, style: ElevatedButton.styleFrom( backgroundColor: AppTheme.brightNavy, foregroundColor: AppTheme.white, elevation: 0, padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), shape: const StadiumBorder(), ), child: isLoading ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: AppTheme.white, ), ) : const Text( 'Post', style: TextStyle( fontWeight: FontWeight.w700, fontSize: 15, ), ), ), ), ), ], ); } } /// Body with immersive canvas and optional media preview class ComposeBody extends StatelessWidget { final TextEditingController controller; final FocusNode focusNode; final bool isBold; final bool isItalic; final Widget? imageWidget; final List suggestions; final ValueChanged onSelectSuggestion; const ComposeBody({ super.key, required this.controller, required this.focusNode, required this.isBold, required this.isItalic, required this.suggestions, required this.onSelectSuggestion, this.imageWidget, }); @override Widget build(BuildContext context) { return Column( children: [ Expanded( child: Padding( padding: const EdgeInsets.symmetric( horizontal: AppTheme.spacingLg, vertical: AppTheme.spacingMd, ), child: Column( children: [ Expanded( child: TextField( controller: controller, focusNode: focusNode, autofocus: true, maxLines: null, minLines: null, expands: true, keyboardType: TextInputType.multiline, textInputAction: TextInputAction.newline, textAlignVertical: TextAlignVertical.top, style: AppTheme.textTheme.bodyLarge?.copyWith( fontSize: _ComposeScreenState._editorFontSize, height: 1.5, fontWeight: isBold ? FontWeight.w700 : FontWeight.w400, fontStyle: isItalic ? FontStyle.italic : FontStyle.normal, ), decoration: const InputDecoration( hintText: "What's happening?", border: InputBorder.none, isCollapsed: true, ), cursorColor: AppTheme.brightNavy, ), ), if (suggestions.isNotEmpty) Container( width: double.infinity, margin: const EdgeInsets.only(top: 8), decoration: BoxDecoration( color: AppTheme.cardSurface, borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.05), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: ListView.separated( shrinkWrap: true, itemCount: suggestions.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) { final tag = suggestions[index]; return ListTile( title: Text('#$tag', style: AppTheme.bodyMedium), onTap: () => onSelectSuggestion(tag), ); }, ), ), ], ), ), ), if (imageWidget != null) imageWidget!, ], ); } } /// Bottom bar pinned above the keyboard with formatting + counter class ComposeBottomBar extends StatelessWidget { final VoidCallback onAddMedia; final VoidCallback onToggleBold; final VoidCallback onToggleItalic; final VoidCallback onToggleChain; final VoidCallback onSelectTtl; final bool isBold; final bool isItalic; final bool allowChain; final bool ttlOverrideActive; final String ttlLabel; final int characterCount; final int maxCharacters; final bool isUploadingImage; final int remainingChars; const ComposeBottomBar({ super.key, required this.onAddMedia, required this.onToggleBold, required this.onToggleItalic, required this.onToggleChain, required this.onSelectTtl, required this.ttlOverrideActive, required this.ttlLabel, required this.isBold, required this.isItalic, required this.allowChain, required this.characterCount, required this.maxCharacters, required this.isUploadingImage, required this.remainingChars, }); @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( color: AppTheme.cardSurface, border: Border( top: BorderSide( color: AppTheme.egyptianBlue.withValues(alpha: 0.12), ), ), ), padding: const EdgeInsets.symmetric( horizontal: AppTheme.spacingMd, vertical: AppTheme.spacingSm, ), child: SafeArea( top: false, child: ComposerToolbar( onAddMedia: onAddMedia, onToggleBold: onToggleBold, onToggleItalic: onToggleItalic, onToggleChain: onToggleChain, onSelectTtl: onSelectTtl, ttlOverrideActive: ttlOverrideActive, ttlLabel: ttlLabel, isBold: isBold, isItalic: isItalic, allowChain: allowChain, characterCount: characterCount, maxCharacters: maxCharacters, isUploadingImage: isUploadingImage, remainingChars: remainingChars, ), ), ); } } class _TtlChoice { final int? hours; const _TtlChoice(this.hours); }