feat: add Codec2 voice messages over LoRa mesh (iOS/macOS only)

Push-to-talk voice messaging using the Codec2 ultra-low-bitrate speech
codec, transmitted as V: prefixed packets over the existing MeshCore
LoRa mesh pipeline.

Voice recording UI (long-press send button or + menu) is gated behind
Platform.isIOS || Platform.isMacOS since the `record` package only
supports microphone capture on those platforms in this build.

Key changes:
- VoiceRecorderService: streams 8kHz mono PCM chunks via `record` package
- VoicePlayerService: decodes Codec2 bytes to WAV and plays via audioplayers
- VoiceCodecService: async Codec2 encode/decode in background isolates
- VoiceProvider: reassembles multi-packet sessions, drives playback
- VoiceMessageBubble: shows packet progress, play/stop controls
- MessagesProvider: detects V: prefix, routes to VoiceProvider
- MessagesTab: PTT long-press gesture + recording indicator (iOS/macOS)
- Message model: isVoice + voiceId fields for session tracking
- Auto-selects codec mode from radio bandwidth (700C/1200/1300 bps)
- codec2_flutter + meshcore_client switched from path to git deps
This commit is contained in:
Janez T
2026-02-28 19:19:18 +01:00
parent 3e5b2ac0de
commit f96d8022ac
27 changed files with 1509 additions and 81 deletions

View File

