mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-16 13:50:48 +10:00
remove voice code make optimizations. Fix channels race conditions. add reply function
This commit is contained in:
@@ -32,6 +32,8 @@ class ChannelChatScreen extends StatefulWidget {
|
||||
class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
ChannelMessage? _replyingToMessage;
|
||||
final Map<String, GlobalKey> _messageKeys = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -60,6 +62,41 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _setReplyingTo(ChannelMessage message) {
|
||||
setState(() {
|
||||
_replyingToMessage = message;
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelReply() {
|
||||
setState(() {
|
||||
_replyingToMessage = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _scrollToMessage(String messageId) async {
|
||||
final key = _messageKeys[messageId];
|
||||
if (key == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Original message not found'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final targetContext = key.currentContext;
|
||||
if (targetContext == null) return;
|
||||
|
||||
await Scrollable.ensureVisible(
|
||||
targetContext,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
alignment: 0.3,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -149,12 +186,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(8),
|
||||
cacheExtent: 0,
|
||||
addAutomaticKeepAlives: false,
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
return _buildMessageBubble(message);
|
||||
if (!_messageKeys.containsKey(message.messageId)) {
|
||||
_messageKeys[message.messageId] = GlobalKey();
|
||||
}
|
||||
return Container(
|
||||
key: _messageKeys[message.messageId]!,
|
||||
child: _buildMessageBubble(message),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -214,6 +255,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
if (message.replyToMessageId != null) ...[
|
||||
_buildReplyPreview(message),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (poi != null)
|
||||
_buildPoiMessage(context, poi, isOutgoing)
|
||||
else if (gifId != null)
|
||||
@@ -282,6 +327,79 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReplyPreview(ChannelMessage message) {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final isOwnNode = message.replyToSenderName == connector.selfName;
|
||||
final replyText = message.replyToText ?? '';
|
||||
|
||||
final gifId = _parseGifId(replyText);
|
||||
final poi = _parsePoiMessage(replyText);
|
||||
|
||||
Widget contentPreview;
|
||||
if (gifId != null) {
|
||||
contentPreview = Row(
|
||||
children: [
|
||||
Icon(Icons.gif_box, size: 14, color: Colors.grey[600]),
|
||||
const SizedBox(width: 4),
|
||||
Text('GIF', style: TextStyle(fontSize: 12, color: Colors.grey[700])),
|
||||
],
|
||||
);
|
||||
} else if (poi != null) {
|
||||
contentPreview = Row(
|
||||
children: [
|
||||
Icon(Icons.location_on_outlined, size: 14, color: Colors.grey[600]),
|
||||
const SizedBox(width: 4),
|
||||
Text('Location', style: TextStyle(fontSize: 12, color: Colors.grey[700])),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
contentPreview = Text(
|
||||
replyText,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[700],
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _scrollToMessage(message.replyToMessageId!),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Reply to ${message.replyToSenderName}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isOwnNode
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
contentPreview,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _parseGifId(String text) {
|
||||
final trimmed = text.trim();
|
||||
final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed);
|
||||
@@ -412,22 +530,84 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
return colors[hash.abs() % colors.length];
|
||||
}
|
||||
|
||||
Widget _buildMessageComposer() {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final maxBytes = maxChannelMessageBytes(connector.selfName);
|
||||
Widget _buildReplyBanner() {
|
||||
final message = _replyingToMessage!;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, -2),
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.reply,
|
||||
size: 18,
|
||||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Replying to ${message.senderName}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
message.text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSecondaryContainer.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
onPressed: _cancelReply,
|
||||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageComposer() {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final maxBytes = maxChannelMessageBytes(connector.selfName);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_replyingToMessage != null) _buildReplyBanner(),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.gif_box),
|
||||
@@ -491,7 +671,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -500,16 +682,23 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
if (text.isEmpty) return;
|
||||
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
|
||||
String messageText = text;
|
||||
if (_replyingToMessage != null) {
|
||||
messageText = '@[${_replyingToMessage!.senderName}] $text';
|
||||
}
|
||||
|
||||
final maxBytes = maxChannelMessageBytes(connector.selfName);
|
||||
if (utf8.encode(text).length > maxBytes) {
|
||||
if (utf8.encode(messageText).length > maxBytes) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Message too long (max $maxBytes bytes).')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
connector.sendChannelMessage(widget.channel, text);
|
||||
connector.sendChannelMessage(widget.channel, messageText);
|
||||
_textController.clear();
|
||||
_cancelReply();
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
@@ -539,6 +728,14 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.reply),
|
||||
title: const Text('Reply'),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_setReplyingTo(message);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.copy),
|
||||
title: const Text('Copy'),
|
||||
|
||||
@@ -7,7 +7,10 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../models/channel.dart';
|
||||
import '../utils/dialog_utils.dart';
|
||||
import '../utils/disconnect_navigation_mixin.dart';
|
||||
import '../utils/route_transitions.dart';
|
||||
import '../widgets/empty_state.dart';
|
||||
import '../widgets/quick_switch_bar.dart';
|
||||
import '../widgets/unread_badge.dart';
|
||||
import 'channel_chat_screen.dart';
|
||||
@@ -27,7 +30,8 @@ class ChannelsScreen extends StatefulWidget {
|
||||
State<ChannelsScreen> createState() => _ChannelsScreenState();
|
||||
}
|
||||
|
||||
class _ChannelsScreenState extends State<ChannelsScreen> {
|
||||
class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
with DisconnectNavigationMixin {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -39,6 +43,12 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
|
||||
// Auto-navigate back to scanner if disconnected
|
||||
if (!checkConnectionAndNavigate(connector)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final allowBack = !connector.isConnected;
|
||||
|
||||
return PopScope(
|
||||
@@ -77,23 +87,13 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
|
||||
final channels = connector.channels;
|
||||
|
||||
if (channels.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.tag, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No channels configured',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _addPublicChannel(context, connector),
|
||||
icon: const Icon(Icons.public),
|
||||
label: const Text('Add Public Channel'),
|
||||
),
|
||||
],
|
||||
return EmptyState(
|
||||
icon: Icons.tag,
|
||||
title: 'No channels configured',
|
||||
action: FilledButton.icon(
|
||||
onPressed: () => _addPublicChannel(context, connector),
|
||||
icon: const Icon(Icons.public),
|
||||
label: const Text('Add Public Channel'),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -190,14 +190,17 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
onTap: () async {
|
||||
connector.markChannelRead(channel.index);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChannelChatScreen(channel: channel),
|
||||
),
|
||||
);
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
if (context.mounted) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChannelChatScreen(channel: channel),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onLongPress: () => _showChannelActions(context, connector, channel),
|
||||
),
|
||||
@@ -218,17 +221,23 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit_outlined),
|
||||
title: const Text('Edit channel'),
|
||||
onTap: () {
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
_showEditChannelDialog(context, connector, channel);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
if (context.mounted) {
|
||||
_showEditChannelDialog(context, connector, channel);
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
title: const Text('Delete channel', style: TextStyle(color: Colors.red)),
|
||||
onTap: () {
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
_confirmDeleteChannel(context, connector, channel);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
if (context.mounted) {
|
||||
_confirmDeleteChannel(context, connector, channel);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -261,27 +270,7 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
|
||||
|
||||
Future<void> _disconnect(BuildContext context) async {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Disconnect'),
|
||||
content: const Text('Are you sure you want to disconnect from this device?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Disconnect'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await connector.disconnect();
|
||||
}
|
||||
await showDisconnectDialog(context, connector);
|
||||
}
|
||||
|
||||
void _showAddChannelDialog(BuildContext context) {
|
||||
@@ -402,9 +391,11 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
|
||||
|
||||
Navigator.pop(context);
|
||||
connector.setChannel(selectedIndex, name, psk);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Channel "$name" added')),
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Channel "$name" added')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Add'),
|
||||
),
|
||||
|
||||
+54
-325
@@ -1,13 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:record/record.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
@@ -15,14 +12,12 @@ import '../helpers/utf8_length_limiter.dart';
|
||||
import '../models/channel_message.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/message.dart';
|
||||
import '../services/voice_message_service.dart';
|
||||
import '../services/path_history_service.dart';
|
||||
import 'channel_message_path_screen.dart';
|
||||
import 'map_screen.dart';
|
||||
import '../utils/emoji_utils.dart';
|
||||
import '../widgets/gif_message.dart';
|
||||
import '../widgets/gif_picker.dart';
|
||||
import '../widgets/voice_message.dart';
|
||||
|
||||
class ChatScreen extends StatefulWidget {
|
||||
final Contact contact;
|
||||
@@ -37,16 +32,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final _textController = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
bool _forceFlood = false;
|
||||
final AudioRecorder _voiceRecorder = AudioRecorder();
|
||||
StreamSubscription<Uint8List>? _voiceStreamSubscription;
|
||||
BytesBuilder _voiceBuffer = BytesBuilder(copy: false);
|
||||
Timer? _voiceRecordTimer;
|
||||
bool _isRecordingVoice = false;
|
||||
Message? _pendingVoiceMessage;
|
||||
Uint8List? _pendingVoiceCodec2Bytes;
|
||||
int? _pendingVoiceTimestampSeconds;
|
||||
int? _pendingVoiceDurationMs;
|
||||
String? _pendingVoicePath;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -62,11 +47,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
context.read<MeshCoreConnector>().setActiveContact(null);
|
||||
_textController.dispose();
|
||||
_scrollController.dispose();
|
||||
_voiceRecordTimer?.cancel();
|
||||
_voiceStreamSubscription?.cancel();
|
||||
unawaited(_voiceRecorder.stop());
|
||||
_voiceRecorder.dispose();
|
||||
unawaited(_clearPendingVoicePreview(deleteFile: true, notify: false));
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -204,8 +184,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
|
||||
cacheExtent: 0,
|
||||
addAutomaticKeepAlives: false,
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
@@ -221,9 +199,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
|
||||
Widget _buildInputBar(MeshCoreConnector connector) {
|
||||
final maxBytes = maxContactMessageBytes();
|
||||
final isVoiceBusy = connector.isVoiceSending;
|
||||
final voiceSupported = Platform.isAndroid || Platform.isIOS;
|
||||
final hasPendingVoice = _pendingVoiceMessage != null;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -236,93 +211,59 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
child: SafeArea(
|
||||
child: Row(
|
||||
children: [
|
||||
if (voiceSupported)
|
||||
IconButton(
|
||||
icon: Icon(_isRecordingVoice ? Icons.stop_circle : Icons.mic),
|
||||
onPressed: (isVoiceBusy || hasPendingVoice) ? null : () => _toggleVoiceRecording(connector),
|
||||
tooltip: _isRecordingVoice ? 'Stop recording' : 'Record voice',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.gif_box),
|
||||
onPressed: (_isRecordingVoice || isVoiceBusy || hasPendingVoice)
|
||||
? null
|
||||
: () => _showGifPicker(context),
|
||||
onPressed: () => _showGifPicker(context),
|
||||
tooltip: 'Send GIF',
|
||||
),
|
||||
Expanded(
|
||||
child: hasPendingVoice
|
||||
? _buildVoicePreview(colorScheme)
|
||||
: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: _textController,
|
||||
builder: (context, value, child) {
|
||||
final gifId = _parseGifId(value.text);
|
||||
if (gifId != null) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GifMessage(
|
||||
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
fallbackTextColor:
|
||||
colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
width: 160,
|
||||
height: 110,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => _textController.clear(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return TextField(
|
||||
controller: _textController,
|
||||
enabled: !_isRecordingVoice && !isVoiceBusy,
|
||||
inputFormatters: [
|
||||
Utf8LengthLimitingTextInputFormatter(maxBytes),
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Type a message...',
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: _textController,
|
||||
builder: (context, value, child) {
|
||||
final gifId = _parseGifId(value.text);
|
||||
if (gifId != null) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GifMessage(
|
||||
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
fallbackTextColor:
|
||||
colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
width: 160,
|
||||
height: 110,
|
||||
),
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_isRecordingVoice || isVoiceBusy)
|
||||
? null
|
||||
: (_) => _sendMessage(connector),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => _textController.clear(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return TextField(
|
||||
controller: _textController,
|
||||
inputFormatters: [
|
||||
Utf8LengthLimitingTextInputFormatter(maxBytes),
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Type a message...',
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendMessage(connector),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (isVoiceBusy)
|
||||
IconButton.filled(
|
||||
icon: const Icon(Icons.stop_circle),
|
||||
onPressed: () => _cancelVoiceSend(connector),
|
||||
tooltip: 'Cancel voice send',
|
||||
)
|
||||
else if (hasPendingVoice) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => _clearPendingVoicePreview(deleteFile: true),
|
||||
tooltip: 'Discard voice message',
|
||||
),
|
||||
IconButton.filled(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: () => _sendPendingVoice(connector),
|
||||
tooltip: 'Send voice message',
|
||||
),
|
||||
]
|
||||
else
|
||||
IconButton.filled(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: (_isRecordingVoice || isVoiceBusy)
|
||||
? null
|
||||
: () => _sendMessage(connector),
|
||||
),
|
||||
IconButton.filled(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: () => _sendMessage(connector),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -377,208 +318,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelVoiceSend(MeshCoreConnector connector) {
|
||||
connector.cancelVoiceSend();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Voice send canceled')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleVoiceRecording(MeshCoreConnector connector) async {
|
||||
if (_isRecordingVoice) {
|
||||
await _stopVoiceRecording(connector);
|
||||
} else {
|
||||
await _startVoiceRecording();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startVoiceRecording() async {
|
||||
if (_isRecordingVoice) return;
|
||||
final hasPermission = await _voiceRecorder.hasPermission();
|
||||
if (!hasPermission) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Microphone permission denied')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_voiceBuffer = BytesBuilder(copy: false);
|
||||
try {
|
||||
final stream = await _voiceRecorder.startStream(
|
||||
const RecordConfig(
|
||||
encoder: AudioEncoder.pcm16bits,
|
||||
sampleRate: VoiceMessageService.sampleRate,
|
||||
numChannels: VoiceMessageService.channels,
|
||||
),
|
||||
);
|
||||
_voiceStreamSubscription = stream.listen((data) {
|
||||
_voiceBuffer.add(data);
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to start recording: $e')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
_voiceRecordTimer?.cancel();
|
||||
_voiceRecordTimer = Timer(
|
||||
const Duration(seconds: VoiceMessageService.maxRecordSeconds),
|
||||
() => _stopVoiceRecording(context.read<MeshCoreConnector>()),
|
||||
);
|
||||
setState(() {
|
||||
_isRecordingVoice = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _stopVoiceRecording(MeshCoreConnector connector) async {
|
||||
if (!_isRecordingVoice) return;
|
||||
_voiceRecordTimer?.cancel();
|
||||
await _voiceRecorder.stop();
|
||||
await _voiceStreamSubscription?.cancel();
|
||||
_voiceStreamSubscription = null;
|
||||
final pcmBytes = _voiceBuffer.takeBytes();
|
||||
setState(() {
|
||||
_isRecordingVoice = false;
|
||||
});
|
||||
if (pcmBytes.isEmpty) return;
|
||||
await _prepareVoicePreview(connector, pcmBytes);
|
||||
}
|
||||
|
||||
Future<void> _prepareVoicePreview(MeshCoreConnector connector, Uint8List pcmBytes) async {
|
||||
final voiceService = VoiceMessageService.instance;
|
||||
try {
|
||||
final codec2Bytes = voiceService.encodePcmToCodec2(pcmBytes);
|
||||
if (codec2Bytes.isEmpty) return;
|
||||
final timestampSeconds = connector.reserveVoiceTimestampSeconds();
|
||||
final durationMs = voiceService.durationMsForCodec2Bytes(codec2Bytes);
|
||||
final decodedPcm = voiceService.decodeCodec2ToPcm(codec2Bytes);
|
||||
final fileName = voiceService.buildVoiceFileName(
|
||||
senderKeyHex: widget.contact.publicKeyHex,
|
||||
timestampSeconds: timestampSeconds,
|
||||
outgoing: true,
|
||||
);
|
||||
final voicePath = await voiceService.writeWavFile(
|
||||
pcmBytes: decodedPcm,
|
||||
fileName: fileName,
|
||||
);
|
||||
|
||||
final previewMessage = Message(
|
||||
senderKey: widget.contact.publicKey,
|
||||
text: 'Voice message',
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampSeconds * 1000),
|
||||
isOutgoing: true,
|
||||
isCli: false,
|
||||
status: MessageStatus.pending,
|
||||
isVoice: true,
|
||||
voicePath: voicePath,
|
||||
voiceDurationMs: durationMs,
|
||||
voiceCodec: VoiceMessageService.codecName,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_pendingVoiceMessage = previewMessage;
|
||||
_pendingVoiceCodec2Bytes = codec2Bytes;
|
||||
_pendingVoiceTimestampSeconds = timestampSeconds;
|
||||
_pendingVoiceDurationMs = durationMs;
|
||||
_pendingVoicePath = voicePath;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Voice message failed: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildVoicePreview(ColorScheme colorScheme) {
|
||||
final message = _pendingVoiceMessage;
|
||||
if (message == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: VoiceMessageBubble(
|
||||
message: message,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
textColor: colorScheme.onSurface,
|
||||
metaColor: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
isOutgoing: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sendPendingVoice(MeshCoreConnector connector) async {
|
||||
final codec2Bytes = _pendingVoiceCodec2Bytes;
|
||||
final voicePath = _pendingVoicePath;
|
||||
final durationMs = _pendingVoiceDurationMs;
|
||||
final timestampSeconds = _pendingVoiceTimestampSeconds;
|
||||
|
||||
if (codec2Bytes == null ||
|
||||
codec2Bytes.isEmpty ||
|
||||
voicePath == null ||
|
||||
voicePath.isEmpty ||
|
||||
durationMs == null ||
|
||||
timestampSeconds == null) {
|
||||
return;
|
||||
}
|
||||
if (!connector.isConnected) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Not connected to a MeshCore device')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (connector.isVoiceSending) {
|
||||
return;
|
||||
}
|
||||
|
||||
await connector.sendVoiceMessage(
|
||||
contact: widget.contact,
|
||||
codec2Bytes: codec2Bytes,
|
||||
voicePath: voicePath,
|
||||
durationMs: durationMs,
|
||||
timestampSeconds: timestampSeconds,
|
||||
);
|
||||
unawaited(_clearPendingVoicePreview(deleteFile: false));
|
||||
}
|
||||
|
||||
Future<void> _clearPendingVoicePreview({required bool deleteFile, bool notify = true}) async {
|
||||
final path = _pendingVoicePath;
|
||||
if (notify && mounted) {
|
||||
setState(() {
|
||||
_pendingVoiceMessage = null;
|
||||
_pendingVoiceCodec2Bytes = null;
|
||||
_pendingVoiceTimestampSeconds = null;
|
||||
_pendingVoiceDurationMs = null;
|
||||
_pendingVoicePath = null;
|
||||
});
|
||||
} else {
|
||||
_pendingVoiceMessage = null;
|
||||
_pendingVoiceCodec2Bytes = null;
|
||||
_pendingVoiceTimestampSeconds = null;
|
||||
_pendingVoiceDurationMs = null;
|
||||
_pendingVoicePath = null;
|
||||
}
|
||||
if (deleteFile && path != null && path.isNotEmpty) {
|
||||
try {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showPathHistory(BuildContext context) {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
@@ -1279,15 +1018,14 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!message.isVoice)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.copy),
|
||||
title: const Text('Copy'),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_copyMessageText(message.text);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.copy),
|
||||
title: const Text('Copy'),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_copyMessageText(message.text);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline),
|
||||
title: const Text('Delete'),
|
||||
@@ -1297,8 +1035,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
},
|
||||
),
|
||||
if (message.isOutgoing &&
|
||||
message.status == MessageStatus.failed &&
|
||||
!message.isVoice)
|
||||
message.status == MessageStatus.failed)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.refresh),
|
||||
title: const Text('Retry'),
|
||||
@@ -1412,15 +1149,7 @@ class _MessageBubble extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
if (message.isVoice)
|
||||
VoiceMessageBubble(
|
||||
message: message,
|
||||
backgroundColor: bubbleColor,
|
||||
textColor: textColor,
|
||||
metaColor: metaColor,
|
||||
isOutgoing: isOutgoing,
|
||||
)
|
||||
else if (poi != null)
|
||||
if (poi != null)
|
||||
_buildPoiMessage(context, poi, textColor, metaColor)
|
||||
else if (gifId != null)
|
||||
GifMessage(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -7,8 +9,11 @@ import '../models/contact.dart';
|
||||
import '../models/contact_group.dart';
|
||||
import '../storage/contact_group_store.dart';
|
||||
import '../utils/contact_search.dart';
|
||||
import '../utils/dialog_utils.dart';
|
||||
import '../utils/disconnect_navigation_mixin.dart';
|
||||
import '../utils/emoji_utils.dart';
|
||||
import '../utils/route_transitions.dart';
|
||||
import '../widgets/empty_state.dart';
|
||||
import '../widgets/quick_switch_bar.dart';
|
||||
import '../widgets/repeater_login_dialog.dart';
|
||||
import '../widgets/unread_badge.dart';
|
||||
@@ -46,7 +51,8 @@ class ContactsScreen extends StatefulWidget {
|
||||
State<ContactsScreen> createState() => _ContactsScreenState();
|
||||
}
|
||||
|
||||
class _ContactsScreenState extends State<ContactsScreen> {
|
||||
class _ContactsScreenState extends State<ContactsScreen>
|
||||
with DisconnectNavigationMixin {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
ContactSortOption _sortOption = ContactSortOption.lastSeen;
|
||||
@@ -54,6 +60,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
bool _showUnreadOnly = false;
|
||||
final ContactGroupStore _groupStore = ContactGroupStore();
|
||||
List<ContactGroup> _groups = [];
|
||||
Timer? _searchDebounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -63,6 +70,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchDebounce?.cancel();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -82,16 +90,13 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final allowBack = !connector.isConnected;
|
||||
|
||||
if (!connector.isConnected) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) {
|
||||
Navigator.popUntil(context, (route) => route.isFirst);
|
||||
}
|
||||
});
|
||||
// Auto-navigate back to scanner if disconnected
|
||||
if (!checkConnectionAndNavigate(connector)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final allowBack = !connector.isConnected;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return PopScope(
|
||||
@@ -245,27 +250,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Disconnect'),
|
||||
content: const Text('Are you sure you want to disconnect from this device?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Disconnect'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await connector.disconnect();
|
||||
}
|
||||
await showDisconnectDialog(context, connector);
|
||||
}
|
||||
|
||||
Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) {
|
||||
@@ -276,23 +261,10 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
}
|
||||
|
||||
if (contacts.isEmpty && _groups.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.people_outline, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No contacts yet',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Contacts will appear when devices advertise',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||
),
|
||||
],
|
||||
),
|
||||
return const EmptyState(
|
||||
icon: Icons.people_outline,
|
||||
title: 'No contacts yet',
|
||||
subtitle: 'Contacts will appear when devices advertise',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -326,8 +298,12 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_searchQuery = value.toLowerCase();
|
||||
_searchDebounce?.cancel();
|
||||
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_searchQuery = value.toLowerCase();
|
||||
});
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../utils/dialog_utils.dart';
|
||||
import '../utils/disconnect_navigation_mixin.dart';
|
||||
import '../utils/route_transitions.dart';
|
||||
import '../widgets/quick_switch_bar.dart';
|
||||
import 'channels_screen.dart';
|
||||
@@ -17,7 +19,8 @@ class DeviceScreen extends StatefulWidget {
|
||||
State<DeviceScreen> createState() => _DeviceScreenState();
|
||||
}
|
||||
|
||||
class _DeviceScreenState extends State<DeviceScreen> {
|
||||
class _DeviceScreenState extends State<DeviceScreen>
|
||||
with DisconnectNavigationMixin {
|
||||
bool _showBatteryVoltage = false;
|
||||
int _quickIndex = 0;
|
||||
|
||||
@@ -25,13 +28,9 @@ class _DeviceScreenState extends State<DeviceScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, child) {
|
||||
// If disconnected, pop back to scanner
|
||||
if (!connector.isConnected) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) {
|
||||
Navigator.popUntil(context, (route) => route.isFirst);
|
||||
}
|
||||
});
|
||||
// Auto-navigate back to scanner if disconnected
|
||||
if (!checkConnectionAndNavigate(connector)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final theme = Theme.of(context);
|
||||
@@ -286,26 +285,6 @@ class _DeviceScreenState extends State<DeviceScreen> {
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Disconnect'),
|
||||
content: const Text('Are you sure you want to disconnect from this device?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Disconnect'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await connector.disconnect();
|
||||
}
|
||||
await showDisconnectDialog(context, connector);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user