- Move /image-proxy to public (unauthenticated) route so CachedNetworkImage can fetch proxied GIFs without Bearer token (fixes 401 errors) - Expand image proxy SSRF allowlist to include i.imgur.com + media.giphy.com - Broaden GIF filter in gif_picker: accept i.redd.it, preview.redd.it, i.imgur.com URLs (not just .gif extension) so Reddit posts actually show - Change GIF search hint to 'Search GIFs…' (no subreddit mention) - Add i.imgur.com + media.giphy.com to client-side needsProxy() list - Create lib/widgets/composer/composer_bar.dart — single ComposerBar widget with ComposerConfig presets (publicPost/comment/threadReply/chat/privatePost) that handles text, GIF picker, image picker, upload, and send button uniformly - Apply ComposerBar to: group feed posts, group feed comments sheet, group thread reply bar, quips comment sheet, group chat bar - Remove all duplicate TextField+send-button+media-button implementations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
313 lines
11 KiB
Dart
313 lines
11 KiB
Dart
import 'dart:io';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import '../../config/api_config.dart';
|
|
import '../../services/image_upload_service.dart';
|
|
import '../../theme/app_theme.dart';
|
|
import '../../theme/tokens.dart';
|
|
import '../gif/gif_picker.dart';
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// ComposerConfig — controls which options are visible per context
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
class ComposerConfig {
|
|
final bool allowImages;
|
|
final bool allowGifs;
|
|
final String hintText;
|
|
final int? maxLines;
|
|
|
|
const ComposerConfig({
|
|
this.allowImages = false,
|
|
this.allowGifs = false,
|
|
this.hintText = 'Write something…',
|
|
this.maxLines,
|
|
});
|
|
|
|
bool get hasMedia => allowImages || allowGifs;
|
|
|
|
// ── Presets ──────────────────────────────────────────────────────────────
|
|
|
|
/// Public group post — images + GIFs allowed.
|
|
static const publicPost = ComposerConfig(allowImages: true, allowGifs: true);
|
|
|
|
/// Encrypted capsule post — text only.
|
|
static const privatePost = ComposerConfig(hintText: 'Write an encrypted post…');
|
|
|
|
/// Public comment / reply — GIF allowed, no image upload.
|
|
static const comment = ComposerConfig(allowGifs: true, hintText: 'Add a comment…');
|
|
|
|
/// Encrypted comment / reply or thread reply — text only.
|
|
static const textOnly = ComposerConfig(hintText: 'Add a comment…');
|
|
|
|
/// Thread detail reply — text only.
|
|
static const threadReply = ComposerConfig(hintText: 'Add to this chain…');
|
|
|
|
/// Public group chat message — GIF allowed.
|
|
static const chat = ComposerConfig(allowGifs: true, hintText: 'Message…');
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// ComposerBar
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Unified text + media composer used throughout the app.
|
|
///
|
|
/// [onSend] receives the trimmed text and an optional resolved media URL.
|
|
/// Image upload and GIF picking are handled internally. On a successful
|
|
/// [onSend] the text and attachment are automatically cleared.
|
|
///
|
|
/// Pass [externalController] when the parent must access or clear the text
|
|
/// independently (e.g. reply state in quips sheet). Don't dispose it while
|
|
/// this widget is still mounted — ComposerBar will not dispose an external
|
|
/// controller.
|
|
class ComposerBar extends StatefulWidget {
|
|
final ComposerConfig config;
|
|
final Future<void> Function(String text, String? mediaUrl) onSend;
|
|
final TextEditingController? externalController;
|
|
final FocusNode? focusNode;
|
|
|
|
const ComposerBar({
|
|
required this.config,
|
|
required this.onSend,
|
|
this.externalController,
|
|
this.focusNode,
|
|
super.key,
|
|
});
|
|
|
|
@override
|
|
State<ComposerBar> createState() => _ComposerBarState();
|
|
}
|
|
|
|
class _ComposerBarState extends State<ComposerBar> {
|
|
late final TextEditingController _ctrl;
|
|
File? _mediaFile;
|
|
String? _mediaUrl;
|
|
bool _uploading = false;
|
|
bool _sending = false;
|
|
|
|
bool get _hasAttachment => _mediaFile != null || _mediaUrl != null;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_ctrl = widget.externalController ?? TextEditingController();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
if (widget.externalController == null) _ctrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _pickImage() async {
|
|
final picker = ImagePicker();
|
|
final xf = await picker.pickImage(source: ImageSource.gallery);
|
|
if (xf == null || !mounted) return;
|
|
setState(() {
|
|
_mediaFile = File(xf.path);
|
|
_mediaUrl = null;
|
|
});
|
|
}
|
|
|
|
void _attachGif(String gifUrl) {
|
|
setState(() {
|
|
_mediaFile = null;
|
|
_mediaUrl = gifUrl;
|
|
});
|
|
}
|
|
|
|
void _clearAttachment() {
|
|
setState(() {
|
|
_mediaFile = null;
|
|
_mediaUrl = null;
|
|
});
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
final text = _ctrl.text.trim();
|
|
if ((text.isEmpty && !_hasAttachment) || _sending) return;
|
|
|
|
setState(() => _sending = true);
|
|
try {
|
|
String? resolvedUrl = _mediaUrl;
|
|
if (_mediaFile != null) {
|
|
setState(() => _uploading = true);
|
|
try {
|
|
resolvedUrl = await ImageUploadService().uploadImage(_mediaFile!);
|
|
} finally {
|
|
if (mounted) setState(() => _uploading = false);
|
|
}
|
|
}
|
|
await widget.onSend(text, resolvedUrl);
|
|
if (mounted) {
|
|
_ctrl.clear();
|
|
_clearAttachment();
|
|
}
|
|
} catch (_) {
|
|
// caller handles error display; don't clear on failure
|
|
} finally {
|
|
if (mounted) setState(() => _sending = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final busy = _sending || _uploading;
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// ── Text field + send button ─────────────────────────────────────
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _ctrl,
|
|
focusNode: widget.focusNode,
|
|
style: TextStyle(color: SojornColors.postContent, fontSize: 14),
|
|
maxLines: widget.config.maxLines,
|
|
decoration: InputDecoration(
|
|
hintText: widget.config.hintText,
|
|
hintStyle: TextStyle(color: SojornColors.textDisabled),
|
|
filled: true,
|
|
fillColor: AppTheme.scaffoldBg,
|
|
contentPadding:
|
|
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(20),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
),
|
|
onSubmitted: (_) => _submit(),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
GestureDetector(
|
|
onTap: busy ? null : _submit,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 150),
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: busy
|
|
? AppTheme.brightNavy.withValues(alpha: 0.5)
|
|
: AppTheme.brightNavy,
|
|
),
|
|
child: busy
|
|
? const Padding(
|
|
padding: EdgeInsets.all(9),
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: SojornColors.basicWhite,
|
|
),
|
|
)
|
|
: const Icon(Icons.send,
|
|
color: SojornColors.basicWhite, size: 16),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
// ── Media action row ─────────────────────────────────────────────
|
|
if (widget.config.hasMedia) ...[
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
if (widget.config.allowImages) ...[
|
|
_MediaPill(
|
|
icon: Icons.image_outlined,
|
|
label: 'Photo',
|
|
onTap: _pickImage,
|
|
),
|
|
if (widget.config.allowGifs) const SizedBox(width: 8),
|
|
],
|
|
if (widget.config.allowGifs)
|
|
_MediaPill(
|
|
icon: Icons.gif_outlined,
|
|
label: 'GIF',
|
|
onTap: () =>
|
|
showGifPicker(context, onSelected: _attachGif),
|
|
),
|
|
if (_hasAttachment) ...[
|
|
const Spacer(),
|
|
GestureDetector(
|
|
onTap: _clearAttachment,
|
|
child: Icon(Icons.cancel_outlined,
|
|
size: 18, color: AppTheme.textSecondary),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
// ── Attachment preview ───────────────────────────────────────
|
|
if (_mediaFile != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(10),
|
|
child: Image.file(_mediaFile!, height: 120, fit: BoxFit.cover),
|
|
),
|
|
),
|
|
if (_mediaUrl != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(10),
|
|
child: Image.network(
|
|
ApiConfig.needsProxy(_mediaUrl!)
|
|
? ApiConfig.proxyImageUrl(_mediaUrl!)
|
|
: _mediaUrl!,
|
|
height: 120,
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// _MediaPill — small rounded pill button for Photo / GIF
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
class _MediaPill extends StatelessWidget {
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback onTap;
|
|
const _MediaPill({required this.icon, required this.label, required this.onTap});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.navyBlue.withValues(alpha: 0.06),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 16, color: AppTheme.textSecondary),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: AppTheme.textSecondary,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|