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 '../../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,
),
);
}

View file

@ -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),
],

View file

@ -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,
),

View file

@ -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,
),

View file

@ -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,

View file

@ -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;

View file

@ -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: [

View file

@ -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;

View file

@ -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,
),
),
),
),
),

View file

@ -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),
),
],
),