mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
121
lib/providers/voice_provider.dart
Normal file
121
lib/providers/voice_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
72
lib/services/voice_codec_service.dart
Normal file
72
lib/services/voice_codec_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
98
lib/services/voice_player_service.dart
Normal file
98
lib/services/voice_player_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
119
lib/services/voice_recorder_service.dart
Normal file
119
lib/services/voice_recorder_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
155
lib/utils/voice_message_parser.dart
Normal file
155
lib/utils/voice_message_parser.dart
Normal 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)';
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
149
lib/widgets/messages/voice_message_bubble.dart
Normal file
149
lib/widgets/messages/voice_message_bubble.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_linux
|
||||
objectbox_flutter_libs
|
||||
record_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
135
pubspec.lock
135
pubspec.lock
@@ -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:
|
||||
|
||||
13
pubspec.yaml
13
pubspec.yaml
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user