1073 lines
33 KiB
Dart
1073 lines
33 KiB
Dart
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/api_service.dart';
|
|
import '../../services/image_upload_service.dart';
|
|
import '../../theme/app_theme.dart';
|
|
import '../../theme/tokens.dart';
|
|
import '../../widgets/composer/composer_toolbar.dart';
|
|
import '../../services/content_filter.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<ComposeScreen> createState() => _ComposeScreenState();
|
|
}
|
|
|
|
class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|
final _bodyController = TextEditingController();
|
|
final _charCountNotifier = ValueNotifier<int>(0);
|
|
final _bodyFocusNode = FocusNode();
|
|
final ImageUploadService _imageUploadService = ImageUploadService();
|
|
|
|
bool _isLoading = false;
|
|
bool _isUploadingImage = false;
|
|
bool _popped = false;
|
|
String? _errorMessage;
|
|
String? _blockedMessage;
|
|
final int _maxCharacters = 500;
|
|
bool _allowChain = true;
|
|
bool _isNsfw = false;
|
|
bool _isBold = false;
|
|
bool _isItalic = false;
|
|
int? _ttlHoursOverride;
|
|
|
|
// Link preview state
|
|
String? _detectedUrl;
|
|
Map<String, dynamic>? _linkPreview;
|
|
bool _isFetchingPreview = false;
|
|
Timer? _urlDebounce;
|
|
bool _isTyping = false;
|
|
|
|
File? _selectedImageFile;
|
|
Uint8List? _selectedImageBytes;
|
|
String? _selectedImageName;
|
|
ImageFilter? _selectedFilter;
|
|
final ImagePicker _imagePicker = ImagePicker();
|
|
static const double _editorFontSize = 18;
|
|
List<String> _tagSuggestions = [];
|
|
Timer? _hashtagDebounce;
|
|
static const Map<int?, String> _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();
|
|
_handleUrlDetection();
|
|
// Clear blocked banner when user edits their text
|
|
if (_blockedMessage != null) {
|
|
setState(() => _blockedMessage = null);
|
|
}
|
|
});
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
_bodyFocusNode.requestFocus();
|
|
});
|
|
}
|
|
|
|
/// Detect URLs in text and fetch a preview after a pause in typing.
|
|
void _handleUrlDetection() {
|
|
setState(() => _isTyping = true);
|
|
_urlDebounce?.cancel();
|
|
_urlDebounce = Timer(const Duration(milliseconds: 800), () {
|
|
if (!mounted) return;
|
|
setState(() => _isTyping = false);
|
|
final urlMatch = RegExp(r'https?://\S+').firstMatch(_bodyController.text);
|
|
final url = urlMatch?.group(0);
|
|
if (url != null && url != _detectedUrl && url.length > 10) {
|
|
_detectedUrl = url;
|
|
_fetchLinkPreview(url);
|
|
} else if (url == null) {
|
|
setState(() {
|
|
_detectedUrl = null;
|
|
_linkPreview = null;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _fetchLinkPreview(String url) async {
|
|
if (_isFetchingPreview) return;
|
|
setState(() => _isFetchingPreview = true);
|
|
try {
|
|
final data = await ApiService.instance.callGoApi(
|
|
'/safe-domains/check',
|
|
method: 'GET',
|
|
queryParams: {'url': url},
|
|
);
|
|
if (!mounted) return;
|
|
// Now fetch the OG preview from a lightweight endpoint
|
|
// For now, just show the URL + safety status as a card
|
|
setState(() {
|
|
_linkPreview = {
|
|
'url': url,
|
|
'domain': data['domain'] ?? '',
|
|
'safe': data['safe'] ?? false,
|
|
'status': data['status'] ?? 'unknown',
|
|
};
|
|
});
|
|
} catch (_) {
|
|
// Silently fail - preview is optional
|
|
}
|
|
if (mounted) setState(() => _isFetchingPreview = false);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_urlDebounce?.cancel();
|
|
_hashtagDebounce?.cancel();
|
|
_charCountNotifier.dispose();
|
|
_bodyFocusNode.dispose();
|
|
_bodyController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
// Refactored _pickMedia to support Video > QuipEditorScreen flow
|
|
Future<void> _pickMedia() async {
|
|
final choice = await showModalBottomSheet<String>(
|
|
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<bool>(
|
|
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<void> _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<SojornMediaResult>(
|
|
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<void> _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<void> _publish() async {
|
|
if (_bodyController.text.trim().isEmpty) {
|
|
sojornSnackbar.showError(
|
|
context: context,
|
|
message: 'Post cannot be empty',
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Layer 0: Client-side hard blocklist — never even send to server
|
|
final blockMessage = ContentFilter.instance.check(_bodyController.text.trim());
|
|
if (blockMessage != null) {
|
|
setState(() => _blockedMessage = blockMessage);
|
|
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);
|
|
|
|
await apiService.publishPost(
|
|
body: _bodyController.text.trim(),
|
|
bodyFormat: 'plain',
|
|
allowChain: _allowChain,
|
|
chainParentId: widget.chainParentPost?.id,
|
|
imageUrl: imageUrl,
|
|
ttlHours: _ttlHoursOverride,
|
|
isNsfw: _isNsfw,
|
|
);
|
|
|
|
if (mounted && !_popped) {
|
|
_popped = true;
|
|
ref.read(feedRefreshProvider.notifier).increment();
|
|
Navigator.of(context).pop(true);
|
|
return; // Skip finally setState — widget is being disposed
|
|
}
|
|
} catch (e) {
|
|
if (!mounted || _popped) return;
|
|
final msg = e.toString().replaceAll('Exception: ', '');
|
|
// Server-side blocklist catch (422 with blocked content message)
|
|
if (msg.contains("isn't allowed on Sojorn") || msg.contains('not allowed')) {
|
|
setState(() => _blockedMessage = msg);
|
|
} else {
|
|
setState(() {
|
|
_errorMessage = msg;
|
|
});
|
|
}
|
|
} finally {
|
|
if (mounted && !_popped) {
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<bool> _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<bool>(
|
|
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;
|
|
}
|
|
|
|
Widget _buildBlockedBanner() {
|
|
return AnimatedSize(
|
|
duration: const Duration(milliseconds: 250),
|
|
curve: Curves.easeOut,
|
|
child: _blockedMessage != null
|
|
? Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppTheme.spacingMd,
|
|
vertical: 12,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFFEF2F2),
|
|
border: Border(
|
|
bottom: BorderSide(
|
|
color: AppTheme.error.withValues(alpha: 0.2),
|
|
),
|
|
),
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(Icons.info_outline, color: AppTheme.error, size: 20),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
_blockedMessage!,
|
|
style: AppTheme.textTheme.bodySmall?.copyWith(
|
|
color: const Color(0xFF991B1B),
|
|
fontWeight: FontWeight.w500,
|
|
height: 1.4,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
GestureDetector(
|
|
onTap: () => setState(() => _blockedMessage = null),
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(left: 8),
|
|
child: Icon(
|
|
Icons.close,
|
|
size: 16,
|
|
color: AppTheme.error.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: const SizedBox.shrink(),
|
|
);
|
|
}
|
|
|
|
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<int>(
|
|
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: [
|
|
_buildBlockedBanner(),
|
|
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,
|
|
linkPreviewWidget: !_isTyping && _linkPreview != null && !isKeyboardOpen
|
|
? _buildComposeLinkPreview()
|
|
: null,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
bottomNavigationBar: ValueListenableBuilder<int>(
|
|
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,
|
|
onToggleNsfw: () => setState(() => _isNsfw = !_isNsfw),
|
|
onSelectTtl: _openTtlSelector,
|
|
ttlOverrideActive: _ttlHoursOverride != null,
|
|
ttlLabel: _ttlOptions[_ttlHoursOverride] ?? 'Use default',
|
|
isBold: _isBold,
|
|
isItalic: _isItalic,
|
|
allowChain: _allowChain,
|
|
isNsfw: _isNsfw,
|
|
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: SojornColors.overlayDark,
|
|
shape: const CircleBorder(),
|
|
child: InkWell(
|
|
onTap: _removeImage,
|
|
customBorder: const CircleBorder(),
|
|
child: const Padding(
|
|
padding: EdgeInsets.all(6),
|
|
child: Icon(Icons.close, color: SojornColors.basicWhite, size: 18),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildComposeLinkPreview() {
|
|
final preview = _linkPreview!;
|
|
final domain = preview['domain'] as String? ?? '';
|
|
final url = preview['url'] as String? ?? '';
|
|
final isSafe = preview['safe'] as bool? ?? false;
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: AppTheme.spacingLg, vertical: 8),
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: isSafe
|
|
? AppTheme.navyBlue.withValues(alpha: 0.05)
|
|
: SojornColors.nsfwWarningBg,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: isSafe
|
|
? AppTheme.navyBlue.withValues(alpha: 0.15)
|
|
: SojornColors.nsfwWarningBorder,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
isSafe ? Icons.link_rounded : Icons.warning_amber_rounded,
|
|
size: 20,
|
|
color: isSafe ? AppTheme.navyBlue : AppTheme.nsfwWarningIcon,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
domain.toUpperCase(),
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.8,
|
|
color: AppTheme.textTertiary,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
url.length > 60 ? '${url.substring(0, 57)}...' : url,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (_isFetchingPreview)
|
|
const SizedBox(
|
|
width: 16, height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
else
|
|
GestureDetector(
|
|
onTap: () => setState(() {
|
|
_linkPreview = null;
|
|
_detectedUrl = null;
|
|
}),
|
|
child: Icon(Icons.close, size: 18, color: AppTheme.textTertiary),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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 Widget? linkPreviewWidget;
|
|
final List<String> suggestions;
|
|
final ValueChanged<String> 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,
|
|
this.linkPreviewWidget,
|
|
});
|
|
|
|
@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: SojornColors.overlayScrim,
|
|
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 (linkPreviewWidget != null) linkPreviewWidget!,
|
|
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? onToggleNsfw;
|
|
final VoidCallback onSelectTtl;
|
|
final bool isBold;
|
|
final bool isItalic;
|
|
final bool allowChain;
|
|
final bool isNsfw;
|
|
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,
|
|
this.onToggleNsfw,
|
|
required this.onSelectTtl,
|
|
required this.ttlOverrideActive,
|
|
required this.ttlLabel,
|
|
required this.isBold,
|
|
required this.isItalic,
|
|
required this.allowChain,
|
|
this.isNsfw = false,
|
|
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,
|
|
onToggleNsfw: onToggleNsfw,
|
|
onSelectTtl: onSelectTtl,
|
|
ttlOverrideActive: ttlOverrideActive,
|
|
ttlLabel: ttlLabel,
|
|
isBold: isBold,
|
|
isItalic: isItalic,
|
|
allowChain: allowChain,
|
|
isNsfw: isNsfw,
|
|
characterCount: characterCount,
|
|
maxCharacters: maxCharacters,
|
|
isUploadingImage: isUploadingImage,
|
|
remainingChars: remainingChars,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TtlChoice {
|
|
final int? hours;
|
|
const _TtlChoice(this.hours);
|
|
}
|