feat: Migrate board entries and encrypted chat to ComposerBar widget
- Replace custom TextField+send-button in board_entry_detail_screen with ComposerBar - Replace custom compose UI in create_board_post_sheet with ComposerBar (text+image+send) - Enable GIF picker in encrypted group chat (allowGifs: true in ComposerConfig) - Enable GIF picker in encrypted group feed posts (allowGifs: true) - Add GIF URL detection in group_chat_tab: detect GIF URLs stored as body text from sendGroupMessage fallback - Add
This commit is contained in:
parent
8844a95f3f
commit
72046c08df
|
|
@ -5,6 +5,7 @@ import '../../models/board_entry.dart';
|
|||
import '../../services/api_service.dart';
|
||||
import '../../theme/tokens.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../widgets/composer/composer_bar.dart';
|
||||
|
||||
class BoardEntryDetailScreen extends ConsumerStatefulWidget {
|
||||
final BoardEntry entry;
|
||||
|
|
@ -20,8 +21,6 @@ class _BoardEntryDetailScreenState extends ConsumerState<BoardEntryDetailScreen>
|
|||
List<BoardReply> _replies = [];
|
||||
bool _isLoading = true;
|
||||
bool _isNeighborhoodAdmin = false;
|
||||
bool _isSendingReply = false;
|
||||
final _replyController = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
|
|
@ -33,7 +32,6 @@ class _BoardEntryDetailScreenState extends ConsumerState<BoardEntryDetailScreen>
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
_replyController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
@ -57,46 +55,32 @@ class _BoardEntryDetailScreenState extends ConsumerState<BoardEntryDetailScreen>
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _sendReply() async {
|
||||
final body = _replyController.text.trim();
|
||||
if (body.isEmpty) return;
|
||||
|
||||
setState(() => _isSendingReply = true);
|
||||
try {
|
||||
final data = await ApiService.instance.createBoardReply(
|
||||
entryId: _entry.id,
|
||||
body: body,
|
||||
);
|
||||
if (mounted) {
|
||||
final reply = BoardReply.fromJson(data['reply'] as Map<String, dynamic>);
|
||||
setState(() {
|
||||
_replies.add(reply);
|
||||
_entry = BoardEntry(
|
||||
id: _entry.id, body: _entry.body, imageUrl: _entry.imageUrl, topic: _entry.topic,
|
||||
lat: _entry.lat, long: _entry.long, upvotes: _entry.upvotes,
|
||||
replyCount: _entry.replyCount + 1, isPinned: _entry.isPinned, createdAt: _entry.createdAt,
|
||||
authorHandle: _entry.authorHandle, authorDisplayName: _entry.authorDisplayName,
|
||||
authorAvatarUrl: _entry.authorAvatarUrl, hasVoted: _entry.hasVoted,
|
||||
Future<void> _sendReply(String text, String? _) async {
|
||||
final data = await ApiService.instance.createBoardReply(
|
||||
entryId: _entry.id,
|
||||
body: text,
|
||||
);
|
||||
if (mounted) {
|
||||
final reply = BoardReply.fromJson(data['reply'] as Map<String, dynamic>);
|
||||
setState(() {
|
||||
_replies.add(reply);
|
||||
_entry = BoardEntry(
|
||||
id: _entry.id, body: _entry.body, imageUrl: _entry.imageUrl, topic: _entry.topic,
|
||||
lat: _entry.lat, long: _entry.long, upvotes: _entry.upvotes,
|
||||
replyCount: _entry.replyCount + 1, isPinned: _entry.isPinned, createdAt: _entry.createdAt,
|
||||
authorHandle: _entry.authorHandle, authorDisplayName: _entry.authorDisplayName,
|
||||
authorAvatarUrl: _entry.authorAvatarUrl, hasVoted: _entry.hasVoted,
|
||||
);
|
||||
});
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
_isSendingReply = false;
|
||||
});
|
||||
_replyController.clear();
|
||||
// Scroll to bottom
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isSendingReply = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not post reply: $e')));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -412,45 +396,9 @@ class _BoardEntryDetailScreenState extends ConsumerState<BoardEntryDetailScreen>
|
|||
color: AppTheme.cardSurface,
|
||||
border: Border(top: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.08))),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _replyController,
|
||||
style: TextStyle(color: SojornColors.postContent, fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Write a reply…',
|
||||
hintStyle: TextStyle(color: SojornColors.textDisabled, fontSize: 14),
|
||||
filled: true,
|
||||
fillColor: AppTheme.scaffoldBg,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(20), borderSide: BorderSide.none),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
isDense: true,
|
||||
),
|
||||
maxLines: 3,
|
||||
minLines: 1,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendReply(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
GestureDetector(
|
||||
onTap: _isSendingReply ? null : _sendReply,
|
||||
child: Container(
|
||||
width: 38, height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.brightNavy,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: _isSendingReply
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(10),
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite),
|
||||
)
|
||||
: const Icon(Icons.send, size: 16, color: SojornColors.basicWhite),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: ComposerBar(
|
||||
config: const ComposerConfig(hintText: 'Write a reply…'),
|
||||
onSend: _sendReply,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../../models/board_entry.dart';
|
||||
import '../../providers/api_provider.dart';
|
||||
import '../../services/image_upload_service.dart';
|
||||
import '../../theme/tokens.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../widgets/composer/composer_bar.dart';
|
||||
|
||||
/// Compose sheet for the standalone neighborhood board.
|
||||
/// Creates board_entries — completely separate from posts/beacons.
|
||||
|
|
@ -27,80 +25,23 @@ class CreateBoardPostSheet extends ConsumerStatefulWidget {
|
|||
}
|
||||
|
||||
class _CreateBoardPostSheetState extends ConsumerState<CreateBoardPostSheet> {
|
||||
final ImageUploadService _imageUploadService = ImageUploadService();
|
||||
final _bodyController = TextEditingController();
|
||||
|
||||
BoardTopic _selectedTopic = BoardTopic.community;
|
||||
bool _isSubmitting = false;
|
||||
bool _isUploadingImage = false;
|
||||
File? _selectedImage;
|
||||
String? _uploadedImageUrl;
|
||||
|
||||
static const _topics = BoardTopic.values;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bodyController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickImage() async {
|
||||
setState(() => _isUploadingImage = true);
|
||||
try {
|
||||
final image = await ImagePicker().pickImage(
|
||||
source: ImageSource.gallery,
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1920,
|
||||
imageQuality: 85,
|
||||
);
|
||||
if (image == null) {
|
||||
setState(() => _isUploadingImage = false);
|
||||
return;
|
||||
}
|
||||
final file = File(image.path);
|
||||
setState(() => _selectedImage = file);
|
||||
final imageUrl = await _imageUploadService.uploadImage(file);
|
||||
if (mounted) setState(() { _uploadedImageUrl = imageUrl; _isUploadingImage = false; });
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isUploadingImage = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not upload photo: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _removeImage() {
|
||||
setState(() { _selectedImage = null; _uploadedImageUrl = null; });
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final body = _bodyController.text.trim();
|
||||
if (body.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Write something to share with your neighbors.')));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
final apiService = ref.read(apiServiceProvider);
|
||||
final data = await apiService.createBoardEntry(
|
||||
body: body,
|
||||
imageUrl: _uploadedImageUrl,
|
||||
topic: _selectedTopic.value,
|
||||
lat: widget.centerLat,
|
||||
long: widget.centerLong,
|
||||
);
|
||||
if (mounted) {
|
||||
final entry = BoardEntry.fromJson(data['entry'] as Map<String, dynamic>);
|
||||
widget.onEntryCreated(entry);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not create post: $e')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSubmitting = false);
|
||||
Future<void> _onComposerSend(String text, String? imageUrl) async {
|
||||
final apiService = ref.read(apiServiceProvider);
|
||||
final data = await apiService.createBoardEntry(
|
||||
body: text,
|
||||
imageUrl: imageUrl,
|
||||
topic: _selectedTopic.value,
|
||||
lat: widget.centerLat,
|
||||
long: widget.centerLong,
|
||||
);
|
||||
if (mounted) {
|
||||
final entry = BoardEntry.fromJson(data['entry'] as Map<String, dynamic>);
|
||||
widget.onEntryCreated(entry);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -136,7 +77,7 @@ class _CreateBoardPostSheetState extends ConsumerState<CreateBoardPostSheet> {
|
|||
child: Text('Post to Board', style: TextStyle(color: AppTheme.navyBlue, fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: Icon(Icons.close, color: SojornColors.textDisabled),
|
||||
),
|
||||
],
|
||||
|
|
@ -185,93 +126,13 @@ class _CreateBoardPostSheetState extends ConsumerState<CreateBoardPostSheet> {
|
|||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Body
|
||||
TextFormField(
|
||||
controller: _bodyController,
|
||||
style: TextStyle(color: SojornColors.postContent, fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
// Composer (text + photo + send)
|
||||
ComposerBar(
|
||||
config: const ComposerConfig(
|
||||
allowImages: true,
|
||||
hintText: 'Share with your neighborhood…',
|
||||
hintStyle: TextStyle(color: SojornColors.textDisabled),
|
||||
filled: true,
|
||||
fillColor: AppTheme.scaffoldBg,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.1))),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.1))),
|
||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.brightNavy, width: 1)),
|
||||
counterStyle: TextStyle(color: SojornColors.textDisabled),
|
||||
),
|
||||
maxLines: 4,
|
||||
maxLength: 500,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Photo
|
||||
if (_selectedImage != null) ...[
|
||||
Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.file(_selectedImage!, height: 120, width: double.infinity, fit: BoxFit.cover),
|
||||
),
|
||||
Positioned(
|
||||
top: 6, right: 6,
|
||||
child: IconButton(
|
||||
onPressed: _removeImage,
|
||||
icon: const Icon(Icons.close, color: SojornColors.basicWhite, size: 18),
|
||||
style: IconButton.styleFrom(backgroundColor: SojornColors.overlayDark, padding: const EdgeInsets.all(4)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
] else ...[
|
||||
GestureDetector(
|
||||
onTap: _isUploadingImage ? null : _pickImage,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.scaffoldBg,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.1)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (_isUploadingImage)
|
||||
SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.brightNavy))
|
||||
else
|
||||
Icon(Icons.add_photo_alternate, size: 18, color: SojornColors.textDisabled),
|
||||
const SizedBox(width: 8),
|
||||
Text(_isUploadingImage ? 'Uploading…' : 'Add photo',
|
||||
style: TextStyle(color: SojornColors.textDisabled, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
|
||||
// Submit
|
||||
SizedBox(
|
||||
width: double.infinity, height: 48,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isSubmitting ? null : _submit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.brightNavy,
|
||||
foregroundColor: SojornColors.basicWhite,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
disabledBackgroundColor: AppTheme.brightNavy.withValues(alpha: 0.3),
|
||||
),
|
||||
child: _isSubmitting
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite))
|
||||
: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.send, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('Post', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
|
||||
],
|
||||
),
|
||||
),
|
||||
onSend: _onComposerSend,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -51,7 +51,16 @@ class _GroupChatTabState extends State<GroupChatTab> {
|
|||
await _loadEncryptedMessages();
|
||||
} else {
|
||||
final msgs = await ApiService.instance.fetchGroupMessages(widget.groupId);
|
||||
_messages = msgs.reversed.toList(); // API returns newest first, we want oldest first
|
||||
// Detect GIF URLs stored as body text (from sendGroupMessage fallback)
|
||||
_messages = msgs.reversed.map((msg) {
|
||||
final body = msg['body'] as String? ?? '';
|
||||
if (msg['gif_url'] == null && body.isNotEmpty && ApiConfig.needsProxy(body)) {
|
||||
return Map<String, dynamic>.from(msg)
|
||||
..['gif_url'] = body
|
||||
..['body'] = '';
|
||||
}
|
||||
return msg;
|
||||
}).toList();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[GroupChat] Error: $e');
|
||||
|
|
@ -324,7 +333,7 @@ class _GroupChatTabState extends State<GroupChatTab> {
|
|||
top: false,
|
||||
child: ComposerBar(
|
||||
config: widget.isEncrypted
|
||||
? const ComposerConfig(hintText: 'Encrypted message…')
|
||||
? const ComposerConfig(allowGifs: true, hintText: 'Encrypted message…')
|
||||
: ComposerConfig.chat,
|
||||
onSend: _onChatSend,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -99,7 +99,8 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
|
|||
Future<void> _onComposerSend(String text, String? mediaUrl) async {
|
||||
if (widget.isEncrypted && widget.capsuleKey != null) {
|
||||
final encrypted = await CapsuleSecurityService.encryptPayload(
|
||||
payload: {'text': text, 'ts': DateTime.now().toIso8601String()},
|
||||
payload: {'text': text, 'ts': DateTime.now().toIso8601String(),
|
||||
if (mediaUrl != null) 'image_url': mediaUrl},
|
||||
capsuleKey: widget.capsuleKey!,
|
||||
);
|
||||
await ApiService.instance.callGoApi(
|
||||
|
|
@ -162,7 +163,7 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
|
|||
),
|
||||
child: ComposerBar(
|
||||
config: widget.isEncrypted
|
||||
? ComposerConfig.privatePost
|
||||
? const ComposerConfig(allowGifs: true, hintText: 'Write an encrypted post…')
|
||||
: ComposerConfig.publicPost,
|
||||
onSend: _onComposerSend,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ import '../../services/api_service.dart';
|
|||
import '../../services/image_upload_service.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../theme/tokens.dart';
|
||||
import '../../config/api_config.dart';
|
||||
import '../../widgets/composer/composer_toolbar.dart';
|
||||
import '../../widgets/gif/gif_picker.dart';
|
||||
import '../../services/content_filter.dart';
|
||||
import '../../widgets/sojorn_snackbar.dart';
|
||||
import 'image_editor_screen.dart';
|
||||
|
|
@ -61,6 +63,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||
Uint8List? _selectedImageBytes;
|
||||
String? _selectedImageName;
|
||||
ImageFilter? _selectedFilter;
|
||||
String? _selectedGifUrl;
|
||||
final ImagePicker _imagePicker = ImagePicker();
|
||||
static const double _editorFontSize = 18;
|
||||
List<String> _tagSuggestions = [];
|
||||
|
|
@ -275,6 +278,19 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||
_selectedImageBytes = null;
|
||||
_selectedImageName = null;
|
||||
_selectedFilter = null;
|
||||
_selectedGifUrl = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _openGifPicker() {
|
||||
showGifPicker(context, onSelected: (url) {
|
||||
setState(() {
|
||||
_selectedGifUrl = url;
|
||||
_selectedImageFile = null;
|
||||
_selectedImageBytes = null;
|
||||
_selectedImageName = null;
|
||||
_selectedFilter = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -401,7 +417,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||
});
|
||||
|
||||
try {
|
||||
String? imageUrl;
|
||||
String? imageUrl = _selectedGifUrl;
|
||||
|
||||
if (_selectedImageFile != null || _selectedImageBytes != null) {
|
||||
setState(() {
|
||||
|
|
@ -650,6 +666,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||
!isKeyboardOpen
|
||||
? _buildImagePreview()
|
||||
: null,
|
||||
gifPreviewWidget: _selectedGifUrl != null && !isKeyboardOpen
|
||||
? _buildGifPreview()
|
||||
: null,
|
||||
linkPreviewWidget: !_isTyping && _linkPreview != null && !isKeyboardOpen
|
||||
? _buildComposeLinkPreview()
|
||||
: null,
|
||||
|
|
@ -668,6 +687,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||
bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: ComposeBottomBar(
|
||||
onAddMedia: _pickMedia,
|
||||
onAddGif: _openGifPicker,
|
||||
onToggleBold: _toggleBold,
|
||||
onToggleItalic: _toggleItalic,
|
||||
onToggleChain: _toggleChain,
|
||||
|
|
@ -734,6 +754,44 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildGifPreview() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.spacingLg, vertical: AppTheme.spacingSm),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Stack(
|
||||
children: [
|
||||
Image.network(
|
||||
ApiConfig.needsProxy(_selectedGifUrl!)
|
||||
? ApiConfig.proxyImageUrl(_selectedGifUrl!)
|
||||
: _selectedGifUrl!,
|
||||
height: 150,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||||
),
|
||||
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? ?? '';
|
||||
|
|
@ -895,6 +953,7 @@ class ComposeBody extends StatelessWidget {
|
|||
final bool isBold;
|
||||
final bool isItalic;
|
||||
final Widget? imageWidget;
|
||||
final Widget? gifPreviewWidget;
|
||||
final Widget? linkPreviewWidget;
|
||||
final List<String> suggestions;
|
||||
final ValueChanged<String> onSelectSuggestion;
|
||||
|
|
@ -908,6 +967,7 @@ class ComposeBody extends StatelessWidget {
|
|||
required this.suggestions,
|
||||
required this.onSelectSuggestion,
|
||||
this.imageWidget,
|
||||
this.gifPreviewWidget,
|
||||
this.linkPreviewWidget,
|
||||
});
|
||||
|
||||
|
|
@ -982,6 +1042,7 @@ class ComposeBody extends StatelessWidget {
|
|||
),
|
||||
if (linkPreviewWidget != null) linkPreviewWidget!,
|
||||
if (imageWidget != null) imageWidget!,
|
||||
if (gifPreviewWidget != null) gifPreviewWidget!,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -990,6 +1051,7 @@ class ComposeBody extends StatelessWidget {
|
|||
/// Bottom bar pinned above the keyboard with formatting + counter
|
||||
class ComposeBottomBar extends StatelessWidget {
|
||||
final VoidCallback onAddMedia;
|
||||
final VoidCallback? onAddGif;
|
||||
final VoidCallback onToggleBold;
|
||||
final VoidCallback onToggleItalic;
|
||||
final VoidCallback onToggleChain;
|
||||
|
|
@ -1009,6 +1071,7 @@ class ComposeBottomBar extends StatelessWidget {
|
|||
const ComposeBottomBar({
|
||||
super.key,
|
||||
required this.onAddMedia,
|
||||
this.onAddGif,
|
||||
required this.onToggleBold,
|
||||
required this.onToggleItalic,
|
||||
required this.onToggleChain,
|
||||
|
|
@ -1045,6 +1108,7 @@ class ComposeBottomBar extends StatelessWidget {
|
|||
top: false,
|
||||
child: ComposerToolbar(
|
||||
onAddMedia: onAddMedia,
|
||||
onAddGif: onAddGif,
|
||||
onToggleBold: onToggleBold,
|
||||
onToggleItalic: onToggleItalic,
|
||||
onToggleChain: onToggleChain,
|
||||
|
|
|
|||
|
|
@ -151,96 +151,89 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
|
|||
showSearch: false,
|
||||
showMessages: false,
|
||||
leadingActions: [
|
||||
IconButton(
|
||||
onPressed: _loadConversations,
|
||||
icon: Icon(Icons.refresh, color: AppTheme.navyBlue),
|
||||
tooltip: 'Refresh conversations',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
await _chatService.uploadKeysManually();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Keys uploaded successfully'),
|
||||
backgroundColor: const Color(0xFF4CAF50),
|
||||
PopupMenuButton<_ChatMenuAction>(
|
||||
icon: Icon(Icons.more_vert, color: AppTheme.navyBlue),
|
||||
tooltip: 'More options',
|
||||
onSelected: (action) async {
|
||||
switch (action) {
|
||||
case _ChatMenuAction.refresh:
|
||||
await _loadConversations();
|
||||
case _ChatMenuAction.uploadKeys:
|
||||
try {
|
||||
await _chatService.uploadKeysManually();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Keys uploaded successfully'),
|
||||
backgroundColor: Color(0xFF4CAF50),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to upload keys: $e'),
|
||||
backgroundColor: SojornColors.destructive,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
case _ChatMenuAction.backup:
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const EncryptionHubScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
case _ChatMenuAction.devices:
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to upload keys: $e'),
|
||||
backgroundColor: SojornColors.destructive,
|
||||
),
|
||||
const SnackBar(content: Text('Device management coming soon')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: Icon(Icons.key, color: AppTheme.navyBlue),
|
||||
tooltip: 'Upload encryption keys',
|
||||
itemBuilder: (_) => [
|
||||
PopupMenuItem(
|
||||
value: _ChatMenuAction.refresh,
|
||||
child: Row(children: [
|
||||
Icon(Icons.refresh, size: 18, color: AppTheme.navyBlue),
|
||||
const SizedBox(width: 12),
|
||||
const Text('Refresh'),
|
||||
]),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _ChatMenuAction.uploadKeys,
|
||||
child: Row(children: [
|
||||
Icon(Icons.key, size: 18, color: AppTheme.navyBlue),
|
||||
const SizedBox(width: 12),
|
||||
const Text('Upload keys'),
|
||||
]),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _ChatMenuAction.backup,
|
||||
child: Row(children: [
|
||||
Icon(Icons.backup, size: 18, color: AppTheme.navyBlue),
|
||||
const SizedBox(width: 12),
|
||||
const Text('Backup & Recovery'),
|
||||
]),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _ChatMenuAction.devices,
|
||||
child: Row(children: [
|
||||
Icon(Icons.devices, size: 18, color: AppTheme.navyBlue),
|
||||
const SizedBox(width: 12),
|
||||
const Text('Device Management'),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
body: _buildBody(),
|
||||
bottomNavigationBar: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.scaffoldBg,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _showNewConversationSheet,
|
||||
icon: Icon(Icons.add, size: 18),
|
||||
label: Text('New Conversation'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.brightNavy,
|
||||
foregroundColor: SojornColors.basicWhite,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const EncryptionHubScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: Icon(Icons.backup, color: AppTheme.navyBlue),
|
||||
tooltip: 'Backup & Recovery',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
// TODO: Show device management
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Device management coming soon')),
|
||||
);
|
||||
},
|
||||
icon: Icon(Icons.devices, color: AppTheme.navyBlue),
|
||||
tooltip: 'Device Management',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _showNewConversationSheet,
|
||||
backgroundColor: AppTheme.brightNavy,
|
||||
tooltip: 'New conversation',
|
||||
child: const Icon(Icons.edit_outlined, color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -490,6 +483,8 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
enum _ChatMenuAction { refresh, uploadKeys, backup, devices }
|
||||
|
||||
class _ConversationTile extends StatefulWidget {
|
||||
final SecureConversation conversation;
|
||||
final VoidCallback onTap;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import '../../theme/tokens.dart';
|
|||
/// Keyboard-attached toolbar for the composer with attachments, formatting, topic, and counter.
|
||||
class ComposerToolbar extends StatelessWidget {
|
||||
final VoidCallback onAddMedia;
|
||||
final VoidCallback? onAddGif;
|
||||
final VoidCallback onToggleBold;
|
||||
final VoidCallback onToggleItalic;
|
||||
final VoidCallback onToggleChain;
|
||||
|
|
@ -24,6 +25,7 @@ class ComposerToolbar extends StatelessWidget {
|
|||
const ComposerToolbar({
|
||||
super.key,
|
||||
required this.onAddMedia,
|
||||
this.onAddGif,
|
||||
required this.onToggleBold,
|
||||
required this.onToggleItalic,
|
||||
required this.onToggleChain,
|
||||
|
|
@ -71,6 +73,13 @@ class ComposerToolbar extends StatelessWidget {
|
|||
),
|
||||
tooltip: 'Add media',
|
||||
),
|
||||
if (onAddGif != null)
|
||||
IconButton(
|
||||
onPressed: onAddGif,
|
||||
icon: Icon(Icons.gif_outlined,
|
||||
color: AppTheme.navyText.withValues(alpha: 0.75)),
|
||||
tooltip: 'Add GIF',
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
|
|
|||
|
|
@ -302,7 +302,8 @@ class _RetroTabState extends State<_RetroTab>
|
|||
|
||||
static const _defaultQuery = 'space';
|
||||
static final _gifUrlRegex = RegExp(
|
||||
r'https://blob\.gifcities\.org/gifcities/[A-Z0-9]+\.gif');
|
||||
r'https://blob\.gifcities\.org/gifcities/[A-Za-z0-9_\-]+\.gif',
|
||||
caseSensitive: false);
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
|
|
|||
|
|
@ -165,22 +165,17 @@ class _ChatBubbleWidgetState extends State<ChatBubbleWidget>
|
|||
|
||||
if (!widget.showAvatar) return bubble;
|
||||
|
||||
// Only show avatar for incoming messages — not for own messages
|
||||
if (widget.isMe) return bubble;
|
||||
|
||||
final avatar = _buildAvatar();
|
||||
return Row(
|
||||
mainAxisAlignment: widget.isMe
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (!widget.isMe) ...[
|
||||
avatar,
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
avatar,
|
||||
const SizedBox(width: 10),
|
||||
Flexible(child: bubble),
|
||||
if (widget.isMe) ...[
|
||||
const SizedBox(width: 10),
|
||||
avatar,
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
|
|
@ -238,6 +233,24 @@ class _ChatBubbleWidgetState extends State<ChatBubbleWidget>
|
|||
);
|
||||
}
|
||||
|
||||
void _showImageFullscreen(BuildContext context, String url) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierColor: Colors.black87,
|
||||
builder: (_) => GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Center(
|
||||
child: InteractiveViewer(
|
||||
child: SignedMediaImage(
|
||||
url: url,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBubbleContent(double textScale) {
|
||||
final background = widget.isMe ? AppTheme.navyBlue : AppTheme.cardSurface;
|
||||
final textColor = widget.isMe ? AppTheme.white : AppTheme.navyText;
|
||||
|
|
@ -374,13 +387,18 @@ class _ChatBubbleWidgetState extends State<ChatBubbleWidget>
|
|||
...attachments.images.map(
|
||||
(url) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SignedMediaImage(
|
||||
url: url,
|
||||
width: double.infinity,
|
||||
height: 220,
|
||||
fit: BoxFit.cover,
|
||||
child: GestureDetector(
|
||||
onTap: () => _showImageFullscreen(context, url),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
child: SignedMediaImage(
|
||||
url: url,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import 'package:google_fonts/google_fonts.dart';
|
|||
import 'package:image_picker/image_picker.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../theme/tokens.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
import '../../services/image_upload_service.dart';
|
||||
import '../gif/gif_picker.dart';
|
||||
|
||||
class ComposerWidget extends StatefulWidget {
|
||||
const ComposerWidget({
|
||||
|
|
@ -39,6 +39,7 @@ class _ComposerWidgetState extends State<ComposerWidget>
|
|||
int _lineCount = 1;
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
bool _isUploadingAttachment = false;
|
||||
bool _drawerOpen = false;
|
||||
|
||||
bool get _isDesktop {
|
||||
final platform = Theme.of(context).platform;
|
||||
|
|
@ -73,9 +74,7 @@ class _ComposerWidgetState extends State<ComposerWidget>
|
|||
final lines = widget.controller.text.split('\n').length;
|
||||
final clamped = lines.clamp(1, 6).toInt();
|
||||
if (clamped != _lineCount) {
|
||||
setState(() {
|
||||
_lineCount = clamped;
|
||||
});
|
||||
setState(() => _lineCount = clamped);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -86,45 +85,37 @@ class _ComposerWidgetState extends State<ComposerWidget>
|
|||
widget.onSend();
|
||||
}
|
||||
|
||||
Future<void> _handleAddAttachment() async {
|
||||
void _toggleDrawer() {
|
||||
setState(() => _drawerOpen = !_drawerOpen);
|
||||
if (_drawerOpen) widget.focusNode.unfocus();
|
||||
}
|
||||
|
||||
Future<void> _pickFromGallery() async {
|
||||
setState(() => _drawerOpen = false);
|
||||
await _pickAndUpload('photo_gallery');
|
||||
}
|
||||
|
||||
Future<void> _takePhoto() async {
|
||||
setState(() => _drawerOpen = false);
|
||||
await _pickAndUpload('photo_camera');
|
||||
}
|
||||
|
||||
Future<void> _pickGif() async {
|
||||
setState(() => _drawerOpen = false);
|
||||
showGifPicker(context, onSelected: (url) {
|
||||
final existing = widget.controller.text.trim();
|
||||
final tag = '[img]$url';
|
||||
final nextText = existing.isEmpty ? tag : '$existing\n$tag';
|
||||
widget.controller.text = nextText;
|
||||
widget.controller.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: widget.controller.text.length),
|
||||
);
|
||||
widget.focusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickAndUpload(String choice) async {
|
||||
if (widget.isSending || _isUploadingAttachment) return;
|
||||
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.photo_outlined, color: AppTheme.navyBlue),
|
||||
title: const Text('Photo from gallery'),
|
||||
onTap: () => Navigator.pop(context, 'photo_gallery'),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.videocam_outlined, color: AppTheme.navyBlue),
|
||||
title: const Text('Video from gallery'),
|
||||
onTap: () => Navigator.pop(context, 'video_gallery'),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.photo_camera_outlined, color: AppTheme.navyBlue),
|
||||
title: const Text('Take a photo'),
|
||||
onTap: () => Navigator.pop(context, 'photo_camera'),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.videocam, color: AppTheme.navyBlue),
|
||||
title: const Text('Record a video'),
|
||||
onTap: () => Navigator.pop(context, 'video_camera'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (!mounted || choice == null) return;
|
||||
|
||||
try {
|
||||
XFile? file;
|
||||
if (choice == 'photo_gallery') {
|
||||
|
|
@ -134,14 +125,17 @@ class _ComposerWidgetState extends State<ComposerWidget>
|
|||
} else if (choice == 'photo_camera') {
|
||||
file = await _picker.pickImage(source: ImageSource.camera);
|
||||
} else if (choice == 'video_camera') {
|
||||
file = await _picker.pickVideo(source: ImageSource.camera, maxDuration: const Duration(seconds: 30));
|
||||
file = await _picker.pickVideo(
|
||||
source: ImageSource.camera,
|
||||
maxDuration: const Duration(seconds: 30));
|
||||
}
|
||||
if (file != null) {
|
||||
setState(() => _isUploadingAttachment = true);
|
||||
final url = await _uploadAttachment(File(file.path), isVideo: choice?.contains('video') ?? false);
|
||||
final isVideo = choice.contains('video');
|
||||
final url = await _uploadAttachment(File(file.path), isVideo: isVideo);
|
||||
if (url != null && mounted) {
|
||||
final existing = widget.controller.text.trim();
|
||||
final tag = (choice?.contains('video') ?? false) ? '[video]$url' : '[img]$url';
|
||||
final tag = isVideo ? '[video]$url' : '[img]$url';
|
||||
final nextText = existing.isEmpty ? tag : '$existing\n$tag';
|
||||
widget.controller.text = nextText;
|
||||
widget.controller.selection = TextSelection.fromPosition(
|
||||
|
|
@ -153,28 +147,22 @@ class _ComposerWidgetState extends State<ComposerWidget>
|
|||
} catch (_) {
|
||||
// Ignore picker errors; UI remains usable.
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isUploadingAttachment = false);
|
||||
}
|
||||
if (mounted) setState(() => _isUploadingAttachment = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _uploadAttachment(File file, {required bool isVideo}) async {
|
||||
try {
|
||||
final service = ImageUploadService();
|
||||
if (isVideo) {
|
||||
return await service.uploadVideo(file);
|
||||
} else {
|
||||
return await service.uploadImage(file);
|
||||
}
|
||||
return isVideo
|
||||
? await service.uploadVideo(file)
|
||||
: await service.uploadImage(file);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Attachment upload failed: $e'),
|
||||
backgroundColor: AppTheme.error,
|
||||
),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Attachment upload failed: $e'),
|
||||
backgroundColor: AppTheme.error,
|
||||
));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -186,8 +174,7 @@ class _ComposerWidgetState extends State<ComposerWidget>
|
|||
final maxHeight = (52 + (_lineCount - 1) * 22).clamp(52, 140).toDouble();
|
||||
final shortcuts = _isDesktop
|
||||
? <ShortcutActivator, Intent>{
|
||||
const SingleActivator(LogicalKeyboardKey.enter):
|
||||
const _SendIntent(),
|
||||
const SingleActivator(LogicalKeyboardKey.enter): const _SendIntent(),
|
||||
}
|
||||
: const <ShortcutActivator, Intent>{};
|
||||
|
||||
|
|
@ -196,13 +183,10 @@ class _ComposerWidgetState extends State<ComposerWidget>
|
|||
curve: Curves.easeOut,
|
||||
padding: EdgeInsets.only(bottom: viewInsets.bottom),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.1),
|
||||
),
|
||||
top: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.1)),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
|
|
@ -210,119 +194,187 @@ class _ComposerWidgetState extends State<ComposerWidget>
|
|||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.replyingLabel != null &&
|
||||
widget.replyingSnippet != null)
|
||||
_ReplyPreviewBar(
|
||||
label: widget.replyingLabel!,
|
||||
snippet: widget.replyingSnippet!,
|
||||
onCancel: widget.onCancelReply,
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _handleAddAttachment,
|
||||
icon: Icon(Icons.add, color: AppTheme.brightNavy),
|
||||
// ── Reply preview ──────────────────────────────────────────
|
||||
if (widget.replyingLabel != null && widget.replyingSnippet != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
|
||||
child: _ReplyPreviewBar(
|
||||
label: widget.replyingLabel!,
|
||||
snippet: widget.replyingSnippet!,
|
||||
onCancel: widget.onCancelReply,
|
||||
),
|
||||
Expanded(
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
curve: Curves.easeOut,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: 44,
|
||||
maxHeight: maxHeight,
|
||||
),
|
||||
|
||||
// ── Horizontal tool drawer ─────────────────────────────────
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
height: _drawerOpen ? 52 : 0,
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.topLeft,
|
||||
maxHeight: 52,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
_DrawerPill(
|
||||
icon: Icons.photo_library_outlined,
|
||||
label: 'Gallery',
|
||||
onTap: _pickFromGallery,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: SojornColors.basicWhite.withValues(alpha: 0.04),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.08),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_DrawerPill(
|
||||
icon: Icons.camera_alt_outlined,
|
||||
label: 'Camera',
|
||||
onTap: _takePhoto,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_DrawerPill(
|
||||
icon: Icons.gif_outlined,
|
||||
label: 'GIF',
|
||||
onTap: _pickGif,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Input row ──────────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// + toggle button
|
||||
AnimatedRotation(
|
||||
turns: _drawerOpen ? 0.125 : 0, // 45° when open → X
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: IconButton(
|
||||
onPressed: _toggleDrawer,
|
||||
icon: Icon(
|
||||
Icons.add_circle_outline,
|
||||
color: _drawerOpen
|
||||
? AppTheme.brightNavy
|
||||
: AppTheme.navyText.withValues(alpha: 0.6),
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
|
||||
// Text field
|
||||
Expanded(
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
curve: Curves.easeOut,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: 40,
|
||||
maxHeight: maxHeight,
|
||||
),
|
||||
child: Shortcuts(
|
||||
shortcuts: shortcuts,
|
||||
child: Actions(
|
||||
actions: {
|
||||
_SendIntent: CallbackAction<_SendIntent>(
|
||||
onInvoke: (_) {
|
||||
_handleSend();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: TextField(
|
||||
controller: widget.controller,
|
||||
focusNode: widget.focusNode,
|
||||
keyboardType: TextInputType.multiline,
|
||||
textInputAction: TextInputAction.send,
|
||||
maxLines: 6,
|
||||
minLines: 1,
|
||||
textCapitalization:
|
||||
TextCapitalization.sentences,
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.navyText,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
isCollapsed: true,
|
||||
hintText: 'Message',
|
||||
hintStyle: GoogleFonts.inter(
|
||||
color: AppTheme.textDisabled,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.scaffoldBg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
child: Shortcuts(
|
||||
shortcuts: shortcuts,
|
||||
child: Actions(
|
||||
actions: {
|
||||
_SendIntent: CallbackAction<_SendIntent>(
|
||||
onInvoke: (_) {
|
||||
_handleSend();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
},
|
||||
child: TextField(
|
||||
controller: widget.controller,
|
||||
focusNode: widget.focusNode,
|
||||
keyboardType: TextInputType.multiline,
|
||||
textInputAction: TextInputAction.send,
|
||||
maxLines: 6,
|
||||
minLines: 1,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.navyText, fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
isCollapsed: true,
|
||||
hintText: 'Message',
|
||||
hintStyle: GoogleFonts.inter(
|
||||
color: AppTheme.textDisabled,
|
||||
fontSize: 14),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
onSubmitted: _isDesktop ? null : (_) => _handleSend(),
|
||||
),
|
||||
onSubmitted:
|
||||
_isDesktop ? null : (_) => _handleSend(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: widget.controller,
|
||||
builder: (context, value, _) {
|
||||
final canSend = _canSend(value.text);
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOut,
|
||||
decoration: BoxDecoration(
|
||||
color: canSend
|
||||
? AppTheme.brightNavy
|
||||
: AppTheme.queenPink,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed:
|
||||
canSend && !widget.isSending && !_isUploadingAttachment
|
||||
? _handleSend
|
||||
: null,
|
||||
icon: (widget.isSending || _isUploadingAttachment)
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
const SizedBox(width: 6),
|
||||
|
||||
// Voice / Send toggle
|
||||
ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: widget.controller,
|
||||
builder: (context, value, _) {
|
||||
final canSend = _canSend(value.text);
|
||||
final busy = widget.isSending || _isUploadingAttachment;
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOut,
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: canSend
|
||||
? AppTheme.brightNavy
|
||||
: AppTheme.navyBlue.withValues(alpha: 0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: busy
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(10),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: SojornColors.basicWhite,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.send,
|
||||
color: SojornColors.basicWhite, size: 18),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
: GestureDetector(
|
||||
onTap: canSend ? _handleSend : null,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: canSend
|
||||
? const Icon(Icons.send,
|
||||
key: ValueKey('send'),
|
||||
color: SojornColors.basicWhite,
|
||||
size: 17)
|
||||
: Icon(Icons.mic_none_outlined,
|
||||
key: const ValueKey('mic'),
|
||||
color: AppTheme.navyBlue
|
||||
.withValues(alpha: 0.5),
|
||||
size: 19),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -332,6 +384,56 @@ class _ComposerWidgetState extends State<ComposerWidget>
|
|||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Horizontal drawer pill button
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _DrawerPill extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _DrawerPill({
|
||||
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: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.07),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.1)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: AppTheme.brightNavy),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: AppTheme.navyBlue,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Reply preview bar
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _ReplyPreviewBar extends StatelessWidget {
|
||||
const _ReplyPreviewBar({
|
||||
required this.label,
|
||||
|
|
@ -367,9 +469,7 @@ class _ReplyPreviewBar extends StatelessWidget {
|
|||
snippet,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.textDisabled,
|
||||
),
|
||||
style: GoogleFonts.inter(color: AppTheme.textDisabled),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in a new issue