@@ -13,10 +13,10 @@ Flutter app for Search and Rescue operations over a [MeshCore](https://github.co
## BLE Protocol
The BLE communication layer lives in a separate reusable package:
MeshCore SAR is powered by the `meshcore_client` package:
**[github.com/dz0ny/meshcore_client](https://github.com/dz0ny/meshcore_client)**
It handles connection management, command queueing, and binary frame parsing/building.
The client handles BLE connection management, command queueing, and binary frame parsing/building, and is extracted so you can build custom tools, integrations, and apps on top of MeshCore.
## Requirements

View File

@@ -21,6 +21,9 @@
<!-- Network Service Discovery (Bonjour/mDNS) -->
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<!-- Microphone for voice messages -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Notifications -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />

View File

@@ -1,4 +1,9 @@
PODS:
- audioplayers_darwin (0.0.1):
- Flutter
- FlutterMacOS
- codec2_flutter (0.1.0):
- Flutter
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.9):
@@ -61,6 +66,8 @@ PODS:
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- record_ios (1.2.0):
- Flutter
- SDWebImage (5.21.7):
- SDWebImage/Core (= 5.21.7)
- SDWebImage/Core (5.21.7)
@@ -76,6 +83,8 @@ PODS:
- Flutter
DEPENDENCIES:
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
- codec2_flutter (from `.symlinks/plugins/codec2_flutter/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`)
@@ -89,6 +98,7 @@ DEPENDENCIES:
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- record_ios (from `.symlinks/plugins/record_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
@@ -103,6 +113,10 @@ SPEC REPOS:
- SwiftyGif
EXTERNAL SOURCES:
audioplayers_darwin:
:path: ".symlinks/plugins/audioplayers_darwin/darwin"
codec2_flutter:
:path: ".symlinks/plugins/codec2_flutter/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
@@ -129,6 +143,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
record_ios:
:path: ".symlinks/plugins/record_ios/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
@@ -139,6 +155,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/vibration/ios"
SPEC CHECKSUMS:
audioplayers_darwin: 4f9ca89d92d3d21cec7ec580e78ca888e5fb68bd
codec2_flutter: 15e24fa897d9d903a2afb1cc5a17ae3ac88b6d6f
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
@@ -155,6 +173,7 @@ SPEC CHECKSUMS:
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb

View File

@@ -66,6 +66,8 @@
<string>MeshCore SAR needs location access to display team members and SAR markers on the map</string>
<key>NSMotionUsageDescription</key>
<string>MeshCore SAR needs access to the compass to show your heading direction on the map</string>
<key>NSMicrophoneUsageDescription</key>
<string>MeshCore SAR needs microphone access to send voice messages over the mesh radio network during SAR operations</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>MeshCore SAR may need access to your photo library to attach images to messages or save map screenshots for documentation during SAR operations</string>
<key>UIApplicationSceneManifest</key>

View File

@@ -11,7 +11,10 @@ import 'providers/messages_provider.dart';
import 'providers/map_provider.dart';
import 'providers/drawing_provider.dart';
import 'providers/channels_provider.dart';
import 'providers/voice_provider.dart';
import 'providers/app_provider.dart';
import 'services/voice_codec_service.dart';
import 'services/voice_player_service.dart';
import 'services/tile_cache_service.dart';
import 'services/notification_service.dart';
import 'services/locale_preferences.dart';
@@ -231,10 +234,19 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
),
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
// Voice provider (packet reassembly + playback)
ChangeNotifierProvider(
create: (_) => VoiceProvider(
codec: VoiceCodecService(),
player: VoicePlayerService(),
),
),
// Tile cache service
Provider(create: (_) => TileCacheService()),
// App provider that coordinates everything
// VoiceProvider is read via context.read inside create since it's already registered above
ChangeNotifierProxyProvider6<
ConnectionProvider,
ContactsProvider,
@@ -250,6 +262,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
messagesProvider: context.read<MessagesProvider>(),
drawingProvider: context.read<DrawingProvider>(),
channelsProvider: context.read<ChannelsProvider>(),
voiceProvider: context.read<VoiceProvider>(),
tileCacheService: context.read<TileCacheService>(),
),
update:
@@ -270,6 +283,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
messagesProvider: messages,
drawingProvider: drawings,
channelsProvider: channels,
voiceProvider: context.read<VoiceProvider>(),
tileCacheService: tileCache,
),
),

View File

@@ -9,6 +9,20 @@ export 'package:meshcore_client/meshcore_client.dart'
import 'package:flutter/foundation.dart';
import 'package:meshcore_client/meshcore_client.dart';
import 'sar_marker.dart';
import '../utils/voice_message_parser.dart';
extension MessageVoiceExtension on Message {
/// True when this message is a voice recording (`V:` text or binary voice).
bool get isVoiceMessage => isVoice;
/// Parses the voice packet mode from the stored [voiceId] session info.
/// Returns null for non-voice messages.
VoicePacketMode? get voicePacketMode {
if (!isVoice || text.isEmpty) return null;
final pkt = VoicePacket.tryParseText(text);
return pkt?.mode;
}
}
extension MessageSarExtension on Message {
/// Infer the [SarMarkerType] from stored SAR fields.

View File

@@ -6,11 +6,13 @@ import 'contacts_provider.dart';
import 'messages_provider.dart';
import 'drawing_provider.dart';
import 'channels_provider.dart';
import 'voice_provider.dart';
import '../services/tile_cache_service.dart';
import '../services/location_tracking_service.dart';
import '../models/contact.dart';
import '../models/message.dart';
import '../utils/drawing_message_parser.dart';
import '../utils/voice_message_parser.dart';
/// Main App Provider - coordinates all other providers
class AppProvider with ChangeNotifier {
@@ -19,6 +21,7 @@ class AppProvider with ChangeNotifier {
final MessagesProvider messagesProvider;
final DrawingProvider drawingProvider;
final ChannelsProvider channelsProvider;
final VoiceProvider voiceProvider;
final TileCacheService tileCacheService;
final LocationTrackingService locationTrackingService =
LocationTrackingService();
@@ -38,6 +41,7 @@ class AppProvider with ChangeNotifier {
required this.messagesProvider,
required this.drawingProvider,
required this.channelsProvider,
required this.voiceProvider,
required this.tileCacheService,
}) {
_setupCallbacks();
@@ -335,6 +339,19 @@ class AppProvider with ChangeNotifier {
return;
}
// If it's a text-format voice packet, feed it to VoiceProvider
if (VoicePacket.isVoiceText(enrichedMessage.text)) {
final pkt = VoicePacket.tryParseText(enrichedMessage.text);
if (pkt != null) {
voiceProvider.addPacket(pkt);
// Mark the message with voice metadata before adding to chat
enrichedMessage = enrichedMessage.copyWith(
isVoice: true,
voiceId: pkt.sessionId,
);
}
}
// Pass contact lookup function to link channel messages with contacts
messagesProvider.addMessage(
enrichedMessage,
@@ -380,6 +397,18 @@ class AppProvider with ChangeNotifier {
contactsProvider.updateTelemetry(publicKeyPrefix, responseData);
};
// When raw binary data is received (PUSH_CODE_RAW_DATA 0x84)
// Used for direct binary voice packets (VoicePacket binary format, magic 0x56 'V')
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
if (!VoicePacket.isVoiceBinary(payload)) return;
final pkt = VoicePacket.tryParseBinary(payload);
if (pkt == null) return;
debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt');
final justComplete = voiceProvider.addPacket(pkt);
// Insert or update the placeholder message in the chat list
_handleIncomingVoicePacket(pkt, justComplete: justComplete);
};
// When a contact's routing path is updated in the mesh network
connectionProvider.onPathUpdated = (publicKey) {
debugPrint(
@@ -693,6 +722,41 @@ class AppProvider with ChangeNotifier {
}
}
/// Insert or update a voice placeholder message for binary raw-data packets.
///
/// Binary voice packets arrive without a chat message, so we synthesise one
/// to give the user a playable bubble in the message list.
void _handleIncomingVoicePacket(VoicePacket pkt, {required bool justComplete}) {
final sessionId = pkt.sessionId;
// Check if a placeholder for this session already exists
final existing = messagesProvider.messages.where(
(m) => m.isVoice && m.voiceId == sessionId,
).firstOrNull;
if (existing != null) {
// Already have a placeholder — no need to add another
return;
}
// First packet of a new session: insert placeholder message
final msgId = 'voice_$sessionId';
final placeholder = Message(
id: msgId,
messageType: MessageType.contact, // direct contact (binary only)
senderPublicKeyPrefix: null,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
text: '', // no text — displayed as VoiceMessageBubble
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.received,
isVoice: true,
voiceId: sessionId,
);
messagesProvider.addMessage(placeholder, contactLookup: (_) => '');
}
// Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events
// The ConnectionProvider's onMessageWaiting callback handles automatic message fetching

View File

@@ -167,6 +167,7 @@ class ConnectionProvider with ChangeNotifier {
Function(String messageId, int echoCount, int snrRaw, int rssiDbm)?
onMessageEchoDetected;
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived;
// Track pending send operations for auto-recovery
final Map<String, _PendingSendOperation> _pendingSendOperations = {};
@@ -498,6 +499,10 @@ class ConnectionProvider with ChangeNotifier {
onStatusResponse?.call(publicKeyPrefix, statusData);
};
_bleService.onRawDataReceived = (payload, snrRaw, rssiDbm) {
onRawDataReceived?.call(payload, snrRaw, rssiDbm);
};
_bleService.onDeviceInfoReceived = (deviceInfo) {
debugPrint('📥 [Provider] Received DeviceInfo:');
debugPrint(' Firmware Version: ${deviceInfo['firmwareVersion']}');
@@ -1318,6 +1323,21 @@ class ConnectionProvider with ChangeNotifier {
}
}
/// Send a raw binary voice packet directly to a contact (cmdSendRawData, code 25).
/// Only works for contacts with a known direct route (outPathLen >= 0).
Future<void> sendRawVoicePacket({
required Uint8List contactPath,
required int contactPathLen,
required Uint8List payload,
}) async {
if (!_bleService.isConnected) return;
await _bleService.sendRawVoicePacket(
contactPathLen: contactPathLen,
contactPath: contactPath,
payload: payload,
);
}
/// Request telemetry from contact
///
/// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39).

View File

@@ -8,6 +8,7 @@ import '../services/message_storage_service.dart';
import '../services/notification_service.dart';
import '../utils/sar_message_parser.dart';
import '../utils/drawing_message_parser.dart';
import '../utils/voice_message_parser.dart';
import '../l10n/app_localizations.dart';
import 'helpers/message_retry_manager.dart';
@@ -277,6 +278,17 @@ class MessagesProvider with ChangeNotifier {
);
}
// Check if it's a voice message (V:...) and not already marked
if (VoicePacket.isVoiceText(enhancedMessage.text) && !enhancedMessage.isVoice) {
final pkt = VoicePacket.tryParseText(enhancedMessage.text);
if (pkt != null) {
enhancedMessage = enhancedMessage.copyWith(
isVoice: true,
voiceId: pkt.sessionId,
);
}
}
// For channel messages with sender name, try to link with contact
Message finalMessage = enhancedMessage;
if (enhancedMessage.isChannelMessage &&

View File

@@ -0,0 +1,121 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../utils/voice_message_parser.dart';
import '../services/voice_codec_service.dart';
import '../services/voice_player_service.dart';
/// Reassembly state for one voice session.
class VoiceSession {
final String sessionId;
final VoicePacketMode mode;
final int total;
final List<VoicePacket?> packets; // indexed by packet.index
VoiceSession({
required this.sessionId,
required this.mode,
required this.total,
}) : packets = List.filled(total, null);
int get receivedCount => packets.where((p) => p != null).length;
bool get isComplete => receivedCount == total;
/// Total estimated audio duration in seconds (sum of all received packets).
double get estimatedDurationSeconds {
var ms = 0;
for (final p in packets) {
if (p != null) ms += p.durationMs;
}
return ms / 1000.0;
}
}
/// Manages incoming voice packet sessions and coordinates playback.
class VoiceProvider with ChangeNotifier {
final VoiceCodecService _codec;
final VoicePlayerService _player;
/// Active sessions keyed by sessionId.
final Map<String, VoiceSession> _sessions = {};
/// Currently playing session ID, or null.
String? _playingSessionId;
VoiceProvider({
required VoiceCodecService codec,
required VoicePlayerService player,
}) : _codec = codec,
_player = player;
// ── Session accessors ────────────────────────────────────────────────────
VoiceSession? session(String sessionId) => _sessions[sessionId];
bool isComplete(String sessionId) => _sessions[sessionId]?.isComplete ?? false;
bool isPlaying(String sessionId) =>
_playingSessionId == sessionId && _player.isPlaying;
// ── Packet reception ─────────────────────────────────────────────────────
/// Add an incoming [packet] to its session. Creates the session on first packet.
/// Returns true if the session just became complete.
bool addPacket(VoicePacket packet) {
_sessions.putIfAbsent(
packet.sessionId,
() => VoiceSession(
sessionId: packet.sessionId,
mode: packet.mode,
total: packet.total,
),
);
final session = _sessions[packet.sessionId]!;
if (packet.index < session.total) {
session.packets[packet.index] = packet;
}
final justComplete = session.isComplete;
notifyListeners();
return justComplete;
}
// ── Playback ─────────────────────────────────────────────────────────────
/// Decode and play the voice session with [sessionId].
/// Plays whatever packets are available (handles partial reception gracefully).
Future<void> play(String sessionId) async {
final session = _sessions[sessionId];
if (session == null) {
debugPrint('❌ [VoiceProvider] play($sessionId) — session not found, known: ${_sessions.keys.toList()}');
return;
}
debugPrint('🎙️ [VoiceProvider] play($sessionId): ${session.receivedCount}/${session.total} packets, mode=${session.mode.label}');
try {
final pcm = await _codec.decodePackets(session.packets, session.mode);
debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples');
_playingSessionId = sessionId;
notifyListeners();
await _player.play(pcm);
} catch (e, st) {
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
} finally {
if (_playingSessionId == sessionId) {
_playingSessionId = null;
notifyListeners();
}
}
}
Future<void> stop() async {
await _player.stop();
_playingSessionId = null;
notifyListeners();
}
@override
void dispose() {
_player.dispose();
super.dispose();
}
}

View File

@@ -1,4 +1,7 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
@@ -8,6 +11,7 @@ import '../providers/contacts_provider.dart';
import '../providers/map_provider.dart';
import '../providers/connection_provider.dart';
import '../providers/drawing_provider.dart';
import '../providers/voice_provider.dart';
import '../providers/app_provider.dart';
import '../models/message.dart';
import '../models/contact.dart';
@@ -15,8 +19,11 @@ import '../widgets/messages/sar_update_sheet.dart';
import '../widgets/messages/recipient_selector_sheet.dart';
import '../widgets/messages/message_bubble.dart';
import '../services/message_destination_preferences.dart';
import '../services/voice_recorder_service.dart';
import '../services/voice_codec_service.dart';
import '../utils/toast_logger.dart';
import '../utils/key_comparison.dart';
import '../utils/voice_message_parser.dart';
import '../l10n/app_localizations.dart';
class MessagesTab extends StatefulWidget {
@@ -42,6 +49,17 @@ class _MessagesTabState extends State<MessagesTab> {
MessageDestinationPreferences.destinationTypeChannel;
Contact? _selectedRecipient;
// Voice recording state
final VoiceRecorderService _voiceRecorder = VoiceRecorderService();
bool _isRecording = false;
bool _isSendingVoice = false;
static const int _maxVoicePackets = 10;
bool get _voiceSupported => Platform.isIOS || Platform.isMacOS;
StreamSubscription<Int16List>? _voiceStreamSub;
String? _currentVoiceSessionId;
final List<Int16List> _recordedChunks = [];
VoicePacketMode? _activeVoiceMode;
@override
void initState() {
super.initState();
@@ -68,6 +86,8 @@ class _MessagesTabState extends State<MessagesTab> {
@override
void dispose() {
_highlightTimer?.cancel();
_voiceStreamSub?.cancel();
_voiceRecorder.dispose();
_textController.dispose();
_focusNode.dispose();
_scrollController.dispose();
@@ -231,7 +251,8 @@ class _MessagesTabState extends State<MessagesTab> {
/// Get tooltip for destination button
String _getDestinationTooltip() {
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel && _selectedRecipient != null) {
MessageDestinationPreferences.destinationTypeChannel &&
_selectedRecipient != null) {
final channelName = _selectedRecipient!.getLocalizedDisplayName(context);
return '$channelName (tap to change)';
} else if (_selectedRecipient != null) {
@@ -260,8 +281,15 @@ class _MessagesTabState extends State<MessagesTab> {
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel) {
// Send to selected channel (or public channel if none selected)
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0; // Extract channel index from pseudo public key
await _sendToChannel(text, connectionProvider, messagesProvider, channelIdx);
final channelIdx =
_selectedRecipient?.publicKey[1] ??
0; // Extract channel index from pseudo public key
await _sendToChannel(
text,
connectionProvider,
messagesProvider,
channelIdx,
);
} else if (_selectedRecipient != null) {
// Send to contact or room
await _sendToRecipient(
@@ -376,6 +404,218 @@ class _MessagesTabState extends State<MessagesTab> {
}
}
// ── Voice recording ────────────────────────────────────────────────────────
Future<void> _startVoiceRecording() async {
if (_isSendingVoice || _isRecording) return;
debugPrint('🎙️ [Voice] _startVoiceRecording called');
final hasPermission = await _voiceRecorder.requestPermission();
debugPrint('🎙️ [Voice] hasPermission=$hasPermission');
if (!hasPermission) {
if (!mounted) return;
ToastLogger.error(context, 'Microphone permission required for voice');
return;
}
if (!mounted) return;
final connectionProvider = context.read<ConnectionProvider>();
debugPrint(
'🎙️ [Voice] isConnected=${connectionProvider.deviceInfo.isConnected}',
);
if (!connectionProvider.deviceInfo.isConnected) {
ToastLogger.error(context, 'Not connected to device');
return;
}
// Generate a new session ID (4 random bytes → 8 hex chars)
final rng = math.Random.secure();
_currentVoiceSessionId = List.generate(
4,
(_) => rng.nextInt(256),
).map((b) => b.toRadixString(16).padLeft(2, '0')).join();
final radioBwKhz = connectionProvider.deviceInfo.radioBw ?? 125;
_activeVoiceMode = voiceModeForBandwidth(radioBwKhz * 1000);
final packetDuration = Duration(
milliseconds: codec2ModeFor(_activeVoiceMode!).packetDurationMs,
);
debugPrint(
'🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms',
);
_recordedChunks.clear();
setState(() => _isRecording = true);
try {
final stream = _voiceRecorder.startCapture(chunkDuration: packetDuration);
debugPrint('🎙️ [Voice] capture started, listening for chunks...');
_voiceStreamSub = stream.listen(
(pcmChunk) {
if (!_isRecording) return;
_recordedChunks.add(pcmChunk);
debugPrint(
'🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples',
);
setState(() {});
if (_recordedChunks.length >= _maxVoicePackets) {
debugPrint('🎙️ [Voice] max packets reached, stopping');
_stopAndSendVoice();
}
},
onError: (e) {
debugPrint('❌ [Voice] stream error: $e');
_stopAndSendVoice();
},
onDone: () => debugPrint('🎙️ [Voice] stream done'),
);
} catch (e) {
debugPrint('❌ [Voice] startCapture threw: $e');
if (mounted) setState(() => _isRecording = false);
}
}
Future<void> _stopAndSendVoice() async {
if (!_isRecording) return;
debugPrint(
'🎙️ [Voice] _stopAndSendVoice: ${_recordedChunks.length} chunks buffered',
);
await _voiceStreamSub?.cancel();
_voiceStreamSub = null;
await _voiceRecorder.stopCapture();
final chunks = List<Int16List>.from(_recordedChunks);
final sessionId = _currentVoiceSessionId;
final mode = _activeVoiceMode;
_recordedChunks.clear();
if (mounted) {
setState(() {
_isRecording = false;
_isSendingVoice = chunks.isNotEmpty && sessionId != null;
});
}
if (chunks.isEmpty || sessionId == null || mode == null || !mounted) {
if (mounted) setState(() { _isSendingVoice = false; _currentVoiceSessionId = null; });
return;
}
try {
await _encodeAndSendAllPackets(
chunks: chunks,
sessionId: sessionId,
mode: mode,
);
} catch (e, st) {
debugPrint('❌ [Voice] _encodeAndSendAllPackets threw: $e\n$st');
} finally {
debugPrint('🎙️ [Voice] _stopAndSendVoice finally: resetting _isSendingVoice');
if (mounted) {
setState(() {
_isSendingVoice = false;
_currentVoiceSessionId = null;
});
}
}
}
Future<void> _encodeAndSendAllPackets({
required List<Int16List> chunks,
required String sessionId,
required VoicePacketMode mode,
}) async {
final total = chunks.length;
final codec = VoiceCodecService();
final connectionProvider = context.read<ConnectionProvider>();
final messagesProvider = context.read<MessagesProvider>();
final voiceProvider = context.read<VoiceProvider>();
// Insert the chat placeholder before sending (so it appears immediately)
final msgId = 'voice_${sessionId}_sent';
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6);
final isChannel =
_destinationType ==
MessageDestinationPreferences.destinationTypeChannel;
final sentMsg = Message(
id: msgId,
messageType: (!isChannel && _selectedRecipient != null)
? MessageType.contact
: MessageType.channel,
senderPublicKeyPrefix: senderPublicKeyPrefix,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
text: '',
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sent,
isVoice: true,
voiceId: sessionId,
channelIdx: isChannel ? (_selectedRecipient?.publicKey[1] ?? 0) : null,
recipientPublicKey: _selectedRecipient?.publicKey,
);
messagesProvider.addSentMessage(sentMsg);
debugPrint(
'🎙️ [Voice] encoding+sending $total packets, mode=${mode.label}, session=$sessionId',
);
for (var i = 0; i < total; i++) {
if (!mounted) return;
try {
final codec2Data = await codec.encode(chunks[i], mode);
debugPrint(
'🎙️ [Voice] packet $i/$total encoded: ${codec2Data.length} bytes',
);
final packet = VoicePacket(
sessionId: sessionId,
mode: mode,
index: i,
total: total,
codec2Data: codec2Data,
);
voiceProvider.addPacket(packet);
if (!isChannel &&
_selectedRecipient != null &&
_selectedRecipient!.outPathLen >= 0) {
debugPrint(
'🎙️ [Voice] packet $i → binary (raw data), pathLen=${_selectedRecipient!.outPathLen}',
);
await connectionProvider.sendRawVoicePacket(
contactPath: _selectedRecipient!.outPath,
contactPathLen: _selectedRecipient!.outPathLen,
payload: packet.encodeBinary(),
);
} else {
final channelIdx = isChannel
? (_selectedRecipient?.publicKey[1] ?? 0)
: 0;
final text = packet.encodeText();
debugPrint(
'🎙️ [Voice] packet $i → text ch=$channelIdx len=${text.length}: $text',
);
await connectionProvider.sendChannelMessage(
channelIdx: channelIdx,
text: text,
);
}
debugPrint('🎙️ [Voice] packet $i sent ok');
} catch (e, st) {
debugPrint('❌ [Voice] packet $i send error: $e\n$st');
}
}
debugPrint('🎙️ [Voice] all packets sent for session $sessionId');
// Mark the placeholder message as "sent" (ackTag=0, timeout=0 = no ACK tracking).
// addSentMessage() forces deliveryStatus.sending; we upgrade it here so the
// bubble shows "Sent" instead of "Sending" once all packets are on the wire.
messagesProvider.markMessageSent(msgId, 0, 0);
}
// ── SAR dialog ─────────────────────────────────────────────────────────────
void _showSarDialog() {
showModalBottomSheet(
context: context,
@@ -406,6 +646,45 @@ class _MessagesTabState extends State<MessagesTab> {
);
}
void _showComposerActions() {
showModalBottomSheet(
context: context,
builder: (sheetContext) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.add_location_alt),
title: Text(AppLocalizations.of(context)!.sendSarMarker),
onTap: () {
Navigator.pop(sheetContext);
_showSarDialog();
},
),
if (_voiceSupported)
ListTile(
enabled: !_isSendingVoice,
leading: Icon(_isRecording ? Icons.stop : Icons.mic),
title: Text(_isRecording ? 'Stop recording' : 'Record voice'),
onTap: _isSendingVoice
? null
: () {
Navigator.pop(sheetContext);
if (_isRecording) {
_stopAndSendVoice();
} else {
_startVoiceRecording();
}
},
),
],
),
);
},
);
}
Future<void> _sendSarMessage(
String emoji,
String name,
@@ -426,7 +705,10 @@ class _MessagesTabState extends State<MessagesTab> {
if (!sendToChannel && !sendToAllContacts && roomPublicKey == null) {
if (!mounted) return;
ToastLogger.error(context, 'Please select a destination to send SAR marker');
ToastLogger.error(
context,
'Please select a destination to send SAR marker',
);
return;
}
@@ -443,7 +725,10 @@ class _MessagesTabState extends State<MessagesTab> {
if (chatContacts.isEmpty) {
if (!mounted) return;
ToastLogger.error(context, AppLocalizations.of(context)!.noContactsAvailable);
ToastLogger.error(
context,
AppLocalizations.of(context)!.noContactsAvailable,
);
return;
}
@@ -788,9 +1073,12 @@ class _MessagesTabState extends State<MessagesTab> {
widget.onNavigateToMap?.call();
}
: widget.onNavigateToMap != null &&
message.isDrawing && message.drawingId != null
message.isDrawing &&
message.drawingId != null
? () {
debugPrint('🗺️ [MessagesTab] Drawing tapped! ID: ${message.drawingId}');
debugPrint(
'🗺️ [MessagesTab] Drawing tapped! ID: ${message.drawingId}',
);
final mapProvider = context
.read<MapProvider>();
final drawingProvider = context
@@ -823,18 +1111,20 @@ class _MessagesTabState extends State<MessagesTab> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// SAR quick action button
// Quick actions (+) button
IconButton(
icon: const Icon(Icons.add_location_alt),
tooltip: AppLocalizations.of(context)!.sendSarMarker,
onPressed: _showSarDialog,
icon: Icon(_isRecording ? Icons.stop : Icons.add),
tooltip: _isRecording ? 'Stop recording' : 'More actions',
onPressed: _isRecording
? _stopAndSendVoice
: _showComposerActions,
style: IconButton.styleFrom(
backgroundColor: Theme.of(
context,
).colorScheme.primaryContainer,
foregroundColor: Theme.of(
context,
).colorScheme.onPrimaryContainer,
foregroundColor: _isRecording
? Colors.red
: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 4),
@@ -890,18 +1180,52 @@ class _MessagesTabState extends State<MessagesTab> {
? Colors.orange
: Theme.of(context).textTheme.bodySmall?.color,
),
suffixIcon: IconButton(
icon: Icon(
Icons.send_rounded,
size: 22,
color: _textController.text.trim().isEmpty
? Theme.of(context).disabledColor
: Theme.of(context).colorScheme.primary,
suffixIcon: GestureDetector(
onLongPressStart: (_voiceSupported && !_isSendingVoice)
? (_) => _startVoiceRecording()
: null,
onLongPressEnd: (_voiceSupported && _isRecording)
? (_) => _stopAndSendVoice()
: null,
onLongPressCancel: (_voiceSupported && _isRecording)
? () => _stopAndSendVoice()
: null,
child: IconButton(
icon: _isSendingVoice
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Icon(
_isRecording
? Icons.mic
: Icons.send_rounded,
size: 22,
color: _isRecording
? Colors.red
: (_textController.text.trim().isEmpty
? Theme.of(context).disabledColor
: Theme.of(
context,
).colorScheme.primary),
),
onPressed:
_isRecording ||
_isSendingVoice ||
_textController.text.trim().isEmpty
? null
: _sendMessage,
tooltip: _isRecording
? 'Recording... release to send voice'
: (_isSendingVoice
? 'Sending voice...'
: _voiceSupported
? 'Send (long press to record voice)'
: 'Send'),
),
onPressed: _textController.text.trim().isEmpty
? null
: _sendMessage,
tooltip: 'Send',
),
),
textInputAction: TextInputAction.send,
@@ -917,4 +1241,3 @@ class _MessagesTabState extends State<MessagesTab> {
);
}
}

View File

@@ -0,0 +1,72 @@
import 'dart:typed_data';
import 'package:codec2_flutter/codec2_flutter.dart';
import '../utils/voice_message_parser.dart';
export 'package:codec2_flutter/codec2_flutter.dart' show Codec2Mode;
/// Maps [VoicePacketMode] to the [Codec2Mode] enum from the FFI plugin.
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
switch (pktMode) {
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;
case VoicePacketMode.mode2400: return Codec2Mode.mode2400;
}
}
/// Selects the [VoicePacketMode] best suited for a given LoRa radio bandwidth.
///
/// Call with [radioBandwidthHz] from the device's radio params
/// (e.g. 125000 for 125 kHz).
VoicePacketMode voiceModeForBandwidth(int radioBandwidthHz) {
if (radioBandwidthHz <= 62500) return VoicePacketMode.mode700c;
if (radioBandwidthHz <= 125000) return VoicePacketMode.mode1200;
return VoicePacketMode.mode1300;
}
/// High-level codec service that provides async Codec2 encode/decode
/// executed in a background isolate so the UI thread is never blocked.
class VoiceCodecService {
/// Encode [pcm] (Int16 samples, 8000 Hz mono) with [mode].
/// Returns the raw Codec2-encoded bytes.
Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) =>
Codec2.encodeInIsolate(pcm, codec2ModeFor(mode));
/// Decode [codec2Bytes] back to Int16 PCM (8000 Hz mono) with [mode].
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) =>
Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode));
/// Decode and concatenate multiple [packets] into a single PCM Int16List.
/// Packets with null/missing entries are substituted with silence.
Future<Int16List> decodePackets(
List<VoicePacket?> packets,
VoicePacketMode mode,
) async {
final c2Mode = codec2ModeFor(mode);
final c2 = Codec2.create(c2Mode);
final spf = c2.samplesPerFrame;
c2.destroy();
// Estimate total samples (use actual data or silence per missing packet)
final all = <Int16List>[];
for (final pkt in packets) {
if (pkt == null || pkt.codec2Data.isEmpty) {
// Silence for missing packet — duration approximated by mode
final silenceSamples = (codec2ModeFor(mode).framesPerSecond) * spf;
all.add(Int16List(silenceSamples));
} else {
final decoded = await Codec2.decodeInIsolate(pkt.codec2Data, c2Mode);
all.add(decoded);
}
}
final total = all.fold<int>(0, (sum, l) => sum + l.length);
final result = Int16List(total);
var offset = 0;
for (final chunk in all) {
result.setRange(offset, offset + chunk.length, chunk);
offset += chunk.length;
}
return result;
}
}

View File

@@ -0,0 +1,98 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
/// Plays decoded 8000 Hz / 16-bit mono PCM samples by writing a WAV file
/// to the system temp directory and using [AudioPlayer].
class VoicePlayerService {
final AudioPlayer _player = AudioPlayer();
bool _isPlaying = false;
bool get isPlaying => _isPlaying;
VoicePlayerService() {
_player.onPlayerStateChanged.listen((state) {
debugPrint('🔊 [VoicePlayer] state → $state');
_isPlaying = state == PlayerState.playing;
});
_player.onLog.listen((msg) => debugPrint('🔊 [VoicePlayer] log: $msg'));
}
/// Play [pcmSamples] (Int16, 8000 Hz, mono).
Future<void> play(Int16List pcmSamples) async {
debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples');
if (_isPlaying) await stop();
final wavBytes = _buildWav(pcmSamples, sampleRate: 8000);
final tmpDir = await getTemporaryDirectory();
final file = File('${tmpDir.path}/vc_voice.wav');
await file.writeAsBytes(wavBytes);
debugPrint('🔊 [VoicePlayer] WAV written: ${wavBytes.length} bytes → ${file.path}');
try {
_isPlaying = true;
await _player.play(DeviceFileSource(file.path));
debugPrint('🔊 [VoicePlayer] play() returned (audio playing)');
} catch (e, st) {
debugPrint('❌ [VoicePlayer] play() error: $e\n$st');
_isPlaying = false;
}
}
Future<void> stop() async {
debugPrint('🔊 [VoicePlayer] stop()');
await _player.stop();
_isPlaying = false;
}
void dispose() {
_player.dispose();
}
// ── WAV file builder ─────────────────────────────────────────────────────
/// Constructs a minimal WAV (RIFF/PCM) file from Int16 mono samples.
static Uint8List _buildWav(Int16List samples, {required int sampleRate}) {
const int numChannels = 1;
const int bitsPerSample = 16;
const int audioFormat = 1; // PCM
final dataSize = samples.length * 2; // 2 bytes per Int16 sample
final byteRate = sampleRate * numChannels * bitsPerSample ~/ 8;
final blockAlign = numChannels * bitsPerSample ~/ 8;
final totalSize = 36 + dataSize;
final buf = ByteData(44 + dataSize);
var offset = 0;
void writeStr(String s) {
for (final c in s.codeUnits) { buf.setUint8(offset++, c); }
}
void writeU32(int v) { buf.setUint32(offset, v, Endian.little); offset += 4; }
void writeU16(int v) { buf.setUint16(offset, v, Endian.little); offset += 2; }
writeStr('RIFF');
writeU32(totalSize);
writeStr('WAVE');
writeStr('fmt ');
writeU32(16); // subchunk1 size
writeU16(audioFormat); // 1 = PCM
writeU16(numChannels);
writeU32(sampleRate);
writeU32(byteRate);
writeU16(blockAlign);
writeU16(bitsPerSample);
writeStr('data');
writeU32(dataSize);
// PCM sample data (little-endian Int16)
for (final s in samples) {
buf.setInt16(offset, s, Endian.little);
offset += 2;
}
return buf.buffer.asUint8List();
}
}

View File

@@ -0,0 +1,119 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:record/record.dart';
/// Captures raw PCM audio at 8000 Hz, 16-bit mono.
///
/// [startCapture] returns a [Stream<Int16List>] that emits chunks of PCM
/// samples every [chunkDuration]. Call [stopCapture] to end recording.
class VoiceRecorderService {
final AudioRecorder _recorder = AudioRecorder();
StreamSubscription<Uint8List>? _sub;
StreamController<Int16List>? _controller;
bool _isRecording = false;
bool get isRecording => _isRecording;
/// Request microphone permission. Returns true if granted.
Future<bool> requestPermission() async {
return _recorder.hasPermission();
}
/// Start capturing PCM audio.
///
/// [chunkDuration] controls how often samples are emitted (default 1 s).
/// The returned stream emits [Int16List] chunks that are ready for Codec2 encoding.
Stream<Int16List> startCapture({
Duration chunkDuration = const Duration(seconds: 1),
}) {
if (_isRecording) {
throw StateError('VoiceRecorderService: already recording');
}
_controller = StreamController<Int16List>(
onCancel: () => _stopInternal(),
);
_isRecording = true;
_startRecording(chunkDuration);
return _controller!.stream;
}
Future<void> _startRecording(Duration chunkDuration) async {
final config = const RecordConfig(
encoder: AudioEncoder.pcm16bits,
sampleRate: 8000,
numChannels: 1,
bitRate: 128000, // ignored for PCM, but required by API
);
try {
final stream = await _recorder.startStream(config);
final chunkBytes = 8000 * 2 * chunkDuration.inMilliseconds ~/ 1000;
final buffer = <int>[];
_sub = stream.listen(
(data) {
buffer.addAll(data);
while (buffer.length >= chunkBytes) {
final chunk = buffer.sublist(0, chunkBytes);
buffer.removeRange(0, chunkBytes);
_controller?.add(_bytesToInt16(Uint8List.fromList(chunk)));
}
},
onDone: () {
if (buffer.isNotEmpty) {
final padded = _padToEven(buffer);
_controller?.add(_bytesToInt16(Uint8List.fromList(padded)));
}
_controller?.close();
},
onError: (e) {
debugPrint('❌ [VoiceRecorder] Stream error: $e');
_controller?.addError(e);
_controller?.close();
},
cancelOnError: false,
);
} catch (e) {
debugPrint('❌ [VoiceRecorder] Failed to start: $e');
_isRecording = false;
_controller?.addError(e);
_controller?.close();
}
}
/// Stop recording and flush remaining samples.
Future<void> stopCapture() async {
await _stopInternal();
}
Future<void> _stopInternal() async {
if (!_isRecording) return;
_isRecording = false;
await _sub?.cancel();
_sub = null;
await _recorder.stop();
}
void dispose() {
_stopInternal();
_recorder.dispose();
}
/// Convert raw little-endian PCM bytes to Int16List.
static Int16List _bytesToInt16(Uint8List bytes) {
final bd = ByteData.sublistView(bytes);
final samples = Int16List(bytes.length ~/ 2);
for (var i = 0; i < samples.length; i++) {
samples[i] = bd.getInt16(i * 2, Endian.little);
}
return samples;
}
static List<int> _padToEven(List<int> buf) {
if (buf.length % 2 != 0) buf.add(0);
return buf;
}
}

View File

@@ -0,0 +1,155 @@
import 'dart:convert';
import 'dart:typed_data';
/// Identifies which Codec2 mode was used for a voice packet.
/// Matches the modeId byte in the text/binary packet header.
enum VoicePacketMode {
mode700c(0, '700C'),
mode1200(1, '1200'),
mode2400(2, '2400'),
mode1300(3, '1300');
const VoicePacketMode(this.id, this.label);
final int id;
final String label;
static VoicePacketMode fromId(int id) =>
VoicePacketMode.values.firstWhere((m) => m.id == id, orElse: () => VoicePacketMode.mode700c);
}
/// A single Codec2-encoded chunk belonging to a multi-packet voice session.
///
/// Text format (channels):
/// V:{sessionId8hex}:{modeId}:{index}/{total}:{base64Codec2}
///
/// Binary format (direct contacts, received via pushRawData):
/// [0x56 'V'][sessionId:4B][modeId:1B][index:1B][total:1B][codec2Data...]
class VoicePacket {
final String sessionId; // 8 hex chars (4 bytes)
final VoicePacketMode mode;
final int index; // 0-based
final int total; // total packet count
final Uint8List codec2Data;
const VoicePacket({
required this.sessionId,
required this.mode,
required this.index,
required this.total,
required this.codec2Data,
});
// ── Text (channel) format ────────────────────────────────────────────────
static const String _textPrefix = 'V:';
static bool isVoiceText(String text) => text.startsWith(_textPrefix);
/// Parse a text-format voice packet. Returns null on failure.
static VoicePacket? tryParseText(String text) {
if (!text.startsWith(_textPrefix)) return null;
try {
final body = text.substring(_textPrefix.length);
final parts = body.split(':');
// parts: [sessionId, modeId, 'idx/total', base64data]
if (parts.length != 4) return null;
final sessionId = parts[0];
if (sessionId.length != 8) return null;
final modeId = int.tryParse(parts[1]);
if (modeId == null) return null;
final indexTotal = parts[2].split('/');
if (indexTotal.length != 2) return null;
final index = int.tryParse(indexTotal[0]);
final total = int.tryParse(indexTotal[1]);
if (index == null || total == null || total < 1) return null;
final codec2Data = base64.decode(parts[3]);
return VoicePacket(
sessionId: sessionId,
mode: VoicePacketMode.fromId(modeId),
index: index,
total: total,
codec2Data: codec2Data,
);
} catch (_) {
return null;
}
}
/// Encode as text (channel format). Max ~198 chars for 700C/1200/2400.
String encodeText() {
final b64 = base64.encode(codec2Data);
return 'V:$sessionId:${mode.id}:$index/$total:$b64';
}
// ── Binary format ────────────────────────────────────────────────────────
static const int _binaryMagic = 0x56; // 'V'
static const int _binaryHeaderLen = 8; // magic(1)+session(4)+mode(1)+idx(1)+total(1)
static bool isVoiceBinary(Uint8List payload) =>
payload.isNotEmpty && payload[0] == _binaryMagic;
/// Parse binary-format voice packet (from pushRawData payload).
static VoicePacket? tryParseBinary(Uint8List payload) {
if (payload.length < _binaryHeaderLen) return null;
if (payload[0] != _binaryMagic) return null;
try {
final sessionBytes = payload.sublist(1, 5);
final sessionId = sessionBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
final modeId = payload[5];
final index = payload[6];
final total = payload[7];
if (total < 1) return null;
final codec2Data = payload.sublist(_binaryHeaderLen);
return VoicePacket(
sessionId: sessionId,
mode: VoicePacketMode.fromId(modeId),
index: index,
total: total,
codec2Data: codec2Data,
);
} catch (_) {
return null;
}
}
/// Encode as binary payload (for cmdSendRawData).
Uint8List encodeBinary() {
final sessionBytes = Uint8List(4);
for (var i = 0; i < 4; i++) {
sessionBytes[i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
}
final out = Uint8List(_binaryHeaderLen + codec2Data.length);
out[0] = _binaryMagic;
out.setRange(1, 5, sessionBytes);
out[5] = mode.id;
out[6] = index;
out[7] = total;
out.setRange(_binaryHeaderLen, out.length, codec2Data);
return out;
}
// ── Duration helpers ─────────────────────────────────────────────────────
/// Estimated audio duration of this packet in milliseconds.
int get durationMs {
// bytesPerSecond for each mode
final bps = switch (mode) {
VoicePacketMode.mode700c => 100,
VoicePacketMode.mode1200 => 150,
VoicePacketMode.mode1300 => 175,
VoicePacketMode.mode2400 => 300,
};
if (bps == 0) return 0;
return (codec2Data.length * 1000 ~/ bps).clamp(0, 1500);
}
@override
String toString() =>
'VoicePacket($sessionId ${mode.label} [$index/${total - 1}] ${codec2Data.length}B)';
}

View File

@@ -19,6 +19,7 @@ import '../../utils/sar_message_parser.dart';
import '../../utils/key_comparison.dart';
import '../../l10n/app_localizations.dart';
import '../../utils/message_extensions.dart';
import 'voice_message_bubble.dart';
/// Reusable message bubble widget that displays messages with various types:
/// - Regular text messages (channel or direct)
@@ -634,6 +635,7 @@ class _MessageBubbleState extends State<MessageBubble> {
final displayName = isOwnMessage
? AppLocalizations.of(context)!.you
: message.getRichDisplayName(senderContact);
final l10n = AppLocalizations.of(context)!;
// For sent direct/channel messages, look up destination display label
dynamic recipientContact;
@@ -667,17 +669,22 @@ class _MessageBubbleState extends State<MessageBubble> {
}
} else if (isOwnMessage && message.isChannelMessage) {
if (message.channelIdx == 0) {
recipientDisplayName = AppLocalizations.of(context)!.publicChannel;
recipientDisplayName = l10n.publicChannel;
} else {
final channelContact = contactsProvider.channels.where((c) {
return c.publicKey.length > 1 && c.publicKey[1] == message.channelIdx;
}).firstOrNull;
recipientDisplayName =
channelContact?.getLocalizedDisplayName(context) ??
'${AppLocalizations.of(context)!.channel} ${message.channelIdx}';
'${l10n.channel} ${message.channelIdx}';
}
}
final recipientSubtitle =
isOwnMessage && message.isChannelMessage && recipientDisplayName != null
? '${l10n.channel}: $recipientDisplayName'
: recipientDisplayName;
return GestureDetector(
onTap: widget.onTap,
onLongPress: widget.isCompact ? null : () => _showMessageOptions(context),
@@ -860,47 +867,63 @@ class _MessageBubbleState extends State<MessageBubble> {
const Icon(Icons.person, size: 16),
const SizedBox(width: 4),
Expanded(
child: Text(
displayName,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.bold,
color: isOwnMessage
? Theme.of(context).colorScheme.primary
: null,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayName,
style: Theme.of(context).textTheme.labelMedium
?.copyWith(
fontWeight: FontWeight.bold,
color: isOwnMessage
? Theme.of(context).colorScheme.primary
: null,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Show destination for sent direct/channel messages on a separate line
if (isOwnMessage &&
recipientSubtitle != null &&
!widget.isCompact) ...[
const SizedBox(height: 2),
Row(
children: [
Icon(
Icons.arrow_forward,
size: 12,
color: Theme.of(context)
.textTheme
.labelSmall
?.color
?.withValues(alpha: 0.7),
),
const SizedBox(width: 4),
Expanded(
child: Text(
recipientSubtitle,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: Theme.of(context)
.textTheme
.labelSmall
?.color
?.withValues(alpha: 0.75),
fontStyle: FontStyle.italic,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
],
),
),
// Show destination for sent direct/channel messages
if (isOwnMessage &&
recipientDisplayName != null &&
!widget.isCompact) ...[
const SizedBox(width: 4),
Icon(
Icons.arrow_forward,
size: 14,
color: Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.6),
),
const SizedBox(width: 4),
Flexible(
child: Text(
recipientDisplayName,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.7),
fontStyle: FontStyle.italic,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
// Time for regular messages (not shown for SAR/drawing as it's already above)
if (!isSarMarker && !message.isDrawing) ...[
const SizedBox(width: 8),
// Hop count indicator for received messages
if (!isOwnMessage && message.pathLen < 255) ...[
const SizedBox(width: 4),
@@ -1055,6 +1078,11 @@ class _MessageBubbleState extends State<MessageBubble> {
);
},
)
// Voice message content
else if (message.isVoice &&
message.voiceId != null &&
!widget.isCompact)
VoiceMessageBubble(message: message, isSentByMe: isOwnMessage)
// Regular message content
else if (!message.isDrawing || widget.isCompact)
Text(message.text, style: Theme.of(context).textTheme.bodyMedium),

View File

@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../providers/voice_provider.dart';
import '../../models/message.dart';
/// A message bubble that shows a voice recording with play/stop controls.
class VoiceMessageBubble extends StatelessWidget {
final Message message;
final bool isSentByMe;
const VoiceMessageBubble({
super.key,
required this.message,
required this.isSentByMe,
});
@override
Widget build(BuildContext context) {
final voiceId = message.voiceId;
if (voiceId == null) return const SizedBox.shrink();
return Consumer<VoiceProvider>(
builder: (context, voiceProvider, _) {
final session = voiceProvider.session(voiceId);
final isPlaying = voiceProvider.isPlaying(voiceId);
final isComplete = voiceProvider.isComplete(voiceId);
final received = session?.receivedCount ?? 0;
final total = session?.total ?? 0;
final durationSec = session?.estimatedDurationSeconds ?? 0.0;
final durationLabel = _formatDuration(durationSec);
final modeLabel = session?.mode.label ?? '?';
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// Play / Stop button
InkWell(
onTap: () async {
if (isPlaying) {
await voiceProvider.stop();
} else {
await voiceProvider.play(voiceId);
}
},
borderRadius: BorderRadius.circular(24),
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: isSentByMe
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.secondaryContainer,
shape: BoxShape.circle,
),
child: Icon(
isPlaying ? Icons.stop : Icons.play_arrow,
size: 28,
color: isSentByMe
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSecondaryContainer,
),
),
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Waveform placeholder / progress indicator
if (isPlaying)
SizedBox(
width: 100,
child: LinearProgressIndicator(
backgroundColor: Colors.grey.withValues(alpha: 0.3),
),
)
else
_WaveformBar(isComplete: isComplete),
const SizedBox(height: 4),
// Duration + mode + packet progress
Text(
_buildStatusText(
durationLabel: durationLabel,
modeLabel: modeLabel,
received: received,
total: total,
isComplete: isComplete,
),
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
],
);
},
);
}
static String _formatDuration(double seconds) {
final s = seconds.round();
if (s < 60) return '${s}s';
return '${s ~/ 60}m ${s % 60}s';
}
static String _buildStatusText({
required String durationLabel,
required String modeLabel,
required int received,
required int total,
required bool isComplete,
}) {
final progress = total > 0 ? ' ($received/$total)' : '';
if (!isComplete && total > 0) {
return '🎙️ $durationLabel · $modeLabel$progress';
}
return '🎙️ $durationLabel · $modeLabel';
}
}
/// Simple static waveform bar using a row of rectangles.
class _WaveformBar extends StatelessWidget {
final bool isComplete;
const _WaveformBar({required this.isComplete});
@override
Widget build(BuildContext context) {
const heights = [8.0, 14.0, 10.0, 18.0, 12.0, 16.0, 10.0, 14.0, 8.0, 12.0, 16.0, 10.0];
final color = isComplete
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.7)
: Colors.grey.withValues(alpha: 0.5);
return Row(
children: heights
.map((h) => Container(
width: 3,
height: h,
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
))
.toList(),
);
}
}

View File

@@ -6,13 +6,21 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_linux/audioplayers_linux_plugin.h>
#include <objectbox_flutter_libs/objectbox_flutter_libs_plugin.h>
#include <record_linux/record_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
g_autoptr(FlPluginRegistrar) objectbox_flutter_libs_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "ObjectboxFlutterLibsPlugin");
objectbox_flutter_libs_plugin_register_with_registrar(objectbox_flutter_libs_registrar);
g_autoptr(FlPluginRegistrar) record_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
record_linux_plugin_register_with_registrar(record_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View File

@@ -3,7 +3,9 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
objectbox_flutter_libs
record_linux
url_launcher_linux
)

View File

@@ -5,6 +5,7 @@
import FlutterMacOS
import Foundation
import audioplayers_darwin
import device_info_plus
import file_picker
import flutter_blue_plus_darwin
@@ -14,11 +15,13 @@ import nsd_macos
import objectbox_flutter_libs
import package_info_plus
import path_provider_foundation
import record_macos
import share_plus
import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
@@ -28,6 +31,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))

View File

@@ -1,4 +1,13 @@
PODS:
- audioplayers_darwin (0.0.1):
- Flutter
- FlutterMacOS
- codec2_flutter (0.1.0):
- FlutterMacOS
- device_info_plus (0.0.1):
- FlutterMacOS
- file_picker (0.0.1):
- FlutterMacOS
- flutter_blue_plus_darwin (0.0.2):
- Flutter
- FlutterMacOS
@@ -8,6 +17,8 @@ PODS:
- geolocator_apple (1.2.0):
- Flutter
- FlutterMacOS
- nsd_macos (0.0.1):
- FlutterMacOS
- ObjectBox (4.4.1)
- objectbox_flutter_libs (0.0.1):
- FlutterMacOS
@@ -17,28 +28,47 @@ PODS:
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- record_macos (1.2.0):
- FlutterMacOS
- share_plus (0.0.1):
- FlutterMacOS
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- url_launcher_macos (0.0.1):
- FlutterMacOS
DEPENDENCIES:
- audioplayers_darwin (from `Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin`)
- codec2_flutter (from `Flutter/ephemeral/.symlinks/plugins/codec2_flutter/macos`)
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
- flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin`)
- nsd_macos (from `Flutter/ephemeral/.symlinks/plugins/nsd_macos/macos`)
- objectbox_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/objectbox_flutter_libs/macos`)
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- record_macos (from `Flutter/ephemeral/.symlinks/plugins/record_macos/macos`)
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
SPEC REPOS:
trunk:
- ObjectBox
EXTERNAL SOURCES:
audioplayers_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin
codec2_flutter:
:path: Flutter/ephemeral/.symlinks/plugins/codec2_flutter/macos
device_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
file_picker:
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
flutter_blue_plus_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin
flutter_local_notifications:
@@ -47,28 +77,41 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral
geolocator_apple:
:path: Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin
nsd_macos:
:path: Flutter/ephemeral/.symlinks/plugins/nsd_macos/macos
objectbox_flutter_libs:
:path: Flutter/ephemeral/.symlinks/plugins/objectbox_flutter_libs/macos
package_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
path_provider_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
record_macos:
:path: Flutter/ephemeral/.symlinks/plugins/record_macos/macos
share_plus:
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
SPEC CHECKSUMS:
audioplayers_darwin: 4f9ca89d92d3d21cec7ec580e78ca888e5fb68bd
codec2_flutter: 50d930e6a1a187f05891b89300c6ef589651bfbd
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
flutter_local_notifications: 13862b132e32eb858dea558a86d45d08daeacfe7
flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
nsd_macos: a472240e770b92f6c6df1022403aa29c90d012e3
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
objectbox_flutter_libs: f51d18f6a4b5965c218843373b7dc5ed5e3a2008
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
record_macos: 7f227161b93c49e7e34fe681c5891c8622c8cc8b
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009

View File

@@ -26,6 +26,8 @@
<string>$(PRODUCT_COPYRIGHT)</string>
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSMicrophoneUsageDescription</key>
<string>MeshCore SAR needs microphone access to send voice messages over the mesh radio network during SAR operations</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
</dict>

View File

@@ -25,6 +25,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.0"
audioplayers:
dependency: "direct main"
description:
name: audioplayers
sha256: "5441fa0ceb8807a5ad701199806510e56afde2b4913d9d17c2f19f2902cf0ae4"
url: "https://pub.dev"
source: hosted
version: "6.5.1"
audioplayers_android:
dependency: transitive
description:
name: audioplayers_android
sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605"
url: "https://pub.dev"
source: hosted
version: "5.2.1"
audioplayers_darwin:
dependency: transitive
description:
name: audioplayers_darwin
sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333"
url: "https://pub.dev"
source: hosted
version: "6.3.0"
audioplayers_linux:
dependency: transitive
description:
name: audioplayers_linux
sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001
url: "https://pub.dev"
source: hosted
version: "4.2.1"
audioplayers_platform_interface:
dependency: transitive
description:
name: audioplayers_platform_interface
sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656"
url: "https://pub.dev"
source: hosted
version: "7.1.1"
audioplayers_web:
dependency: transitive
description:
name: audioplayers_web
sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
audioplayers_windows:
dependency: transitive
description:
name: audioplayers_windows
sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7"
url: "https://pub.dev"
source: hosted
version: "4.2.1"
bluez:
dependency: transitive
description:
@@ -73,6 +129,15 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
codec2_flutter:
dependency: "direct main"
description:
path: "."
ref: HEAD
resolved-ref: "0d3009f2626cd64232b7e19741f9ac45b0f01385"
url: "https://github.com/dz0ny/codec2_flutter.git"
source: git
version: "0.1.0"
collection:
dependency: transitive
description:
@@ -162,7 +227,7 @@ packages:
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
dependency: "direct main"
description:
name: ffi
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
@@ -593,8 +658,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: main
resolved-ref: ca34662abe4ef511bcb6b3b6fd2c5f648fee62fe
ref: HEAD
resolved-ref: b0126d3941117d4ae2948047834f1136324a26fd
url: "https://github.com/dz0ny/meshcore_client.git"
source: git
version: "0.1.0"
@@ -870,6 +935,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
record:
dependency: "direct main"
description:
name: record
sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277
url: "https://pub.dev"
source: hosted
version: "6.2.0"
record_android:
dependency: transitive
description:
name: record_android
sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1"
url: "https://pub.dev"
source: hosted
version: "1.5.1"
record_ios:
dependency: transitive
description:
name: record_ios
sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
record_linux:
dependency: transitive
description:
name: record_linux
sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7
url: "https://pub.dev"
source: hosted
version: "1.3.0"
record_macos:
dependency: transitive
description:
name: record_macos
sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
record_platform_interface:
dependency: transitive
description:
name: record_platform_interface
sha256: "8a81dbc4e14e1272a285bbfef6c9136d070a47d9b0d1f40aa6193516253ee2f6"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
record_web:
dependency: transitive
description:
name: record_web
sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
record_windows:
dependency: transitive
description:
name: record_windows
sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78"
url: "https://pub.dev"
source: hosted
version: "1.0.7"
rxdart:
dependency: transitive
description:

View File

@@ -44,7 +44,18 @@ dependencies:
meshcore_client:
git:
url: https://github.com/dz0ny/meshcore_client.git
ref: main
# Codec2 ultra-low-bitrate speech codec (FFI plugin)
codec2_flutter:
git:
url: https://github.com/dz0ny/codec2_flutter.git
ffi: ^2.1.0
# Voice recording (raw PCM stream at 8000 Hz)
record: ^6.0.0
# Voice playback (plays WAV from file)
audioplayers: ^6.0.0
# BLE connectivity
flutter_blue_plus: ^2.0.0

View File

@@ -5,21 +5,29 @@
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/main.dart';
import 'package:meshcore_sar_app/screens/home_screen.dart';
import 'package:meshcore_sar_app/screens/welcome_wizard_screen.dart';
void main() {
testWidgets('App launches smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
// Build app.
await tester.pumpWidget(const MeshCoreSarApp());
// Verify that our app title appears.
expect(find.text('MeshCore SAR'), findsOneWidget);
// During async initialization, loading indicator is shown.
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// Verify the tab bar appears with tabs
expect(find.text('Messages'), findsOneWidget);
expect(find.text('Contacts'), findsOneWidget);
expect(find.text('Map'), findsOneWidget);
// Give async startup a bounded amount of time without requiring full settle
// (the app may keep background animations/timers alive).
await tester.pump(const Duration(seconds: 2));
expect(
find.byType(CircularProgressIndicator).evaluate().isNotEmpty ||
find.byType(HomeScreen).evaluate().isNotEmpty ||
find.byType(WelcomeWizardScreen).evaluate().isNotEmpty,
isTrue,
);
});
}

View File

@@ -6,14 +6,18 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_windows/audioplayers_windows_plugin.h>
#include <geolocator_windows/geolocator_windows.h>
#include <nsd_windows/nsd_windows_plugin_c_api.h>
#include <objectbox_flutter_libs/objectbox_flutter_libs_plugin.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <record_windows/record_windows_plugin_c_api.h>
#include <share_plus/share_plus_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
AudioplayersWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
GeolocatorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GeolocatorWindows"));
NsdWindowsPluginCApiRegisterWithRegistrar(
@@ -22,6 +26,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("ObjectboxFlutterLibsPlugin"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
RecordWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(

View File

@@ -3,10 +3,12 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_windows
geolocator_windows
nsd_windows
objectbox_flutter_libs
permission_handler_windows
record_windows
share_plus
url_launcher_windows
)