feat: Migrate board entries and encrypted chat to ComposerBar widget
- Replace custom TextField+send-button in board_entry_detail_screen with ComposerBar - Replace custom compose UI in create_board_post_sheet with ComposerBar (text+image+send) - Enable GIF picker in encrypted group chat (allowGifs: true in ComposerConfig) - Enable GIF picker in encrypted group feed posts (allowGifs: true) - Add GIF URL detection in group_chat_tab: detect GIF URLs stored as body text from sendGroupMessage fallback - Add
This commit is contained in:
parent
8844a95f3f
commit
72046c08df
|
|
@ -5,6 +5,7 @@ import '../../models/board_entry.dart';
|
||||||
import '../../services/api_service.dart';
|
import '../../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,15 +55,10 @@ class _BoardEntryDetailScreenState extends ConsumerState<BoardEntryDetailScreen>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _sendReply() async {
|
Future<void> _sendReply(String text, String? _) async {
|
||||||
final body = _replyController.text.trim();
|
|
||||||
if (body.isEmpty) return;
|
|
||||||
|
|
||||||
setState(() => _isSendingReply = true);
|
|
||||||
try {
|
|
||||||
final data = await ApiService.instance.createBoardReply(
|
final data = await ApiService.instance.createBoardReply(
|
||||||
entryId: _entry.id,
|
entryId: _entry.id,
|
||||||
body: body,
|
body: text,
|
||||||
);
|
);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
final reply = BoardReply.fromJson(data['reply'] as Map<String, dynamic>);
|
final reply = BoardReply.fromJson(data['reply'] as Map<String, dynamic>);
|
||||||
|
|
@ -78,10 +71,7 @@ class _BoardEntryDetailScreenState extends ConsumerState<BoardEntryDetailScreen>
|
||||||
authorHandle: _entry.authorHandle, authorDisplayName: _entry.authorDisplayName,
|
authorHandle: _entry.authorHandle, authorDisplayName: _entry.authorDisplayName,
|
||||||
authorAvatarUrl: _entry.authorAvatarUrl, hasVoted: _entry.hasVoted,
|
authorAvatarUrl: _entry.authorAvatarUrl, hasVoted: _entry.hasVoted,
|
||||||
);
|
);
|
||||||
_isSendingReply = false;
|
|
||||||
});
|
});
|
||||||
_replyController.clear();
|
|
||||||
// Scroll to bottom
|
|
||||||
Future.delayed(const Duration(milliseconds: 100), () {
|
Future.delayed(const Duration(milliseconds: 100), () {
|
||||||
if (_scrollController.hasClients) {
|
if (_scrollController.hasClients) {
|
||||||
_scrollController.animateTo(
|
_scrollController.animateTo(
|
||||||
|
|
@ -92,12 +82,6 @@ class _BoardEntryDetailScreenState extends ConsumerState<BoardEntryDetailScreen>
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() => _isSendingReply = false);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not post reply: $e')));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleEntryVote() async {
|
Future<void> _toggleEntryVote() async {
|
||||||
|
|
@ -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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,65 +25,15 @@ 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() {
|
|
||||||
_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 apiService = ref.read(apiServiceProvider);
|
||||||
final data = await apiService.createBoardEntry(
|
final data = await apiService.createBoardEntry(
|
||||||
body: body,
|
body: text,
|
||||||
imageUrl: _uploadedImageUrl,
|
imageUrl: imageUrl,
|
||||||
topic: _selectedTopic.value,
|
topic: _selectedTopic.value,
|
||||||
lat: widget.centerLat,
|
lat: widget.centerLat,
|
||||||
long: widget.centerLong,
|
long: widget.centerLong,
|
||||||
|
|
@ -95,13 +43,6 @@ class _CreateBoardPostSheetState extends ConsumerState<CreateBoardPostSheet> {
|
||||||
widget.onEntryCreated(entry);
|
widget.onEntryCreated(entry);
|
||||||
Navigator.of(context).pop();
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -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),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -151,20 +151,21 @@ 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();
|
||||||
|
case _ChatMenuAction.uploadKeys:
|
||||||
try {
|
try {
|
||||||
await _chatService.uploadKeysManually();
|
await _chatService.uploadKeysManually();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
const SnackBar(
|
||||||
content: Text('Keys uploaded successfully'),
|
content: Text('Keys uploaded successfully'),
|
||||||
backgroundColor: const Color(0xFF4CAF50),
|
backgroundColor: Color(0xFF4CAF50),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -178,69 +179,61 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
case _ChatMenuAction.backup:
|
||||||
icon: Icon(Icons.key, color: AppTheme.navyBlue),
|
|
||||||
tooltip: 'Upload encryption keys',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
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(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => const EncryptionHubScreen(),
|
builder: (context) => const EncryptionHubScreen(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
case _ChatMenuAction.devices:
|
||||||
icon: Icon(Icons.backup, color: AppTheme.navyBlue),
|
|
||||||
tooltip: 'Backup & Recovery',
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Show device management
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Device management coming soon')),
|
const SnackBar(content: Text('Device management coming soon')),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
icon: Icon(Icons.devices, color: AppTheme.navyBlue),
|
itemBuilder: (_) => [
|
||||||
tooltip: 'Device Management',
|
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(),
|
||||||
|
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 {
|
class _ConversationTile extends StatefulWidget {
|
||||||
final SecureConversation conversation;
|
final SecureConversation conversation;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,17 +387,22 @@ 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: GestureDetector(
|
||||||
|
onTap: () => _showImageFullscreen(context, url),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 200),
|
||||||
child: SignedMediaImage(
|
child: SignedMediaImage(
|
||||||
url: url,
|
url: url,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 220,
|
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
...attachments.videos.map(
|
...attachments.videos.map(
|
||||||
(url) => Padding(
|
(url) => Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
if (widget.isSending || _isUploadingAttachment) return;
|
setState(() => _drawerOpen = !_drawerOpen);
|
||||||
final choice = await showModalBottomSheet<String>(
|
if (_drawerOpen) widget.focusNode.unfocus();
|
||||||
context: context,
|
}
|
||||||
backgroundColor: AppTheme.cardSurface,
|
|
||||||
shape: const RoundedRectangleBorder(
|
Future<void> _pickFromGallery() async {
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
setState(() => _drawerOpen = false);
|
||||||
),
|
await _pickAndUpload('photo_gallery');
|
||||||
builder: (context) => SafeArea(
|
}
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
Future<void> _takePhoto() async {
|
||||||
children: [
|
setState(() => _drawerOpen = false);
|
||||||
ListTile(
|
await _pickAndUpload('photo_camera');
|
||||||
leading: Icon(Icons.photo_outlined, color: AppTheme.navyBlue),
|
}
|
||||||
title: const Text('Photo from gallery'),
|
|
||||||
onTap: () => Navigator.pop(context, 'photo_gallery'),
|
Future<void> _pickGif() async {
|
||||||
),
|
setState(() => _drawerOpen = false);
|
||||||
ListTile(
|
showGifPicker(context, onSelected: (url) {
|
||||||
leading: Icon(Icons.videocam_outlined, color: AppTheme.navyBlue),
|
final existing = widget.controller.text.trim();
|
||||||
title: const Text('Video from gallery'),
|
final tag = '[img]$url';
|
||||||
onTap: () => Navigator.pop(context, 'video_gallery'),
|
final nextText = existing.isEmpty ? tag : '$existing\n$tag';
|
||||||
),
|
widget.controller.text = nextText;
|
||||||
ListTile(
|
widget.controller.selection = TextSelection.fromPosition(
|
||||||
leading: Icon(Icons.photo_camera_outlined, color: AppTheme.navyBlue),
|
TextPosition(offset: widget.controller.text.length),
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
widget.focusNode.requestFocus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!mounted || choice == null) return;
|
Future<void> _pickAndUpload(String choice) async {
|
||||||
|
if (widget.isSending || _isUploadingAttachment) 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,20 +194,78 @@ 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(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
|
||||||
|
child: _ReplyPreviewBar(
|
||||||
label: widget.replyingLabel!,
|
label: widget.replyingLabel!,
|
||||||
snippet: widget.replyingSnippet!,
|
snippet: widget.replyingSnippet!,
|
||||||
onCancel: widget.onCancelReply,
|
onCancel: widget.onCancelReply,
|
||||||
),
|
),
|
||||||
Row(
|
),
|
||||||
|
|
||||||
|
// ── 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,
|
||||||
|
),
|
||||||
|
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,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
// + toggle button
|
||||||
onPressed: _handleAddAttachment,
|
AnimatedRotation(
|
||||||
icon: Icon(Icons.add, color: AppTheme.brightNavy),
|
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(
|
Expanded(
|
||||||
child: AnimatedSize(
|
child: AnimatedSize(
|
||||||
duration: const Duration(milliseconds: 160),
|
duration: const Duration(milliseconds: 160),
|
||||||
|
|
@ -231,15 +273,15 @@ class _ComposerWidgetState extends State<ComposerWidget>
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
minHeight: 44,
|
minHeight: 40,
|
||||||
maxHeight: maxHeight,
|
maxHeight: maxHeight,
|
||||||
),
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16, vertical: 6),
|
horizontal: 14, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: SojornColors.basicWhite.withValues(alpha: 0.04),
|
color: AppTheme.scaffoldBg,
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppTheme.navyBlue.withValues(alpha: 0.08),
|
color: AppTheme.navyBlue.withValues(alpha: 0.08),
|
||||||
),
|
),
|
||||||
|
|
@ -262,25 +304,22 @@ class _ComposerWidgetState extends State<ComposerWidget>
|
||||||
textInputAction: TextInputAction.send,
|
textInputAction: TextInputAction.send,
|
||||||
maxLines: 6,
|
maxLines: 6,
|
||||||
minLines: 1,
|
minLines: 1,
|
||||||
textCapitalization:
|
textCapitalization: TextCapitalization.sentences,
|
||||||
TextCapitalization.sentences,
|
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
color: AppTheme.navyText,
|
color: AppTheme.navyText, fontSize: 14),
|
||||||
),
|
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
isCollapsed: true,
|
isCollapsed: true,
|
||||||
hintText: 'Message',
|
hintText: 'Message',
|
||||||
hintStyle: GoogleFonts.inter(
|
hintStyle: GoogleFonts.inter(
|
||||||
color: AppTheme.textDisabled,
|
color: AppTheme.textDisabled,
|
||||||
),
|
fontSize: 14),
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
enabledBorder: InputBorder.none,
|
enabledBorder: InputBorder.none,
|
||||||
focusedBorder: InputBorder.none,
|
focusedBorder: InputBorder.none,
|
||||||
disabledBorder: InputBorder.none,
|
disabledBorder: InputBorder.none,
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
onSubmitted:
|
onSubmitted: _isDesktop ? null : (_) => _handleSend(),
|
||||||
_isDesktop ? null : (_) => _handleSend(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -288,42 +327,55 @@ class _ComposerWidgetState extends State<ComposerWidget>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 6),
|
||||||
|
|
||||||
|
// Voice / Send toggle
|
||||||
ValueListenableBuilder<TextEditingValue>(
|
ValueListenableBuilder<TextEditingValue>(
|
||||||
valueListenable: widget.controller,
|
valueListenable: widget.controller,
|
||||||
builder: (context, value, _) {
|
builder: (context, value, _) {
|
||||||
final canSend = _canSend(value.text);
|
final canSend = _canSend(value.text);
|
||||||
|
final busy = widget.isSending || _isUploadingAttachment;
|
||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 150),
|
duration: const Duration(milliseconds: 150),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: canSend
|
color: canSend
|
||||||
? AppTheme.brightNavy
|
? AppTheme.brightNavy
|
||||||
: AppTheme.queenPink,
|
: AppTheme.navyBlue.withValues(alpha: 0.12),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: IconButton(
|
child: busy
|
||||||
onPressed:
|
? const Padding(
|
||||||
canSend && !widget.isSending && !_isUploadingAttachment
|
padding: EdgeInsets.all(10),
|
||||||
? _handleSend
|
|
||||||
: null,
|
|
||||||
icon: (widget.isSending || _isUploadingAttachment)
|
|
||||||
? const SizedBox(
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue