diff --git a/sojorn_app/lib/screens/beacon/board_entry_detail_screen.dart b/sojorn_app/lib/screens/beacon/board_entry_detail_screen.dart index 01a5b45..6cae940 100644 --- a/sojorn_app/lib/screens/beacon/board_entry_detail_screen.dart +++ b/sojorn_app/lib/screens/beacon/board_entry_detail_screen.dart @@ -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 List _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 @override void dispose() { - _replyController.dispose(); _scrollController.dispose(); super.dispose(); } @@ -57,46 +55,32 @@ class _BoardEntryDetailScreenState extends ConsumerState } } - Future _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); - 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 _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); + 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 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, ), ); } diff --git a/sojorn_app/lib/screens/beacon/create_board_post_sheet.dart b/sojorn_app/lib/screens/beacon/create_board_post_sheet.dart index 716e888..68814a2 100644 --- a/sojorn_app/lib/screens/beacon/create_board_post_sheet.dart +++ b/sojorn_app/lib/screens/beacon/create_board_post_sheet.dart @@ -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 { - 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 _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 _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); - 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 _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); + widget.onEntryCreated(entry); + Navigator.of(context).pop(); } } @@ -136,7 +77,7 @@ class _CreateBoardPostSheetState extends ConsumerState { 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 { ), 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), ], diff --git a/sojorn_app/lib/screens/clusters/group_chat_tab.dart b/sojorn_app/lib/screens/clusters/group_chat_tab.dart index 0b5f423..baac8a7 100644 --- a/sojorn_app/lib/screens/clusters/group_chat_tab.dart +++ b/sojorn_app/lib/screens/clusters/group_chat_tab.dart @@ -51,7 +51,16 @@ class _GroupChatTabState extends State { 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.from(msg) + ..['gif_url'] = body + ..['body'] = ''; + } + return msg; + }).toList(); } } catch (e) { debugPrint('[GroupChat] Error: $e'); @@ -324,7 +333,7 @@ class _GroupChatTabState extends State { top: false, child: ComposerBar( config: widget.isEncrypted - ? const ComposerConfig(hintText: 'Encrypted message…') + ? const ComposerConfig(allowGifs: true, hintText: 'Encrypted message…') : ComposerConfig.chat, onSend: _onChatSend, ), diff --git a/sojorn_app/lib/screens/clusters/group_feed_tab.dart b/sojorn_app/lib/screens/clusters/group_feed_tab.dart index 6a3ac31..f4723ff 100644 --- a/sojorn_app/lib/screens/clusters/group_feed_tab.dart +++ b/sojorn_app/lib/screens/clusters/group_feed_tab.dart @@ -99,7 +99,8 @@ class _GroupFeedTabState extends State { Future _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 { ), child: ComposerBar( config: widget.isEncrypted - ? ComposerConfig.privatePost + ? const ComposerConfig(allowGifs: true, hintText: 'Write an encrypted post…') : ComposerConfig.publicPost, onSend: _onComposerSend, ), diff --git a/sojorn_app/lib/screens/compose/compose_screen.dart b/sojorn_app/lib/screens/compose/compose_screen.dart index b7a857b..31021ad 100644 --- a/sojorn_app/lib/screens/compose/compose_screen.dart +++ b/sojorn_app/lib/screens/compose/compose_screen.dart @@ -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 { Uint8List? _selectedImageBytes; String? _selectedImageName; ImageFilter? _selectedFilter; + String? _selectedGifUrl; final ImagePicker _imagePicker = ImagePicker(); static const double _editorFontSize = 18; List _tagSuggestions = []; @@ -275,6 +278,19 @@ class _ComposeScreenState extends ConsumerState { _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 { }); try { - String? imageUrl; + String? imageUrl = _selectedGifUrl; if (_selectedImageFile != null || _selectedImageBytes != null) { setState(() { @@ -650,6 +666,9 @@ class _ComposeScreenState extends ConsumerState { !isKeyboardOpen ? _buildImagePreview() : null, + gifPreviewWidget: _selectedGifUrl != null && !isKeyboardOpen + ? _buildGifPreview() + : null, linkPreviewWidget: !_isTyping && _linkPreview != null && !isKeyboardOpen ? _buildComposeLinkPreview() : null, @@ -668,6 +687,7 @@ class _ComposeScreenState extends ConsumerState { 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 { ); } + 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 suggestions; final ValueChanged 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, diff --git a/sojorn_app/lib/screens/secure_chat/secure_chat_full_screen.dart b/sojorn_app/lib/screens/secure_chat/secure_chat_full_screen.dart index e852329..ef45746 100644 --- a/sojorn_app/lib/screens/secure_chat/secure_chat_full_screen.dart +++ b/sojorn_app/lib/screens/secure_chat/secure_chat_full_screen.dart @@ -151,96 +151,89 @@ class _SecureChatFullScreenState extends State { 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 { } } +enum _ChatMenuAction { refresh, uploadKeys, backup, devices } + class _ConversationTile extends StatefulWidget { final SecureConversation conversation; final VoidCallback onTap; diff --git a/sojorn_app/lib/widgets/composer/composer_toolbar.dart b/sojorn_app/lib/widgets/composer/composer_toolbar.dart index 632b6e0..ae5ac84 100644 --- a/sojorn_app/lib/widgets/composer/composer_toolbar.dart +++ b/sojorn_app/lib/widgets/composer/composer_toolbar.dart @@ -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: [ diff --git a/sojorn_app/lib/widgets/gif/gif_picker.dart b/sojorn_app/lib/widgets/gif/gif_picker.dart index 60c90a8..a28577e 100644 --- a/sojorn_app/lib/widgets/gif/gif_picker.dart +++ b/sojorn_app/lib/widgets/gif/gif_picker.dart @@ -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; diff --git a/sojorn_app/lib/widgets/secure_chat/chat_bubble_widget.dart b/sojorn_app/lib/widgets/secure_chat/chat_bubble_widget.dart index e561973..b3718f1 100644 --- a/sojorn_app/lib/widgets/secure_chat/chat_bubble_widget.dart +++ b/sojorn_app/lib/widgets/secure_chat/chat_bubble_widget.dart @@ -165,22 +165,17 @@ class _ChatBubbleWidgetState extends State 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 ); } + void _showImageFullscreen(BuildContext context, String url) { + showDialog( + 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 ...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, + ), + ), ), ), ), diff --git a/sojorn_app/lib/widgets/secure_chat/composer_widget.dart b/sojorn_app/lib/widgets/secure_chat/composer_widget.dart index b390afe..c248618 100644 --- a/sojorn_app/lib/widgets/secure_chat/composer_widget.dart +++ b/sojorn_app/lib/widgets/secure_chat/composer_widget.dart @@ -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 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 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 widget.onSend(); } - Future _handleAddAttachment() async { + void _toggleDrawer() { + setState(() => _drawerOpen = !_drawerOpen); + if (_drawerOpen) widget.focusNode.unfocus(); + } + + Future _pickFromGallery() async { + setState(() => _drawerOpen = false); + await _pickAndUpload('photo_gallery'); + } + + Future _takePhoto() async { + setState(() => _drawerOpen = false); + await _pickAndUpload('photo_camera'); + } + + Future _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 _pickAndUpload(String choice) async { if (widget.isSending || _isUploadingAttachment) return; - final choice = await showModalBottomSheet( - 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 } 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 } catch (_) { // Ignore picker errors; UI remains usable. } finally { - if (mounted) { - setState(() => _isUploadingAttachment = false); - } + if (mounted) setState(() => _isUploadingAttachment = false); } } Future _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 final maxHeight = (52 + (_lineCount - 1) * 22).clamp(52, 140).toDouble(); final shortcuts = _isDesktop ? { - const SingleActivator(LogicalKeyboardKey.enter): - const _SendIntent(), + const SingleActivator(LogicalKeyboardKey.enter): const _SendIntent(), } : const {}; @@ -196,13 +183,10 @@ class _ComposerWidgetState extends State 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 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( - 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( + 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 } } +// ───────────────────────────────────────────────────────────────────────────── +// 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), ), ], ),