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:
Patrick Britton 2026-02-18 10:37:18 -06:00
parent 8844a95f3f
commit 72046c08df
10 changed files with 516 additions and 510 deletions

View file

@ -5,6 +5,7 @@ import '../../models/board_entry.dart';
import '../../services/api_service.dart'; import '../../services/api_service.dart';
import '../../theme/tokens.dart'; import '../../theme/tokens.dart';
import '../../theme/app_theme.dart'; import '../../theme/app_theme.dart';
import '../../widgets/composer/composer_bar.dart';
class BoardEntryDetailScreen extends ConsumerStatefulWidget { class BoardEntryDetailScreen extends ConsumerStatefulWidget {
final BoardEntry entry; final BoardEntry entry;
@ -20,8 +21,6 @@ class _BoardEntryDetailScreenState extends ConsumerState<BoardEntryDetailScreen>
List<BoardReply> _replies = []; List<BoardReply> _replies = [];
bool _isLoading = true; bool _isLoading = true;
bool _isNeighborhoodAdmin = false; bool _isNeighborhoodAdmin = false;
bool _isSendingReply = false;
final _replyController = TextEditingController();
final _scrollController = ScrollController(); final _scrollController = ScrollController();
@override @override
@ -33,7 +32,6 @@ class _BoardEntryDetailScreenState extends ConsumerState<BoardEntryDetailScreen>
@override @override
void dispose() { void dispose() {
_replyController.dispose();
_scrollController.dispose(); _scrollController.dispose();
super.dispose(); super.dispose();
} }
@ -57,46 +55,32 @@ class _BoardEntryDetailScreenState extends ConsumerState<BoardEntryDetailScreen>
} }
} }
Future<void> _sendReply() async { Future<void> _sendReply(String text, String? _) async {
final body = _replyController.text.trim(); final data = await ApiService.instance.createBoardReply(
if (body.isEmpty) return; entryId: _entry.id,
body: text,
setState(() => _isSendingReply = true); );
try { if (mounted) {
final data = await ApiService.instance.createBoardReply( final reply = BoardReply.fromJson(data['reply'] as Map<String, dynamic>);
entryId: _entry.id, setState(() {
body: body, _replies.add(reply);
); _entry = BoardEntry(
if (mounted) { id: _entry.id, body: _entry.body, imageUrl: _entry.imageUrl, topic: _entry.topic,
final reply = BoardReply.fromJson(data['reply'] as Map<String, dynamic>); lat: _entry.lat, long: _entry.long, upvotes: _entry.upvotes,
setState(() { replyCount: _entry.replyCount + 1, isPinned: _entry.isPinned, createdAt: _entry.createdAt,
_replies.add(reply); authorHandle: _entry.authorHandle, authorDisplayName: _entry.authorDisplayName,
_entry = BoardEntry( authorAvatarUrl: _entry.authorAvatarUrl, hasVoted: _entry.hasVoted,
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, Future.delayed(const Duration(milliseconds: 100), () {
authorHandle: _entry.authorHandle, authorDisplayName: _entry.authorDisplayName, if (_scrollController.hasClients) {
authorAvatarUrl: _entry.authorAvatarUrl, hasVoted: _entry.hasVoted, _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, color: AppTheme.cardSurface,
border: Border(top: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.08))), border: Border(top: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.08))),
), ),
child: Row( child: ComposerBar(
children: [ config: const ComposerConfig(hintText: 'Write a reply…'),
Expanded( onSend: _sendReply,
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),
),
),
],
), ),
); );
} }

View file

@ -1,12 +1,10 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import '../../models/board_entry.dart'; import '../../models/board_entry.dart';
import '../../providers/api_provider.dart'; import '../../providers/api_provider.dart';
import '../../services/image_upload_service.dart';
import '../../theme/tokens.dart'; import '../../theme/tokens.dart';
import '../../theme/app_theme.dart'; import '../../theme/app_theme.dart';
import '../../widgets/composer/composer_bar.dart';
/// Compose sheet for the standalone neighborhood board. /// Compose sheet for the standalone neighborhood board.
/// Creates board_entries completely separate from posts/beacons. /// Creates board_entries completely separate from posts/beacons.
@ -27,80 +25,23 @@ class CreateBoardPostSheet extends ConsumerStatefulWidget {
} }
class _CreateBoardPostSheetState extends ConsumerState<CreateBoardPostSheet> { class _CreateBoardPostSheetState extends ConsumerState<CreateBoardPostSheet> {
final ImageUploadService _imageUploadService = ImageUploadService();
final _bodyController = TextEditingController();
BoardTopic _selectedTopic = BoardTopic.community; BoardTopic _selectedTopic = BoardTopic.community;
bool _isSubmitting = false;
bool _isUploadingImage = false;
File? _selectedImage;
String? _uploadedImageUrl;
static const _topics = BoardTopic.values; static const _topics = BoardTopic.values;
@override Future<void> _onComposerSend(String text, String? imageUrl) async {
void dispose() { final apiService = ref.read(apiServiceProvider);
_bodyController.dispose(); final data = await apiService.createBoardEntry(
super.dispose(); body: text,
} imageUrl: imageUrl,
topic: _selectedTopic.value,
Future<void> _pickImage() async { lat: widget.centerLat,
setState(() => _isUploadingImage = true); long: widget.centerLong,
try { );
final image = await ImagePicker().pickImage( if (mounted) {
source: ImageSource.gallery, final entry = BoardEntry.fromJson(data['entry'] as Map<String, dynamic>);
maxWidth: 1920, widget.onEntryCreated(entry);
maxHeight: 1920, Navigator.of(context).pop();
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);
} }
} }
@ -136,7 +77,7 @@ class _CreateBoardPostSheetState extends ConsumerState<CreateBoardPostSheet> {
child: Text('Post to Board', style: TextStyle(color: AppTheme.navyBlue, fontSize: 18, fontWeight: FontWeight.bold)), child: Text('Post to Board', style: TextStyle(color: AppTheme.navyBlue, fontSize: 18, fontWeight: FontWeight.bold)),
), ),
IconButton( IconButton(
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
icon: Icon(Icons.close, color: SojornColors.textDisabled), icon: Icon(Icons.close, color: SojornColors.textDisabled),
), ),
], ],
@ -185,93 +126,13 @@ class _CreateBoardPostSheetState extends ConsumerState<CreateBoardPostSheet> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Body // Composer (text + photo + send)
TextFormField( ComposerBar(
controller: _bodyController, config: const ComposerConfig(
style: TextStyle(color: SojornColors.postContent, fontSize: 14), allowImages: true,
decoration: InputDecoration(
hintText: 'Share with your neighborhood…', 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), const SizedBox(height: 8),
], ],

View file

@ -51,7 +51,16 @@ class _GroupChatTabState extends State<GroupChatTab> {
await _loadEncryptedMessages(); await _loadEncryptedMessages();
} else { } else {
final msgs = await ApiService.instance.fetchGroupMessages(widget.groupId); 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) { } catch (e) {
debugPrint('[GroupChat] Error: $e'); debugPrint('[GroupChat] Error: $e');
@ -324,7 +333,7 @@ class _GroupChatTabState extends State<GroupChatTab> {
top: false, top: false,
child: ComposerBar( child: ComposerBar(
config: widget.isEncrypted config: widget.isEncrypted
? const ComposerConfig(hintText: 'Encrypted message…') ? const ComposerConfig(allowGifs: true, hintText: 'Encrypted message…')
: ComposerConfig.chat, : ComposerConfig.chat,
onSend: _onChatSend, onSend: _onChatSend,
), ),

View file

@ -99,7 +99,8 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
Future<void> _onComposerSend(String text, String? mediaUrl) async { Future<void> _onComposerSend(String text, String? mediaUrl) async {
if (widget.isEncrypted && widget.capsuleKey != null) { if (widget.isEncrypted && widget.capsuleKey != null) {
final encrypted = await CapsuleSecurityService.encryptPayload( 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!, capsuleKey: widget.capsuleKey!,
); );
await ApiService.instance.callGoApi( await ApiService.instance.callGoApi(
@ -162,7 +163,7 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
), ),
child: ComposerBar( child: ComposerBar(
config: widget.isEncrypted config: widget.isEncrypted
? ComposerConfig.privatePost ? const ComposerConfig(allowGifs: true, hintText: 'Write an encrypted post…')
: ComposerConfig.publicPost, : ComposerConfig.publicPost,
onSend: _onComposerSend, onSend: _onComposerSend,
), ),

View file

@ -16,7 +16,9 @@ import '../../services/api_service.dart';
import '../../services/image_upload_service.dart'; import '../../services/image_upload_service.dart';
import '../../theme/app_theme.dart'; import '../../theme/app_theme.dart';
import '../../theme/tokens.dart'; import '../../theme/tokens.dart';
import '../../config/api_config.dart';
import '../../widgets/composer/composer_toolbar.dart'; import '../../widgets/composer/composer_toolbar.dart';
import '../../widgets/gif/gif_picker.dart';
import '../../services/content_filter.dart'; import '../../services/content_filter.dart';
import '../../widgets/sojorn_snackbar.dart'; import '../../widgets/sojorn_snackbar.dart';
import 'image_editor_screen.dart'; import 'image_editor_screen.dart';
@ -61,6 +63,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
Uint8List? _selectedImageBytes; Uint8List? _selectedImageBytes;
String? _selectedImageName; String? _selectedImageName;
ImageFilter? _selectedFilter; ImageFilter? _selectedFilter;
String? _selectedGifUrl;
final ImagePicker _imagePicker = ImagePicker(); final ImagePicker _imagePicker = ImagePicker();
static const double _editorFontSize = 18; static const double _editorFontSize = 18;
List<String> _tagSuggestions = []; List<String> _tagSuggestions = [];
@ -275,6 +278,19 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
_selectedImageBytes = null; _selectedImageBytes = null;
_selectedImageName = null; _selectedImageName = null;
_selectedFilter = 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 { try {
String? imageUrl; String? imageUrl = _selectedGifUrl;
if (_selectedImageFile != null || _selectedImageBytes != null) { if (_selectedImageFile != null || _selectedImageBytes != null) {
setState(() { setState(() {
@ -650,6 +666,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
!isKeyboardOpen !isKeyboardOpen
? _buildImagePreview() ? _buildImagePreview()
: null, : null,
gifPreviewWidget: _selectedGifUrl != null && !isKeyboardOpen
? _buildGifPreview()
: null,
linkPreviewWidget: !_isTyping && _linkPreview != null && !isKeyboardOpen linkPreviewWidget: !_isTyping && _linkPreview != null && !isKeyboardOpen
? _buildComposeLinkPreview() ? _buildComposeLinkPreview()
: null, : null,
@ -668,6 +687,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
bottom: MediaQuery.of(context).viewInsets.bottom), bottom: MediaQuery.of(context).viewInsets.bottom),
child: ComposeBottomBar( child: ComposeBottomBar(
onAddMedia: _pickMedia, onAddMedia: _pickMedia,
onAddGif: _openGifPicker,
onToggleBold: _toggleBold, onToggleBold: _toggleBold,
onToggleItalic: _toggleItalic, onToggleItalic: _toggleItalic,
onToggleChain: _toggleChain, 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() { Widget _buildComposeLinkPreview() {
final preview = _linkPreview!; final preview = _linkPreview!;
final domain = preview['domain'] as String? ?? ''; final domain = preview['domain'] as String? ?? '';
@ -895,6 +953,7 @@ class ComposeBody extends StatelessWidget {
final bool isBold; final bool isBold;
final bool isItalic; final bool isItalic;
final Widget? imageWidget; final Widget? imageWidget;
final Widget? gifPreviewWidget;
final Widget? linkPreviewWidget; final Widget? linkPreviewWidget;
final List<String> suggestions; final List<String> suggestions;
final ValueChanged<String> onSelectSuggestion; final ValueChanged<String> onSelectSuggestion;
@ -908,6 +967,7 @@ class ComposeBody extends StatelessWidget {
required this.suggestions, required this.suggestions,
required this.onSelectSuggestion, required this.onSelectSuggestion,
this.imageWidget, this.imageWidget,
this.gifPreviewWidget,
this.linkPreviewWidget, this.linkPreviewWidget,
}); });
@ -982,6 +1042,7 @@ class ComposeBody extends StatelessWidget {
), ),
if (linkPreviewWidget != null) linkPreviewWidget!, if (linkPreviewWidget != null) linkPreviewWidget!,
if (imageWidget != null) imageWidget!, 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 /// Bottom bar pinned above the keyboard with formatting + counter
class ComposeBottomBar extends StatelessWidget { class ComposeBottomBar extends StatelessWidget {
final VoidCallback onAddMedia; final VoidCallback onAddMedia;
final VoidCallback? onAddGif;
final VoidCallback onToggleBold; final VoidCallback onToggleBold;
final VoidCallback onToggleItalic; final VoidCallback onToggleItalic;
final VoidCallback onToggleChain; final VoidCallback onToggleChain;
@ -1009,6 +1071,7 @@ class ComposeBottomBar extends StatelessWidget {
const ComposeBottomBar({ const ComposeBottomBar({
super.key, super.key,
required this.onAddMedia, required this.onAddMedia,
this.onAddGif,
required this.onToggleBold, required this.onToggleBold,
required this.onToggleItalic, required this.onToggleItalic,
required this.onToggleChain, required this.onToggleChain,
@ -1045,6 +1108,7 @@ class ComposeBottomBar extends StatelessWidget {
top: false, top: false,
child: ComposerToolbar( child: ComposerToolbar(
onAddMedia: onAddMedia, onAddMedia: onAddMedia,
onAddGif: onAddGif,
onToggleBold: onToggleBold, onToggleBold: onToggleBold,
onToggleItalic: onToggleItalic, onToggleItalic: onToggleItalic,
onToggleChain: onToggleChain, onToggleChain: onToggleChain,

View file

@ -151,96 +151,89 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
showSearch: false, showSearch: false,
showMessages: false, showMessages: false,
leadingActions: [ leadingActions: [
IconButton( PopupMenuButton<_ChatMenuAction>(
onPressed: _loadConversations, icon: Icon(Icons.more_vert, color: AppTheme.navyBlue),
icon: Icon(Icons.refresh, color: AppTheme.navyBlue), tooltip: 'More options',
tooltip: 'Refresh conversations', onSelected: (action) async {
), switch (action) {
IconButton( case _ChatMenuAction.refresh:
onPressed: () async { await _loadConversations();
try { case _ChatMenuAction.uploadKeys:
await _chatService.uploadKeysManually(); try {
if (mounted) { await _chatService.uploadKeysManually();
ScaffoldMessenger.of(context).showSnackBar( if (mounted) {
SnackBar( ScaffoldMessenger.of(context).showSnackBar(
content: Text('Keys uploaded successfully'), const SnackBar(
backgroundColor: const Color(0xFF4CAF50), 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(),
), ),
); );
} case _ChatMenuAction.devices:
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( const SnackBar(content: Text('Device management coming soon')),
content: Text('Failed to upload keys: $e'),
backgroundColor: SojornColors.destructive,
),
); );
}
} }
}, },
icon: Icon(Icons.key, color: AppTheme.navyBlue), itemBuilder: (_) => [
tooltip: 'Upload encryption keys', 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(), body: _buildBody(),
bottomNavigationBar: Container( floatingActionButton: FloatingActionButton(
decoration: BoxDecoration( onPressed: _showNewConversationSheet,
color: AppTheme.scaffoldBg, backgroundColor: AppTheme.brightNavy,
border: Border( tooltip: 'New conversation',
top: BorderSide( child: const Icon(Icons.edit_outlined, color: Colors.white),
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',
),
],
),
),
),
), ),
); );
} }
@ -490,6 +483,8 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
} }
} }
enum _ChatMenuAction { refresh, uploadKeys, backup, devices }
class _ConversationTile extends StatefulWidget { class _ConversationTile extends StatefulWidget {
final SecureConversation conversation; final SecureConversation conversation;
final VoidCallback onTap; final VoidCallback onTap;

View file

@ -5,6 +5,7 @@ import '../../theme/tokens.dart';
/// Keyboard-attached toolbar for the composer with attachments, formatting, topic, and counter. /// Keyboard-attached toolbar for the composer with attachments, formatting, topic, and counter.
class ComposerToolbar extends StatelessWidget { class ComposerToolbar extends StatelessWidget {
final VoidCallback onAddMedia; final VoidCallback onAddMedia;
final VoidCallback? onAddGif;
final VoidCallback onToggleBold; final VoidCallback onToggleBold;
final VoidCallback onToggleItalic; final VoidCallback onToggleItalic;
final VoidCallback onToggleChain; final VoidCallback onToggleChain;
@ -24,6 +25,7 @@ class ComposerToolbar extends StatelessWidget {
const ComposerToolbar({ const ComposerToolbar({
super.key, super.key,
required this.onAddMedia, required this.onAddMedia,
this.onAddGif,
required this.onToggleBold, required this.onToggleBold,
required this.onToggleItalic, required this.onToggleItalic,
required this.onToggleChain, required this.onToggleChain,
@ -71,6 +73,13 @@ class ComposerToolbar extends StatelessWidget {
), ),
tooltip: 'Add media', 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( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [

View file

@ -302,7 +302,8 @@ class _RetroTabState extends State<_RetroTab>
static const _defaultQuery = 'space'; static const _defaultQuery = 'space';
static final _gifUrlRegex = RegExp( 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 @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;

View file

@ -165,22 +165,17 @@ class _ChatBubbleWidgetState extends State<ChatBubbleWidget>
if (!widget.showAvatar) return bubble; if (!widget.showAvatar) return bubble;
// Only show avatar for incoming messages not for own messages
if (widget.isMe) return bubble;
final avatar = _buildAvatar(); final avatar = _buildAvatar();
return Row( return Row(
mainAxisAlignment: widget.isMe mainAxisAlignment: MainAxisAlignment.start,
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
if (!widget.isMe) ...[ avatar,
avatar, const SizedBox(width: 10),
const SizedBox(width: 10),
],
Flexible(child: bubble), 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) { Widget _buildBubbleContent(double textScale) {
final background = widget.isMe ? AppTheme.navyBlue : AppTheme.cardSurface; final background = widget.isMe ? AppTheme.navyBlue : AppTheme.cardSurface;
final textColor = widget.isMe ? AppTheme.white : AppTheme.navyText; final textColor = widget.isMe ? AppTheme.white : AppTheme.navyText;
@ -374,13 +387,18 @@ class _ChatBubbleWidgetState extends State<ChatBubbleWidget>
...attachments.images.map( ...attachments.images.map(
(url) => Padding( (url) => Padding(
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect( child: GestureDetector(
borderRadius: BorderRadius.circular(12), onTap: () => _showImageFullscreen(context, url),
child: SignedMediaImage( child: ClipRRect(
url: url, borderRadius: BorderRadius.circular(12),
width: double.infinity, child: ConstrainedBox(
height: 220, constraints: const BoxConstraints(maxHeight: 200),
fit: BoxFit.cover, child: SignedMediaImage(
url: url,
width: double.infinity,
fit: BoxFit.cover,
),
),
), ),
), ),
), ),

View file

@ -7,8 +7,8 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import '../../theme/app_theme.dart'; import '../../theme/app_theme.dart';
import '../../theme/tokens.dart'; import '../../theme/tokens.dart';
import '../../services/auth_service.dart';
import '../../services/image_upload_service.dart'; import '../../services/image_upload_service.dart';
import '../gif/gif_picker.dart';
class ComposerWidget extends StatefulWidget { class ComposerWidget extends StatefulWidget {
const ComposerWidget({ const ComposerWidget({
@ -39,6 +39,7 @@ class _ComposerWidgetState extends State<ComposerWidget>
int _lineCount = 1; int _lineCount = 1;
final ImagePicker _picker = ImagePicker(); final ImagePicker _picker = ImagePicker();
bool _isUploadingAttachment = false; bool _isUploadingAttachment = false;
bool _drawerOpen = false;
bool get _isDesktop { bool get _isDesktop {
final platform = Theme.of(context).platform; final platform = Theme.of(context).platform;
@ -73,9 +74,7 @@ class _ComposerWidgetState extends State<ComposerWidget>
final lines = widget.controller.text.split('\n').length; final lines = widget.controller.text.split('\n').length;
final clamped = lines.clamp(1, 6).toInt(); final clamped = lines.clamp(1, 6).toInt();
if (clamped != _lineCount) { if (clamped != _lineCount) {
setState(() { setState(() => _lineCount = clamped);
_lineCount = clamped;
});
} }
} }
@ -86,45 +85,37 @@ class _ComposerWidgetState extends State<ComposerWidget>
widget.onSend(); 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; 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 { try {
XFile? file; XFile? file;
if (choice == 'photo_gallery') { if (choice == 'photo_gallery') {
@ -134,14 +125,17 @@ class _ComposerWidgetState extends State<ComposerWidget>
} else if (choice == 'photo_camera') { } else if (choice == 'photo_camera') {
file = await _picker.pickImage(source: ImageSource.camera); file = await _picker.pickImage(source: ImageSource.camera);
} else if (choice == 'video_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) { if (file != null) {
setState(() => _isUploadingAttachment = true); 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) { if (url != null && mounted) {
final existing = widget.controller.text.trim(); 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'; final nextText = existing.isEmpty ? tag : '$existing\n$tag';
widget.controller.text = nextText; widget.controller.text = nextText;
widget.controller.selection = TextSelection.fromPosition( widget.controller.selection = TextSelection.fromPosition(
@ -153,28 +147,22 @@ class _ComposerWidgetState extends State<ComposerWidget>
} catch (_) { } catch (_) {
// Ignore picker errors; UI remains usable. // Ignore picker errors; UI remains usable.
} finally { } finally {
if (mounted) { if (mounted) setState(() => _isUploadingAttachment = false);
setState(() => _isUploadingAttachment = false);
}
} }
} }
Future<String?> _uploadAttachment(File file, {required bool isVideo}) async { Future<String?> _uploadAttachment(File file, {required bool isVideo}) async {
try { try {
final service = ImageUploadService(); final service = ImageUploadService();
if (isVideo) { return isVideo
return await service.uploadVideo(file); ? await service.uploadVideo(file)
} else { : await service.uploadImage(file);
return await service.uploadImage(file);
}
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(SnackBar(
SnackBar( content: Text('Attachment upload failed: $e'),
content: Text('Attachment upload failed: $e'), backgroundColor: AppTheme.error,
backgroundColor: AppTheme.error, ));
),
);
} }
return null; return null;
} }
@ -186,8 +174,7 @@ class _ComposerWidgetState extends State<ComposerWidget>
final maxHeight = (52 + (_lineCount - 1) * 22).clamp(52, 140).toDouble(); final maxHeight = (52 + (_lineCount - 1) * 22).clamp(52, 140).toDouble();
final shortcuts = _isDesktop final shortcuts = _isDesktop
? <ShortcutActivator, Intent>{ ? <ShortcutActivator, Intent>{
const SingleActivator(LogicalKeyboardKey.enter): const SingleActivator(LogicalKeyboardKey.enter): const _SendIntent(),
const _SendIntent(),
} }
: const <ShortcutActivator, Intent>{}; : const <ShortcutActivator, Intent>{};
@ -196,13 +183,10 @@ class _ComposerWidgetState extends State<ComposerWidget>
curve: Curves.easeOut, curve: Curves.easeOut,
padding: EdgeInsets.only(bottom: viewInsets.bottom), padding: EdgeInsets.only(bottom: viewInsets.bottom),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.cardSurface, color: AppTheme.cardSurface,
border: Border( border: Border(
top: BorderSide( top: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.1)),
color: AppTheme.navyBlue.withValues(alpha: 0.1),
),
), ),
), ),
child: SafeArea( child: SafeArea(
@ -210,119 +194,187 @@ class _ComposerWidgetState extends State<ComposerWidget>
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (widget.replyingLabel != null && // Reply preview
widget.replyingSnippet != null) if (widget.replyingLabel != null && widget.replyingSnippet != null)
_ReplyPreviewBar( Padding(
label: widget.replyingLabel!, padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
snippet: widget.replyingSnippet!, child: _ReplyPreviewBar(
onCancel: widget.onCancelReply, label: widget.replyingLabel!,
), snippet: widget.replyingSnippet!,
Row( onCancel: widget.onCancelReply,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
IconButton(
onPressed: _handleAddAttachment,
icon: Icon(Icons.add, color: AppTheme.brightNavy),
), ),
Expanded( ),
child: AnimatedSize(
duration: const Duration(milliseconds: 160), // Horizontal tool drawer
curve: Curves.easeOut, AnimatedContainer(
alignment: Alignment.bottomCenter, duration: const Duration(milliseconds: 200),
child: ConstrainedBox( curve: Curves.easeOut,
constraints: BoxConstraints( height: _drawerOpen ? 52 : 0,
minHeight: 44, child: OverflowBox(
maxHeight: maxHeight, 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( const SizedBox(width: 8),
padding: const EdgeInsets.symmetric( _DrawerPill(
horizontal: 16, vertical: 6), icon: Icons.camera_alt_outlined,
decoration: BoxDecoration( label: 'Camera',
color: SojornColors.basicWhite.withValues(alpha: 0.04), onTap: _takePhoto,
borderRadius: BorderRadius.circular(18), ),
border: Border.all( const SizedBox(width: 8),
color: AppTheme.navyBlue.withValues(alpha: 0.08), _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( child: Container(
shortcuts: shortcuts, padding: const EdgeInsets.symmetric(
child: Actions( horizontal: 14, vertical: 8),
actions: { decoration: BoxDecoration(
_SendIntent: CallbackAction<_SendIntent>( color: AppTheme.scaffoldBg,
onInvoke: (_) { borderRadius: BorderRadius.circular(20),
_handleSend(); border: Border.all(
return null; color: AppTheme.navyBlue.withValues(alpha: 0.08),
}, ),
), ),
}, child: Shortcuts(
child: TextField( shortcuts: shortcuts,
controller: widget.controller, child: Actions(
focusNode: widget.focusNode, actions: {
keyboardType: TextInputType.multiline, _SendIntent: CallbackAction<_SendIntent>(
textInputAction: TextInputAction.send, onInvoke: (_) {
maxLines: 6, _handleSend();
minLines: 1, return null;
textCapitalization: },
TextCapitalization.sentences,
style: GoogleFonts.inter(
color: AppTheme.navyText,
),
decoration: InputDecoration(
isCollapsed: true,
hintText: 'Message',
hintStyle: GoogleFonts.inter(
color: AppTheme.textDisabled,
), ),
border: InputBorder.none, },
enabledBorder: InputBorder.none, child: TextField(
focusedBorder: InputBorder.none, controller: widget.controller,
disabledBorder: InputBorder.none, focusNode: widget.focusNode,
contentPadding: EdgeInsets.zero, 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: 6),
const SizedBox(width: 8),
ValueListenableBuilder<TextEditingValue>( // Voice / Send toggle
valueListenable: widget.controller, ValueListenableBuilder<TextEditingValue>(
builder: (context, value, _) { valueListenable: widget.controller,
final canSend = _canSend(value.text); builder: (context, value, _) {
return AnimatedContainer( final canSend = _canSend(value.text);
duration: const Duration(milliseconds: 150), final busy = widget.isSending || _isUploadingAttachment;
curve: Curves.easeOut, return AnimatedContainer(
decoration: BoxDecoration( duration: const Duration(milliseconds: 150),
color: canSend curve: Curves.easeOut,
? AppTheme.brightNavy width: 38,
: AppTheme.queenPink, height: 38,
shape: BoxShape.circle, decoration: BoxDecoration(
), color: canSend
child: IconButton( ? AppTheme.brightNavy
onPressed: : AppTheme.navyBlue.withValues(alpha: 0.12),
canSend && !widget.isSending && !_isUploadingAttachment shape: BoxShape.circle,
? _handleSend ),
: null, child: busy
icon: (widget.isSending || _isUploadingAttachment) ? const Padding(
? const SizedBox( padding: EdgeInsets.all(10),
width: 18,
height: 18,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
color: SojornColors.basicWhite, color: SojornColors.basicWhite,
), ),
) )
: const Icon(Icons.send, : GestureDetector(
color: SojornColors.basicWhite, size: 18), 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 { class _ReplyPreviewBar extends StatelessWidget {
const _ReplyPreviewBar({ const _ReplyPreviewBar({
required this.label, required this.label,
@ -367,9 +469,7 @@ class _ReplyPreviewBar extends StatelessWidget {
snippet, snippet,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: GoogleFonts.inter( style: GoogleFonts.inter(color: AppTheme.textDisabled),
color: AppTheme.textDisabled,
),
), ),
], ],
), ),