mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
fix: retain voice codec settings now
ref:
This commit is contained in:
207
docs/voice-mode-technical.md
Normal file
207
docs/voice-mode-technical.md
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
# Voice Mode Technical Design
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
Voice mode uses a **two-plane architecture**:
|
||||||
|
|
||||||
|
- **Control plane (text messages):**
|
||||||
|
- `VE1:` voice envelope announces voice availability in chat.
|
||||||
|
- `VR1:` direct fetch request asks sender to stream voice payload.
|
||||||
|
- **Data plane (raw binary packets):**
|
||||||
|
- `VoicePacket` payload streamed via `cmdSendRawData` and received through `pushRawData`.
|
||||||
|
|
||||||
|
This design avoids broadcasting full voice payloads to channels/rooms. Chat carries only metadata; audio is fetched on demand when user presses play.
|
||||||
|
|
||||||
|
## 2. Key Modules
|
||||||
|
|
||||||
|
- `lib/utils/voice_message_parser.dart`
|
||||||
|
- `VoicePacket` (legacy text + binary packet format)
|
||||||
|
- `VoiceEnvelope` (`VE1`)
|
||||||
|
- `VoiceFetchRequest` (`VR1`)
|
||||||
|
- `lib/screens/messages_tab.dart`
|
||||||
|
- Capture/encode voice, cache encoded packets, send envelope only
|
||||||
|
- `lib/providers/voice_provider.dart`
|
||||||
|
- Reassembly/playback sessions
|
||||||
|
- Outgoing session cache + deferred serving
|
||||||
|
- `lib/providers/app_provider.dart`
|
||||||
|
- Incoming routing for `VE1` and `VR1`
|
||||||
|
- Handles raw packet ingestion
|
||||||
|
- `lib/widgets/messages/voice_message_bubble.dart`
|
||||||
|
- Play behavior (immediate play if complete, otherwise fetch + auto-play)
|
||||||
|
- `lib/providers/messages_provider.dart`
|
||||||
|
- Message-level voice detection (`VE1` + legacy `V:`)
|
||||||
|
- `lib/services/message_storage_service.dart`
|
||||||
|
- Persists `isVoice` and `voiceId`
|
||||||
|
|
||||||
|
## 3. Wire Formats
|
||||||
|
|
||||||
|
### 3.1 Voice Envelope (`VE1`)
|
||||||
|
|
||||||
|
Prefix: `VE1:` + colon-delimited compact payload
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
- `sid` (string, 8 hex chars): session ID
|
||||||
|
- `mode` (int): codec mode ID (`VoicePacketMode.id`)
|
||||||
|
- `total` (int): packet count (1..255)
|
||||||
|
- `durMs` (int): estimated duration in ms
|
||||||
|
- `senderKey6` (string, 12 hex chars): sender public-key prefix (6 bytes)
|
||||||
|
- `ts` (int): unix timestamp seconds
|
||||||
|
- `ver` (int): protocol version (currently `1`)
|
||||||
|
|
||||||
|
Compact format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
VE1:{sid}:{mode}:{total}:{durMs}:{senderKey6}:{ts}:{ver}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
VE1:deadbeef:1:4:3200:aabbccddeeff:1700000000:1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Voice Fetch Request (`VR1`)
|
||||||
|
|
||||||
|
Prefix: `VR1:` + colon-delimited compact payload
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
- `sid` (string, 8 hex chars): requested session
|
||||||
|
- `want` (string): currently `a` (compact token for `all`)
|
||||||
|
- `requesterKey6` (string, 12 hex chars): requester key prefix
|
||||||
|
- `ts` (int): unix timestamp seconds
|
||||||
|
- `ver` (int): protocol version (`1`)
|
||||||
|
|
||||||
|
Compact format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
VR1:{sid}:{want}:{requesterKey6}:{ts}:{ver}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
VR1:deadbeef:a:112233445566:1700000010:1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Raw Voice Packet (data plane)
|
||||||
|
|
||||||
|
Binary payload structure:
|
||||||
|
|
||||||
|
- Byte 0: magic `0x56` (`'V'`)
|
||||||
|
- Bytes 1..4: session ID (4 bytes)
|
||||||
|
- Byte 5: mode ID
|
||||||
|
- Byte 6: packet index
|
||||||
|
- Byte 7: total packets
|
||||||
|
- Bytes 8..N: codec2 data
|
||||||
|
|
||||||
|
## 4. Outgoing Flow (Send)
|
||||||
|
|
||||||
|
1. Recorder captures PCM chunks.
|
||||||
|
2. Each chunk is codec2-encoded into `VoicePacket` objects.
|
||||||
|
3. Packets are cached in `VoiceProvider` outgoing cache (TTL 15 min).
|
||||||
|
4. Sender inserts local voice placeholder message (`isVoice=true`, `voiceId=sessionId`).
|
||||||
|
5. Sender sends one envelope (`VE1`) through normal message path:
|
||||||
|
- channel/room: `sendChannelMessage`
|
||||||
|
- direct: `sendTextMessage`
|
||||||
|
6. **No raw audio packets are sent during initial send.**
|
||||||
|
|
||||||
|
## 5. Incoming Routing
|
||||||
|
|
||||||
|
### 5.1 `VE1` envelope received
|
||||||
|
|
||||||
|
`AppProvider` marks message as voice (`isVoice`, `voiceId`) and adds it to chat.
|
||||||
|
|
||||||
|
### 5.2 `VR1` request received
|
||||||
|
|
||||||
|
`AppProvider` treats it as control-plane only:
|
||||||
|
|
||||||
|
- request is not added to chat
|
||||||
|
- validates requester prefix match against sender metadata
|
||||||
|
- resolves requester contact via key prefix
|
||||||
|
- calls `voiceProvider.serveSessionTo(...)`
|
||||||
|
|
||||||
|
### 5.3 Raw packet received (`pushRawData`)
|
||||||
|
|
||||||
|
`AppProvider.onRawDataReceived` parses `VoicePacket` binary and appends to session in `VoiceProvider`.
|
||||||
|
|
||||||
|
## 6. Play / Fetch Behavior
|
||||||
|
|
||||||
|
In `VoiceMessageBubble`:
|
||||||
|
|
||||||
|
- If session already complete: play immediately.
|
||||||
|
- If incomplete/missing:
|
||||||
|
1. Resolve sender contact (message sender prefix or `VE1.senderKey6` fallback)
|
||||||
|
2. Send direct `VR1` fetch request
|
||||||
|
3. Show requesting state in UI
|
||||||
|
4. Auto-play when session becomes complete
|
||||||
|
|
||||||
|
If sender cannot be resolved or request cannot be sent, bubble remains and shows: **"Voice unavailable right now"**.
|
||||||
|
|
||||||
|
## 7. Outgoing Cache Details
|
||||||
|
|
||||||
|
`VoiceProvider` outgoing cache:
|
||||||
|
|
||||||
|
- key: `sessionId`
|
||||||
|
- value: encoded packet list + cached timestamp
|
||||||
|
- TTL: 15 minutes (`_outgoingSessionTtl`)
|
||||||
|
- eviction: lazy (on cache access/add/serve)
|
||||||
|
|
||||||
|
Serving prerequisites:
|
||||||
|
|
||||||
|
- session exists in cache
|
||||||
|
- `sendRawPacketCallback` configured
|
||||||
|
- requester has direct path (`outPathLen >= 0`)
|
||||||
|
|
||||||
|
## 8. Persistence
|
||||||
|
|
||||||
|
`MessageStorageService` now stores and restores:
|
||||||
|
|
||||||
|
- `isVoice`
|
||||||
|
- `voiceId`
|
||||||
|
|
||||||
|
This ensures envelope messages remain voice bubbles across app restart.
|
||||||
|
|
||||||
|
## 9. Validation and Safety
|
||||||
|
|
||||||
|
Parser validation enforces:
|
||||||
|
|
||||||
|
- strict hex lengths for IDs and key prefixes
|
||||||
|
- valid mode range
|
||||||
|
- valid packet counts and duration bounds
|
||||||
|
- fixed protocol version (`ver == 1`)
|
||||||
|
- `VR1.want` token `a` (internally normalized to `all`)
|
||||||
|
|
||||||
|
`VR1` handling verifies sender prefix matches `requesterKey6` to reduce spoofing risk.
|
||||||
|
|
||||||
|
## 10. Operational Constraints
|
||||||
|
|
||||||
|
- No firmware changes required.
|
||||||
|
- On-demand fetch works only if sender app is online and has cached session.
|
||||||
|
- Raw return path needs a currently valid direct route to requester.
|
||||||
|
- Voice capture in UI is currently iOS-only (`MessagesTab._voiceSupported`).
|
||||||
|
|
||||||
|
## 11. Backward Compatibility
|
||||||
|
|
||||||
|
- Legacy `V:` text packet parsing is still supported.
|
||||||
|
- Message voice detection accepts both new `VE1` and legacy `V:` formats.
|
||||||
|
|
||||||
|
## 12. High-Level Sequence
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant A as Sender App
|
||||||
|
participant M as Mesh Chat
|
||||||
|
participant B as Receiver App
|
||||||
|
|
||||||
|
A->>A: Record + encode voice packets
|
||||||
|
A->>A: Cache session packets (TTL 15m)
|
||||||
|
A->>M: Send VE1 envelope
|
||||||
|
M->>B: Deliver VE1
|
||||||
|
B->>B: Render voice bubble (metadata only)
|
||||||
|
B->>A: Send VR1 request on Play
|
||||||
|
A->>B: Stream raw VoicePacket packets
|
||||||
|
B->>B: Reassemble session
|
||||||
|
B->>B: Auto-play when complete
|
||||||
|
```
|
||||||
@@ -20,7 +20,5 @@
|
|||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>MinimumOSVersion</key>
|
|
||||||
<string>13.0</string>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -61,9 +61,6 @@ PODS:
|
|||||||
- ObjectBox (= 4.4.1)
|
- ObjectBox (= 4.4.1)
|
||||||
- package_info_plus (0.4.5):
|
- package_info_plus (0.4.5):
|
||||||
- Flutter
|
- Flutter
|
||||||
- path_provider_foundation (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- FlutterMacOS
|
|
||||||
- permission_handler_apple (9.3.0):
|
- permission_handler_apple (9.3.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- record_ios (1.2.0):
|
- record_ios (1.2.0):
|
||||||
@@ -96,7 +93,6 @@ DEPENDENCIES:
|
|||||||
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
|
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
|
||||||
- objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
|
- objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
- 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`)
|
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||||
- record_ios (from `.symlinks/plugins/record_ios/ios`)
|
- record_ios (from `.symlinks/plugins/record_ios/ios`)
|
||||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||||
@@ -139,8 +135,6 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/objectbox_flutter_libs/ios"
|
:path: ".symlinks/plugins/objectbox_flutter_libs/ios"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||||
path_provider_foundation:
|
|
||||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
|
||||||
permission_handler_apple:
|
permission_handler_apple:
|
||||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||||
record_ios:
|
record_ios:
|
||||||
@@ -165,13 +159,12 @@ SPEC CHECKSUMS:
|
|||||||
flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e
|
flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e
|
||||||
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
||||||
flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1
|
flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1
|
||||||
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
|
flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214
|
||||||
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
||||||
nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e
|
nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e
|
||||||
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
|
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
|
||||||
objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31
|
objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31
|
||||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
|
||||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||||
record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
|
record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
|
||||||
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
||||||
@@ -179,7 +172,7 @@ SPEC CHECKSUMS:
|
|||||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||||
vibration: 69774ad57825b11c951ee4c46155f455d7a592ce
|
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
|
||||||
|
|
||||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Flutter
|
import Flutter
|
||||||
|
|
||||||
@UIApplicationMain
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ extension MessageVoiceExtension on Message {
|
|||||||
/// Returns null for non-voice messages.
|
/// Returns null for non-voice messages.
|
||||||
VoicePacketMode? get voicePacketMode {
|
VoicePacketMode? get voicePacketMode {
|
||||||
if (!isVoice || text.isEmpty) return null;
|
if (!isVoice || text.isEmpty) return null;
|
||||||
|
final envelope = VoiceEnvelope.tryParseText(text);
|
||||||
|
if (envelope != null) return envelope.mode;
|
||||||
final pkt = VoicePacket.tryParseText(text);
|
final pkt = VoicePacket.tryParseText(text);
|
||||||
return pkt?.mode;
|
return pkt?.mode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ class AppProvider with ChangeNotifier {
|
|||||||
bool _isMapEnabled = true;
|
bool _isMapEnabled = true;
|
||||||
bool get isMapEnabled => _isMapEnabled;
|
bool get isMapEnabled => _isMapEnabled;
|
||||||
|
|
||||||
|
bool _isVoiceSilenceTrimmingEnabled = true;
|
||||||
|
bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled;
|
||||||
|
bool _isVoiceBandPassFilterEnabled = true;
|
||||||
|
bool get isVoiceBandPassFilterEnabled => _isVoiceBandPassFilterEnabled;
|
||||||
|
|
||||||
AppProvider({
|
AppProvider({
|
||||||
required this.connectionProvider,
|
required this.connectionProvider,
|
||||||
required this.contactsProvider,
|
required this.contactsProvider,
|
||||||
@@ -49,6 +54,8 @@ class AppProvider with ChangeNotifier {
|
|||||||
_initializeLocationTracking();
|
_initializeLocationTracking();
|
||||||
_loadSimpleMode();
|
_loadSimpleMode();
|
||||||
_loadMapEnabled();
|
_loadMapEnabled();
|
||||||
|
_loadVoiceSilenceTrimmingEnabled();
|
||||||
|
_loadVoiceBandPassFilterEnabled();
|
||||||
_syncDrawingsOnStartup(); // Sync drawings immediately after providers load
|
_syncDrawingsOnStartup(); // Sync drawings immediately after providers load
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
}
|
}
|
||||||
@@ -118,6 +125,54 @@ class AppProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load voice silence trimming setting from shared preferences.
|
||||||
|
Future<void> _loadVoiceSilenceTrimmingEnabled() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_isVoiceSilenceTrimmingEnabled =
|
||||||
|
prefs.getBool('voice_silence_trimming_enabled') ?? true;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error loading voice silence trimming setting: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle voice silence trimming on/off.
|
||||||
|
Future<void> toggleVoiceSilenceTrimmingEnabled(bool enabled) async {
|
||||||
|
try {
|
||||||
|
_isVoiceSilenceTrimmingEnabled = enabled;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool('voice_silence_trimming_enabled', enabled);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving voice silence trimming setting: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load voice band-pass filter setting from shared preferences.
|
||||||
|
Future<void> _loadVoiceBandPassFilterEnabled() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_isVoiceBandPassFilterEnabled =
|
||||||
|
prefs.getBool('voice_band_pass_filter_enabled') ?? true;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error loading voice band-pass filter setting: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle voice band-pass filter on/off.
|
||||||
|
Future<void> toggleVoiceBandPassFilterEnabled(bool enabled) async {
|
||||||
|
try {
|
||||||
|
_isVoiceBandPassFilterEnabled = enabled;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool('voice_band_pass_filter_enabled', enabled);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving voice band-pass filter setting: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Initialize tile cache service
|
/// Initialize tile cache service
|
||||||
Future<void> _initializeTileCache() async {
|
Future<void> _initializeTileCache() async {
|
||||||
try {
|
try {
|
||||||
@@ -165,6 +220,20 @@ class AppProvider with ChangeNotifier {
|
|||||||
void _setupCallbacks() {
|
void _setupCallbacks() {
|
||||||
// Monitor connection state changes to start/stop location tracking
|
// Monitor connection state changes to start/stop location tracking
|
||||||
connectionProvider.addListener(_handleConnectionStateChange);
|
connectionProvider.addListener(_handleConnectionStateChange);
|
||||||
|
|
||||||
|
voiceProvider.sendRawPacketCallback =
|
||||||
|
({
|
||||||
|
required Uint8List contactPath,
|
||||||
|
required int contactPathLen,
|
||||||
|
required Uint8List payload,
|
||||||
|
}) async {
|
||||||
|
await connectionProvider.sendRawVoicePacket(
|
||||||
|
contactPath: contactPath,
|
||||||
|
contactPathLen: contactPathLen,
|
||||||
|
payload: payload,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// When a contact is received from BLE
|
// When a contact is received from BLE
|
||||||
connectionProvider.onContactReceived = (contact) {
|
connectionProvider.onContactReceived = (contact) {
|
||||||
// Pass device public key to filter out our own contact
|
// Pass device public key to filter out our own contact
|
||||||
@@ -300,6 +369,43 @@ class AppProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Voice control plane: request sender to stream raw voice packets.
|
||||||
|
final voiceFetchRequest = VoiceFetchRequest.tryParseText(
|
||||||
|
enrichedMessage.text,
|
||||||
|
);
|
||||||
|
if (voiceFetchRequest != null) {
|
||||||
|
final senderPrefix = enrichedMessage.senderPublicKeyPrefix;
|
||||||
|
if (senderPrefix == null) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [AppProvider] Voice fetch request without sender prefix',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final senderPrefixHex = senderPrefix
|
||||||
|
.take(6)
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join('');
|
||||||
|
if (senderPrefixHex.toLowerCase() !=
|
||||||
|
voiceFetchRequest.requesterKey6.toLowerCase()) {
|
||||||
|
debugPrint('⚠️ [AppProvider] Voice fetch requester key mismatch');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final requester = contactsProvider.findContactByPrefix(senderPrefix);
|
||||||
|
if (requester == null) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [AppProvider] Voice fetch requester contact not found',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unawaited(
|
||||||
|
voiceProvider.serveSessionTo(
|
||||||
|
sessionId: voiceFetchRequest.sessionId,
|
||||||
|
requester: requester,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if message is a drawing broadcast
|
// Check if message is a drawing broadcast
|
||||||
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
|
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
|
||||||
debugPrint('🎨 [AppProvider] Drawing message received, parsing...');
|
debugPrint('🎨 [AppProvider] Drawing message received, parsing...');
|
||||||
@@ -339,6 +445,33 @@ class AppProvider with ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Voice envelope message (new public/direct on-demand format).
|
||||||
|
final voiceEnvelope = VoiceEnvelope.tryParseText(enrichedMessage.text);
|
||||||
|
if (voiceEnvelope != null) {
|
||||||
|
enrichedMessage = enrichedMessage.copyWith(
|
||||||
|
isVoice: true,
|
||||||
|
voiceId: voiceEnvelope.sessionId,
|
||||||
|
);
|
||||||
|
messagesProvider.addMessage(
|
||||||
|
enrichedMessage,
|
||||||
|
contactLookup: (name) {
|
||||||
|
try {
|
||||||
|
final contact = contactsProvider.contacts.firstWhere(
|
||||||
|
(c) => c.advName == name,
|
||||||
|
);
|
||||||
|
return contact.publicKeyHex.isNotEmpty &&
|
||||||
|
contact.publicKeyHex.length >= 12
|
||||||
|
? contact.publicKeyHex.substring(0, 12)
|
||||||
|
: '';
|
||||||
|
} catch (_) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If it's a text-format voice packet, feed it to VoiceProvider
|
// If it's a text-format voice packet, feed it to VoiceProvider
|
||||||
if (VoicePacket.isVoiceText(enrichedMessage.text)) {
|
if (VoicePacket.isVoiceText(enrichedMessage.text)) {
|
||||||
final pkt = VoicePacket.tryParseText(enrichedMessage.text);
|
final pkt = VoicePacket.tryParseText(enrichedMessage.text);
|
||||||
@@ -726,13 +859,16 @@ class AppProvider with ChangeNotifier {
|
|||||||
///
|
///
|
||||||
/// Binary voice packets arrive without a chat message, so we synthesise one
|
/// Binary voice packets arrive without a chat message, so we synthesise one
|
||||||
/// to give the user a playable bubble in the message list.
|
/// to give the user a playable bubble in the message list.
|
||||||
void _handleIncomingVoicePacket(VoicePacket pkt, {required bool justComplete}) {
|
void _handleIncomingVoicePacket(
|
||||||
|
VoicePacket pkt, {
|
||||||
|
required bool justComplete,
|
||||||
|
}) {
|
||||||
final sessionId = pkt.sessionId;
|
final sessionId = pkt.sessionId;
|
||||||
|
|
||||||
// Check if a placeholder for this session already exists
|
// Check if a placeholder for this session already exists
|
||||||
final existing = messagesProvider.messages.where(
|
final existing = messagesProvider.messages
|
||||||
(m) => m.isVoice && m.voiceId == sessionId,
|
.where((m) => m.isVoice && m.voiceId == sessionId)
|
||||||
).firstOrNull;
|
.firstOrNull;
|
||||||
|
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
// Already have a placeholder — no need to add another
|
// Already have a placeholder — no need to add another
|
||||||
@@ -748,7 +884,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
pathLen: 0,
|
pathLen: 0,
|
||||||
textType: MessageTextType.plain,
|
textType: MessageTextType.plain,
|
||||||
senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
text: '', // no text — displayed as VoiceMessageBubble
|
// Persist the first real packet in legacy V: text form so UI/debug paths
|
||||||
|
// can reconstruct packet metadata from actual data.
|
||||||
|
text: pkt.encodeText(),
|
||||||
receivedAt: DateTime.now(),
|
receivedAt: DateTime.now(),
|
||||||
deliveryStatus: MessageDeliveryStatus.received,
|
deliveryStatus: MessageDeliveryStatus.received,
|
||||||
isVoice: true,
|
isVoice: true,
|
||||||
@@ -845,6 +983,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
void clearAllData() {
|
void clearAllData() {
|
||||||
contactsProvider.clearContacts();
|
contactsProvider.clearContacts();
|
||||||
messagesProvider.clearAll();
|
messagesProvider.clearAll();
|
||||||
|
unawaited(voiceProvider.clearStoredVoiceData());
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,8 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
// SSE client connection state
|
// SSE client connection state
|
||||||
bool get isSseClientConnecting => _sseClient.isConnecting;
|
bool get isSseClientConnecting => _sseClient.isConnecting;
|
||||||
int get sseClientReconnectionAttempt => _sseClient.reconnectionAttempts;
|
int get sseClientReconnectionAttempt => _sseClient.reconnectionAttempts;
|
||||||
int get sseClientMaxReconnectionAttempts => _sseClient.maxReconnectionAttempts;
|
int get sseClientMaxReconnectionAttempts =>
|
||||||
|
_sseClient.maxReconnectionAttempts;
|
||||||
|
|
||||||
// Message sync state
|
// Message sync state
|
||||||
bool _noMoreMessages = false;
|
bool _noMoreMessages = false;
|
||||||
@@ -151,7 +152,8 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
Function(List<Contact>)? onContactsComplete;
|
Function(List<Contact>)? onContactsComplete;
|
||||||
Function(Message)? onMessageReceived;
|
Function(Message)? onMessageReceived;
|
||||||
Function(Uint8List publicKey, Uint8List lppData)? onTelemetryReceived;
|
Function(Uint8List publicKey, Uint8List lppData)? onTelemetryReceived;
|
||||||
Function(int channelIdx, String channelName, Uint8List secret, int? flags)? onChannelInfoReceived;
|
Function(int channelIdx, String channelName, Uint8List secret, int? flags)?
|
||||||
|
onChannelInfoReceived;
|
||||||
Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData)?
|
Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData)?
|
||||||
onBinaryResponse;
|
onBinaryResponse;
|
||||||
Function(Uint8List publicKey)? onContactDeleted;
|
Function(Uint8List publicKey)? onContactDeleted;
|
||||||
@@ -310,17 +312,22 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
};
|
};
|
||||||
|
|
||||||
_bleService.onContactsComplete = (contacts) {
|
_bleService.onContactsComplete = (contacts) {
|
||||||
debugPrint('📥 [Provider] Contacts sync complete: ${contacts.length} contacts');
|
debugPrint(
|
||||||
|
'📥 [Provider] Contacts sync complete: ${contacts.length} contacts',
|
||||||
|
);
|
||||||
debugPrint(' Forwarding to AppProvider via onContactsComplete callback');
|
debugPrint(' Forwarding to AppProvider via onContactsComplete callback');
|
||||||
onContactsComplete?.call(contacts);
|
onContactsComplete?.call(contacts);
|
||||||
};
|
};
|
||||||
|
|
||||||
_bleService.onChannelInfoReceived = (int channelIdx, String channelName, Uint8List secret, int? flags) {
|
_bleService.onChannelInfoReceived =
|
||||||
onChannelInfoReceived?.call(channelIdx, channelName, secret, flags);
|
(int channelIdx, String channelName, Uint8List secret, int? flags) {
|
||||||
};
|
onChannelInfoReceived?.call(channelIdx, channelName, secret, flags);
|
||||||
|
};
|
||||||
|
|
||||||
_bleService.onContactDeleted = (publicKey) {
|
_bleService.onContactDeleted = (publicKey) {
|
||||||
debugPrint('⚠️ [Provider] Contact deleted by firmware (contacts full overwrite)');
|
debugPrint(
|
||||||
|
'⚠️ [Provider] Contact deleted by firmware (contacts full overwrite)',
|
||||||
|
);
|
||||||
onContactDeleted?.call(publicKey);
|
onContactDeleted?.call(publicKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -349,7 +356,9 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
debugPrint(' LPP data: ${lppData.length} bytes');
|
debugPrint(' LPP data: ${lppData.length} bytes');
|
||||||
// Mark ping as successful if this was a ping request
|
// Mark ping as successful if this was a ping request
|
||||||
_pingTracker.markPingSuccessful(publicKey);
|
_pingTracker.markPingSuccessful(publicKey);
|
||||||
debugPrint(' Forwarding to AppProvider via onTelemetryReceived callback');
|
debugPrint(
|
||||||
|
' Forwarding to AppProvider via onTelemetryReceived callback',
|
||||||
|
);
|
||||||
onTelemetryReceived?.call(publicKey, lppData);
|
onTelemetryReceived?.call(publicKey, lppData);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -442,37 +451,42 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
onPathUpdated?.call(publicKey);
|
onPathUpdated?.call(publicKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
_bleService
|
_bleService.onMessageSent =
|
||||||
.onMessageSent = (expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) {
|
(expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) {
|
||||||
debugPrint(
|
|
||||||
'📥 [Provider] Message sent - ACK tag: $expectedAckTag, timeout: ${suggestedTimeoutMs}ms',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Pop message ID from FIFO queue (matches send order)
|
|
||||||
final messageId = _messageDeliveryTracker.popPendingMessageId();
|
|
||||||
|
|
||||||
if (messageId != null) {
|
|
||||||
debugPrint(' ✅ Matched with message ID: $messageId');
|
|
||||||
|
|
||||||
// Check if approaching firmware limit (8 pending ACKs max)
|
|
||||||
if (_messageDeliveryTracker.shouldRateLimit) {
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' ⚠️ WARNING: ${_messageDeliveryTracker.pendingCount} pending ACKs (firmware limit: 8)',
|
'📥 [Provider] Message sent - ACK tag: $expectedAckTag, timeout: ${suggestedTimeoutMs}ms',
|
||||||
);
|
);
|
||||||
debugPrint(' ⚠️ Firmware may drop ACK tracking if limit exceeded!');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store the ACK tag to message ID mapping for delivery confirmation
|
// Pop message ID from FIFO queue (matches send order)
|
||||||
_messageDeliveryTracker.mapAckTagToMessageId(expectedAckTag, messageId);
|
final messageId = _messageDeliveryTracker.popPendingMessageId();
|
||||||
|
|
||||||
// Notify callback with message ID
|
if (messageId != null) {
|
||||||
onMessageSent?.call(messageId, expectedAckTag, suggestedTimeoutMs);
|
debugPrint(' ✅ Matched with message ID: $messageId');
|
||||||
} else {
|
|
||||||
debugPrint(
|
// Check if approaching firmware limit (8 pending ACKs max)
|
||||||
'⚠️ [Provider] SENT response received but no pending message IDs',
|
if (_messageDeliveryTracker.shouldRateLimit) {
|
||||||
);
|
debugPrint(
|
||||||
}
|
' ⚠️ WARNING: ${_messageDeliveryTracker.pendingCount} pending ACKs (firmware limit: 8)',
|
||||||
};
|
);
|
||||||
|
debugPrint(
|
||||||
|
' ⚠️ Firmware may drop ACK tracking if limit exceeded!',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the ACK tag to message ID mapping for delivery confirmation
|
||||||
|
_messageDeliveryTracker.mapAckTagToMessageId(
|
||||||
|
expectedAckTag,
|
||||||
|
messageId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Notify callback with message ID
|
||||||
|
onMessageSent?.call(messageId, expectedAckTag, suggestedTimeoutMs);
|
||||||
|
} else {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [Provider] SENT response received but no pending message IDs',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
_bleService.onMessageDelivered = (ackCode, roundTripTimeMs) {
|
_bleService.onMessageDelivered = (ackCode, roundTripTimeMs) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -527,7 +541,9 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Update SSE server with device name if running
|
// Update SSE server with device name if running
|
||||||
if (_sseServer.isRunning) {
|
if (_sseServer.isRunning) {
|
||||||
_sseServer.setDeviceName(_deviceInfo.deviceName ?? _deviceInfo.selfName);
|
_sseServer.setDeviceName(
|
||||||
|
_deviceInfo.deviceName ?? _deviceInfo.selfName,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -563,7 +579,9 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Update SSE server with device name if running
|
// Update SSE server with device name if running
|
||||||
if (_sseServer.isRunning) {
|
if (_sseServer.isRunning) {
|
||||||
_sseServer.setDeviceName(_deviceInfo.deviceName ?? _deviceInfo.selfName);
|
_sseServer.setDeviceName(
|
||||||
|
_deviceInfo.deviceName ?? _deviceInfo.selfName,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -755,11 +773,15 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
void _startAckCleanupTimer() {
|
void _startAckCleanupTimer() {
|
||||||
_stopAckCleanupTimer(); // Cancel any existing timer first
|
_stopAckCleanupTimer(); // Cancel any existing timer first
|
||||||
|
|
||||||
debugPrint('🧹 [ConnectionProvider] Starting ACK cleanup timer (1 minute interval)');
|
debugPrint(
|
||||||
|
'🧹 [ConnectionProvider] Starting ACK cleanup timer (1 minute interval)',
|
||||||
|
);
|
||||||
_ackCleanupTimer = Timer.periodic(const Duration(minutes: 1), (_) {
|
_ackCleanupTimer = Timer.periodic(const Duration(minutes: 1), (_) {
|
||||||
final cleanedCount = _messageDeliveryTracker.cleanupStaleAcks();
|
final cleanedCount = _messageDeliveryTracker.cleanupStaleAcks();
|
||||||
if (cleanedCount > 0) {
|
if (cleanedCount > 0) {
|
||||||
debugPrint('🧹 [ConnectionProvider] Cleaned up $cleanedCount stale ACK mappings');
|
debugPrint(
|
||||||
|
'🧹 [ConnectionProvider] Cleaned up $cleanedCount stale ACK mappings',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -811,7 +833,9 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
await _bleService.getContactByKey(publicKey);
|
await _bleService.getContactByKey(publicKey);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = 'Failed to get contact: $e';
|
_error = 'Failed to get contact: $e';
|
||||||
debugPrint('⚠️ [Provider] Failed to get contact by key, falling back to full contact sync');
|
debugPrint(
|
||||||
|
'⚠️ [Provider] Failed to get contact by key, falling back to full contact sync',
|
||||||
|
);
|
||||||
// Fallback to full contact sync if command not supported
|
// Fallback to full contact sync if command not supported
|
||||||
await _bleService.getContacts();
|
await _bleService.getContacts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -902,7 +926,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
// If not cached, query the device
|
// If not cached, query the device
|
||||||
await _bleService.getChannel(channelIdx);
|
await _bleService.getChannel(channelIdx);
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
// Check again after query
|
// Check again after query
|
||||||
if (getChannelInfo != null) {
|
if (getChannelInfo != null) {
|
||||||
final channel = getChannelInfo!(channelIdx);
|
final channel = getChannelInfo!(channelIdx);
|
||||||
@@ -911,7 +935,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
return channelName == null || channelName.isEmpty;
|
return channelName == null || channelName.isEmpty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If still no info, assume it's empty
|
// If still no info, assume it's empty
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -953,7 +977,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
return i;
|
return i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint(' ❌ All slots (1-${maxChannels - 1}) are in use');
|
debugPrint(' ❌ All slots (1-${maxChannels - 1}) are in use');
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -986,7 +1010,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Determine channel type
|
// Determine channel type
|
||||||
final bool isHashChannel = channelName.startsWith('#');
|
final bool isHashChannel = channelName.startsWith('#');
|
||||||
|
|
||||||
// Check for duplicate channels
|
// Check for duplicate channels
|
||||||
int? existingSlot;
|
int? existingSlot;
|
||||||
if (getChannelInfo != null) {
|
if (getChannelInfo != null) {
|
||||||
@@ -998,12 +1022,18 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
if (existingName != null && existingName.isNotEmpty) {
|
if (existingName != null && existingName.isNotEmpty) {
|
||||||
// For hash channels (#name), check exact match to prevent duplicates
|
// For hash channels (#name), check exact match to prevent duplicates
|
||||||
if (isHashChannel && existingName == channelName) {
|
if (isHashChannel && existingName == channelName) {
|
||||||
debugPrint(' ⚠️ Hash channel "$channelName" already exists in slot $i');
|
debugPrint(
|
||||||
throw Exception('Channel "$channelName" already exists. Hash channels cannot be duplicated.');
|
' ⚠️ Hash channel "$channelName" already exists in slot $i',
|
||||||
|
);
|
||||||
|
throw Exception(
|
||||||
|
'Channel "$channelName" already exists. Hash channels cannot be duplicated.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// For private channels, check name match to allow overwrite
|
// For private channels, check name match to allow overwrite
|
||||||
else if (!isHashChannel && existingName == channelName) {
|
else if (!isHashChannel && existingName == channelName) {
|
||||||
debugPrint(' ℹ️ Private channel "$channelName" found in slot $i - will overwrite');
|
debugPrint(
|
||||||
|
' ℹ️ Private channel "$channelName" found in slot $i - will overwrite',
|
||||||
|
);
|
||||||
existingSlot = i;
|
existingSlot = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1022,7 +1052,9 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
// Find next empty slot for new channel
|
// Find next empty slot for new channel
|
||||||
final emptySlot = await findNextEmptyChannelSlot();
|
final emptySlot = await findNextEmptyChannelSlot();
|
||||||
if (emptySlot == null) {
|
if (emptySlot == null) {
|
||||||
throw Exception('All channel slots are in use (maximum 39 custom channels)');
|
throw Exception(
|
||||||
|
'All channel slots are in use (maximum 39 custom channels)',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
slotIdx = emptySlot;
|
slotIdx = emptySlot;
|
||||||
debugPrint(' Using empty slot: $slotIdx (new channel)');
|
debugPrint(' Using empty slot: $slotIdx (new channel)');
|
||||||
@@ -1049,7 +1081,9 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
secret: secretBytes,
|
secret: secretBytes,
|
||||||
);
|
);
|
||||||
|
|
||||||
debugPrint('✅ [Provider] Channel ${existingSlot != null ? 'updated' : 'created'} successfully in slot $slotIdx');
|
debugPrint(
|
||||||
|
'✅ [Provider] Channel ${existingSlot != null ? 'updated' : 'created'} successfully in slot $slotIdx',
|
||||||
|
);
|
||||||
|
|
||||||
// Small delay to allow the response to propagate
|
// Small delay to allow the response to propagate
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
@@ -1088,7 +1122,9 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
// Delete channel on device (sets empty name and zeroed secret)
|
// Delete channel on device (sets empty name and zeroed secret)
|
||||||
await _bleService.deleteChannel(channelIdx);
|
await _bleService.deleteChannel(channelIdx);
|
||||||
|
|
||||||
debugPrint('✅ [Provider] Channel deleted successfully from slot $channelIdx');
|
debugPrint(
|
||||||
|
'✅ [Provider] Channel deleted successfully from slot $channelIdx',
|
||||||
|
);
|
||||||
|
|
||||||
// Small delay to allow the response to propagate
|
// Small delay to allow the response to propagate
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
@@ -1169,7 +1205,9 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [ConnectionProvider] Rate limit hit: $pendingCount pending ACKs (max 7)',
|
'⚠️ [ConnectionProvider] Rate limit hit: $pendingCount pending ACKs (max 7)',
|
||||||
);
|
);
|
||||||
debugPrint('⚠️ Firmware only tracks 8 ACKs - waiting for delivery confirmations...');
|
debugPrint(
|
||||||
|
'⚠️ Firmware only tracks 8 ACKs - waiting for delivery confirmations...',
|
||||||
|
);
|
||||||
|
|
||||||
// Wait briefly for some ACKs to arrive, then proceed anyway
|
// Wait briefly for some ACKs to arrive, then proceed anyway
|
||||||
// (User action shouldn't be blocked forever)
|
// (User action shouldn't be blocked forever)
|
||||||
@@ -1304,7 +1342,11 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
// Track for echo detection
|
// Track for echo detection
|
||||||
// The BLE handler will capture the packet via LOG_RX_DATA and associate it
|
// The BLE handler will capture the packet via LOG_RX_DATA and associate it
|
||||||
debugPrint(' Calling trackSentChannelMessage...');
|
debugPrint(' Calling trackSentChannelMessage...');
|
||||||
_bleService.trackSentChannelMessage(messageId);
|
_bleService.trackSentChannelMessage(
|
||||||
|
messageId,
|
||||||
|
channelIdx: channelIdx,
|
||||||
|
plainText: text,
|
||||||
|
);
|
||||||
debugPrint(' trackSentChannelMessage completed');
|
debugPrint(' trackSentChannelMessage completed');
|
||||||
|
|
||||||
// Small delay to ensure the message is in the MessagesProvider list
|
// Small delay to ensure the message is in the MessagesProvider list
|
||||||
@@ -2022,7 +2064,9 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
// Convert hex string to Uint8List
|
// Convert hex string to Uint8List
|
||||||
final bytes = <int>[];
|
final bytes = <int>[];
|
||||||
for (int i = 0; i < recipientPublicKey.length; i += 2) {
|
for (int i = 0; i < recipientPublicKey.length; i += 2) {
|
||||||
bytes.add(int.parse(recipientPublicKey.substring(i, i + 2), radix: 16));
|
bytes.add(
|
||||||
|
int.parse(recipientPublicKey.substring(i, i + 2), radix: 16),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return await sendTextMessage(
|
return await sendTextMessage(
|
||||||
contactPublicKey: Uint8List.fromList(bytes),
|
contactPublicKey: Uint8List.fromList(bytes),
|
||||||
@@ -2107,7 +2151,9 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
debugPrint('🔌 [ConnectionProvider] Connecting to SSE server: $serverUrl');
|
debugPrint(
|
||||||
|
'🔌 [ConnectionProvider] Connecting to SSE server: $serverUrl',
|
||||||
|
);
|
||||||
_sseClientServerUrl = serverUrl;
|
_sseClientServerUrl = serverUrl;
|
||||||
|
|
||||||
// Wire up callbacks
|
// Wire up callbacks
|
||||||
@@ -2122,11 +2168,17 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
};
|
};
|
||||||
|
|
||||||
_sseClient.onConnectionStateChanged = (isConnected) {
|
_sseClient.onConnectionStateChanged = (isConnected) {
|
||||||
debugPrint('🔔 [ConnectionProvider] SSE client connection state changed: $isConnected');
|
debugPrint(
|
||||||
|
'🔔 [ConnectionProvider] SSE client connection state changed: $isConnected',
|
||||||
|
);
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
debugPrint('✅ [ConnectionProvider] SSE client connected - updating UI state');
|
debugPrint(
|
||||||
|
'✅ [ConnectionProvider] SSE client connected - updating UI state',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
debugPrint('❌ [ConnectionProvider] SSE client disconnected - updating UI state');
|
debugPrint(
|
||||||
|
'❌ [ConnectionProvider] SSE client disconnected - updating UI state',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_deviceInfo = _deviceInfo.copyWith(
|
_deviceInfo = _deviceInfo.copyWith(
|
||||||
connectionState: isConnected
|
connectionState: isConnected
|
||||||
@@ -2142,15 +2194,21 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
};
|
};
|
||||||
|
|
||||||
debugPrint('📌 [ConnectionProvider] SSE callbacks registered, starting connection...');
|
debugPrint(
|
||||||
|
'📌 [ConnectionProvider] SSE callbacks registered, starting connection...',
|
||||||
|
);
|
||||||
await _sseClient.connect(serverUrl: serverUrl, authToken: authToken);
|
await _sseClient.connect(serverUrl: serverUrl, authToken: authToken);
|
||||||
|
|
||||||
_connectionMode = ConnectionMode.sseClient;
|
_connectionMode = ConnectionMode.sseClient;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
debugPrint('✅ [ConnectionProvider] Connected to SSE server');
|
debugPrint('✅ [ConnectionProvider] Connected to SSE server');
|
||||||
debugPrint('📊 [ConnectionProvider] SSE client state: isConnected=${_sseClient.isConnected}');
|
debugPrint(
|
||||||
debugPrint('📊 [ConnectionProvider] DeviceInfo state: connectionState=${_deviceInfo.connectionState}, isConnected=${_deviceInfo.isConnected}');
|
'📊 [ConnectionProvider] SSE client state: isConnected=${_sseClient.isConnected}',
|
||||||
|
);
|
||||||
|
debugPrint(
|
||||||
|
'📊 [ConnectionProvider] DeviceInfo state: connectionState=${_deviceInfo.connectionState}, isConnected=${_deviceInfo.isConnected}',
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = 'Failed to connect to SSE server: $e';
|
_error = 'Failed to connect to SSE server: $e';
|
||||||
debugPrint('❌ [ConnectionProvider] Failed to connect to SSE server: $e');
|
debugPrint('❌ [ConnectionProvider] Failed to connect to SSE server: $e');
|
||||||
@@ -2206,10 +2264,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
throw Exception('Not connected to SSE server');
|
throw Exception('Not connected to SSE server');
|
||||||
}
|
}
|
||||||
|
|
||||||
await _sseClient.sendChannelMessage(
|
await _sseClient.sendChannelMessage(channelIdx: channelIdx, text: text);
|
||||||
channelIdx: channelIdx,
|
|
||||||
text: text,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get SSE client connection status
|
/// Get SSE client connection status
|
||||||
|
|||||||
@@ -446,6 +446,22 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Find contact by first 6-byte public key prefix.
|
||||||
|
Contact? findContactByPrefix(Uint8List prefix) {
|
||||||
|
return _findContactByPrefix(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find contact by 12-hex-char public key prefix.
|
||||||
|
Contact? findContactByPrefixHex(String prefixHex) {
|
||||||
|
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(prefixHex)) return null;
|
||||||
|
final bytes = Uint8List(6);
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
final start = i * 2;
|
||||||
|
bytes[i] = int.parse(prefixHex.substring(start, start + 2), radix: 16);
|
||||||
|
}
|
||||||
|
return _findContactByPrefix(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
/// Find contact by public key
|
/// Find contact by public key
|
||||||
Contact? findContactByKey(Uint8List publicKey) {
|
Contact? findContactByKey(Uint8List publicKey) {
|
||||||
final keyHex = publicKey
|
final keyHex = publicKey
|
||||||
|
|||||||
@@ -156,6 +156,25 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if it's a voice envelope/message and not already marked.
|
||||||
|
if (!enhancedMessage.isVoice) {
|
||||||
|
final envelope = VoiceEnvelope.tryParseText(enhancedMessage.text);
|
||||||
|
if (envelope != null) {
|
||||||
|
enhancedMessage = enhancedMessage.copyWith(
|
||||||
|
isVoice: true,
|
||||||
|
voiceId: envelope.sessionId,
|
||||||
|
);
|
||||||
|
} else if (VoicePacket.isVoiceText(enhancedMessage.text)) {
|
||||||
|
final pkt = VoicePacket.tryParseText(enhancedMessage.text);
|
||||||
|
if (pkt != null) {
|
||||||
|
enhancedMessage = enhancedMessage.copyWith(
|
||||||
|
isVoice: true,
|
||||||
|
voiceId: pkt.sessionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_messages.add(enhancedMessage);
|
_messages.add(enhancedMessage);
|
||||||
|
|
||||||
// Extract SAR markers
|
// Extract SAR markers
|
||||||
@@ -182,20 +201,26 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
/// This restores drawings that may be missing from DrawingProvider storage
|
/// This restores drawings that may be missing from DrawingProvider storage
|
||||||
/// Should be called after both providers are initialized
|
/// Should be called after both providers are initialized
|
||||||
void syncDrawingsWithProvider(dynamic drawingProvider) {
|
void syncDrawingsWithProvider(dynamic drawingProvider) {
|
||||||
debugPrint('🔄 [MessagesProvider] Syncing drawings with DrawingProvider...');
|
debugPrint(
|
||||||
|
'🔄 [MessagesProvider] Syncing drawings with DrawingProvider...',
|
||||||
|
);
|
||||||
int restoredCount = 0;
|
int restoredCount = 0;
|
||||||
|
|
||||||
for (final message in _messages) {
|
for (final message in _messages) {
|
||||||
if (!message.isDrawing || message.drawingId == null) continue;
|
if (!message.isDrawing || message.drawingId == null) continue;
|
||||||
|
|
||||||
// Check if drawing exists in DrawingProvider
|
// Check if drawing exists in DrawingProvider
|
||||||
final existingDrawing = drawingProvider.getDrawingById(message.drawingId!);
|
final existingDrawing = drawingProvider.getDrawingById(
|
||||||
|
message.drawingId!,
|
||||||
|
);
|
||||||
if (existingDrawing != null) {
|
if (existingDrawing != null) {
|
||||||
continue; // Drawing already exists
|
continue; // Drawing already exists
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drawing is missing, reconstruct from message text
|
// Drawing is missing, reconstruct from message text
|
||||||
debugPrint('🔧 [MessagesProvider] Restoring missing drawing: ${message.drawingId}');
|
debugPrint(
|
||||||
|
'🔧 [MessagesProvider] Restoring missing drawing: ${message.drawingId}',
|
||||||
|
);
|
||||||
final drawing = DrawingMessageParser.parseDrawingMessage(
|
final drawing = DrawingMessageParser.parseDrawingMessage(
|
||||||
message.text,
|
message.text,
|
||||||
senderName: message.senderName,
|
senderName: message.senderName,
|
||||||
@@ -203,7 +228,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (drawing == null) {
|
if (drawing == null) {
|
||||||
debugPrint('⚠️ [MessagesProvider] Failed to parse drawing from message ${message.id}');
|
debugPrint(
|
||||||
|
'⚠️ [MessagesProvider] Failed to parse drawing from message ${message.id}',
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,11 +241,15 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
if (restoredDrawing != null) {
|
if (restoredDrawing != null) {
|
||||||
drawingProvider.addReceivedDrawing(restoredDrawing);
|
drawingProvider.addReceivedDrawing(restoredDrawing);
|
||||||
restoredCount++;
|
restoredCount++;
|
||||||
debugPrint('✅ [MessagesProvider] Restored drawing ${message.drawingId}');
|
debugPrint(
|
||||||
|
'✅ [MessagesProvider] Restored drawing ${message.drawingId}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('✅ [MessagesProvider] Sync complete: restored $restoredCount drawings');
|
debugPrint(
|
||||||
|
'✅ [MessagesProvider] Sync complete: restored $restoredCount drawings',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a copy of a drawing with a specific ID
|
/// Create a copy of a drawing with a specific ID
|
||||||
@@ -278,14 +309,22 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a voice message (V:...) and not already marked
|
// Check if it's a voice message (VE1:/V:) and not already marked.
|
||||||
if (VoicePacket.isVoiceText(enhancedMessage.text) && !enhancedMessage.isVoice) {
|
if (!enhancedMessage.isVoice) {
|
||||||
final pkt = VoicePacket.tryParseText(enhancedMessage.text);
|
final envelope = VoiceEnvelope.tryParseText(enhancedMessage.text);
|
||||||
if (pkt != null) {
|
if (envelope != null) {
|
||||||
enhancedMessage = enhancedMessage.copyWith(
|
enhancedMessage = enhancedMessage.copyWith(
|
||||||
isVoice: true,
|
isVoice: true,
|
||||||
voiceId: pkt.sessionId,
|
voiceId: envelope.sessionId,
|
||||||
);
|
);
|
||||||
|
} else if (VoicePacket.isVoiceText(enhancedMessage.text)) {
|
||||||
|
final pkt = VoicePacket.tryParseText(enhancedMessage.text);
|
||||||
|
if (pkt != null) {
|
||||||
|
enhancedMessage = enhancedMessage.copyWith(
|
||||||
|
isVoice: true,
|
||||||
|
voiceId: pkt.sessionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -772,6 +811,25 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if it's a voice message (VE1:/V:) and not already marked.
|
||||||
|
if (!enhancedMessage.isVoice) {
|
||||||
|
final envelope = VoiceEnvelope.tryParseText(enhancedMessage.text);
|
||||||
|
if (envelope != null) {
|
||||||
|
enhancedMessage = enhancedMessage.copyWith(
|
||||||
|
isVoice: true,
|
||||||
|
voiceId: envelope.sessionId,
|
||||||
|
);
|
||||||
|
} else if (VoicePacket.isVoiceText(enhancedMessage.text)) {
|
||||||
|
final pkt = VoicePacket.tryParseText(enhancedMessage.text);
|
||||||
|
if (pkt != null) {
|
||||||
|
enhancedMessage = enhancedMessage.copyWith(
|
||||||
|
isVoice: true,
|
||||||
|
voiceId: pkt.sessionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check for duplicates (shouldn't happen for sent messages, but be safe)
|
// Check for duplicates (shouldn't happen for sent messages, but be safe)
|
||||||
if (_isDuplicate(enhancedMessage)) {
|
if (_isDuplicate(enhancedMessage)) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -861,7 +919,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
// Clamp at 20 seconds maximum
|
// Clamp at 20 seconds maximum
|
||||||
final scaledTimeout = suggestedTimeoutMs * 5;
|
final scaledTimeout = suggestedTimeoutMs * 5;
|
||||||
final effectiveTimeout = scaledTimeout > 20000 ? 20000 : scaledTimeout;
|
final effectiveTimeout = scaledTimeout > 20000 ? 20000 : scaledTimeout;
|
||||||
debugPrint(' ⏱️ Radio suggested ${suggestedTimeoutMs}ms, using ${effectiveTimeout}ms (5x${scaledTimeout > 20000 ? ', clamped at 20s' : ''}) for grouped message');
|
debugPrint(
|
||||||
|
' ⏱️ Radio suggested ${suggestedTimeoutMs}ms, using ${effectiveTimeout}ms (5x${scaledTimeout > 20000 ? ', clamped at 20s' : ''}) for grouped message',
|
||||||
|
);
|
||||||
|
|
||||||
// Store ACK tag → List of (groupId, recipientPublicKey)
|
// Store ACK tag → List of (groupId, recipientPublicKey)
|
||||||
// Multiple recipients can share the same ACK tag
|
// Multiple recipients can share the same ACK tag
|
||||||
@@ -869,8 +929,12 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_ackTagToRecipients[expectedAckTag] = [];
|
_ackTagToRecipients[expectedAckTag] = [];
|
||||||
}
|
}
|
||||||
_ackTagToRecipients[expectedAckTag]!.add((groupId, recipientPublicKey));
|
_ackTagToRecipients[expectedAckTag]!.add((groupId, recipientPublicKey));
|
||||||
debugPrint(' ✅ Added recipient to ACK tag $expectedAckTag → group $groupId, recipient ${recipientPublicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}');
|
debugPrint(
|
||||||
debugPrint(' 📊 Total recipients for ACK $expectedAckTag: ${_ackTagToRecipients[expectedAckTag]!.length}');
|
' ✅ Added recipient to ACK tag $expectedAckTag → group $groupId, recipient ${recipientPublicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}',
|
||||||
|
);
|
||||||
|
debugPrint(
|
||||||
|
' 📊 Total recipients for ACK $expectedAckTag: ${_ackTagToRecipients[expectedAckTag]!.length}',
|
||||||
|
);
|
||||||
|
|
||||||
// Store the mapping so we can update the right recipient on delivery
|
// Store the mapping so we can update the right recipient on delivery
|
||||||
_pendingSentMessages[expectedAckTag] = Message(
|
_pendingSentMessages[expectedAckTag] = Message(
|
||||||
@@ -892,7 +956,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_timeoutTimers[messageId] = Timer(
|
_timeoutTimers[messageId] = Timer(
|
||||||
Duration(milliseconds: effectiveTimeout),
|
Duration(milliseconds: effectiveTimeout),
|
||||||
() {
|
() {
|
||||||
debugPrint('⏱️ [MessagesProvider] Timeout for grouped message recipient (message $messageId)');
|
debugPrint(
|
||||||
|
'⏱️ [MessagesProvider] Timeout for grouped message recipient (message $messageId)',
|
||||||
|
);
|
||||||
// Check if this specific recipient is still pending
|
// Check if this specific recipient is still pending
|
||||||
final recipients = _ackTagToRecipients[expectedAckTag];
|
final recipients = _ackTagToRecipients[expectedAckTag];
|
||||||
if (recipients != null && recipients.isNotEmpty) {
|
if (recipients != null && recipients.isNotEmpty) {
|
||||||
@@ -902,10 +968,13 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (recipientIndex >= 0) {
|
if (recipientIndex >= 0) {
|
||||||
final (timeoutGroupId, timeoutRecipientKey) = recipients[recipientIndex];
|
final (timeoutGroupId, timeoutRecipientKey) =
|
||||||
|
recipients[recipientIndex];
|
||||||
debugPrint(' ⚠️ Timeout fired - marking recipient as failed');
|
debugPrint(' ⚠️ Timeout fired - marking recipient as failed');
|
||||||
debugPrint(' Group: $timeoutGroupId');
|
debugPrint(' Group: $timeoutGroupId');
|
||||||
debugPrint(' Recipient: ${timeoutRecipientKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}');
|
debugPrint(
|
||||||
|
' Recipient: ${timeoutRecipientKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}',
|
||||||
|
);
|
||||||
|
|
||||||
// Mark this specific recipient as failed
|
// Mark this specific recipient as failed
|
||||||
updateGroupedMessageRecipientStatus(
|
updateGroupedMessageRecipientStatus(
|
||||||
@@ -925,7 +994,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_groupedMessageMapping.remove(messageId);
|
_groupedMessageMapping.remove(messageId);
|
||||||
_timeoutTimers.remove(messageId);
|
_timeoutTimers.remove(messageId);
|
||||||
} else {
|
} else {
|
||||||
debugPrint(' ✅ ACK already received for this recipient - ignoring timeout');
|
debugPrint(
|
||||||
|
' ✅ ACK already received for this recipient - ignoring timeout',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
debugPrint(' ✅ All ACKs already received - ignoring timeout');
|
debugPrint(' ✅ All ACKs already received - ignoring timeout');
|
||||||
@@ -1032,6 +1103,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final updatedMessage = message.copyWith(
|
final updatedMessage = message.copyWith(
|
||||||
echoCount: echoCount,
|
echoCount: echoCount,
|
||||||
firstEchoAt: message.firstEchoAt ?? DateTime.now(),
|
firstEchoAt: message.firstEchoAt ?? DateTime.now(),
|
||||||
|
lastEchoSnrRaw: snrRaw.toSigned(8),
|
||||||
|
lastEchoRssiDbm: rssiDbm.toSigned(8),
|
||||||
|
lastEchoAt: DateTime.now(),
|
||||||
);
|
);
|
||||||
_messages[index] = updatedMessage;
|
_messages[index] = updatedMessage;
|
||||||
|
|
||||||
@@ -1052,7 +1126,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
int? roundTripTimeMs,
|
int? roundTripTimeMs,
|
||||||
DateTime? deliveredAt,
|
DateTime? deliveredAt,
|
||||||
}) {
|
}) {
|
||||||
debugPrint('🔄 [MessagesProvider] updateGroupedMessageRecipientStatus called');
|
debugPrint(
|
||||||
|
'🔄 [MessagesProvider] updateGroupedMessageRecipientStatus called',
|
||||||
|
);
|
||||||
debugPrint(' Group ID: $groupId');
|
debugPrint(' Group ID: $groupId');
|
||||||
debugPrint(' New status: $newStatus');
|
debugPrint(' New status: $newStatus');
|
||||||
debugPrint(' RTT: ${roundTripTimeMs}ms');
|
debugPrint(' RTT: ${roundTripTimeMs}ms');
|
||||||
@@ -1060,7 +1136,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final index = _messages.indexWhere((m) => m.id == groupId);
|
final index = _messages.indexWhere((m) => m.id == groupId);
|
||||||
if (index == -1) {
|
if (index == -1) {
|
||||||
debugPrint('⚠️ [MessagesProvider] Grouped message not found: $groupId');
|
debugPrint('⚠️ [MessagesProvider] Grouped message not found: $groupId');
|
||||||
debugPrint(' Available message IDs: ${_messages.take(5).map((m) => m.id).join(", ")}');
|
debugPrint(
|
||||||
|
' Available message IDs: ${_messages.take(5).map((m) => m.id).join(", ")}',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1068,7 +1146,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
debugPrint(' ✅ Found grouped message at index $index');
|
debugPrint(' ✅ Found grouped message at index $index');
|
||||||
|
|
||||||
if (!message.isGroupedMessage) {
|
if (!message.isGroupedMessage) {
|
||||||
debugPrint('⚠️ [MessagesProvider] Message is not a grouped message: $groupId');
|
debugPrint(
|
||||||
|
'⚠️ [MessagesProvider] Message is not a grouped message: $groupId',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1094,7 +1174,11 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
return recipient.copyWith(
|
return recipient.copyWith(
|
||||||
deliveryStatus: newStatus,
|
deliveryStatus: newStatus,
|
||||||
roundTripTimeMs: roundTripTimeMs,
|
roundTripTimeMs: roundTripTimeMs,
|
||||||
deliveredAt: deliveredAt ?? (newStatus == MessageDeliveryStatus.delivered ? DateTime.now() : null),
|
deliveredAt:
|
||||||
|
deliveredAt ??
|
||||||
|
(newStatus == MessageDeliveryStatus.delivered
|
||||||
|
? DateTime.now()
|
||||||
|
: null),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1103,10 +1187,14 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
if (!recipientFound) {
|
if (!recipientFound) {
|
||||||
debugPrint(' ⚠️ Recipient not found in recipients list!');
|
debugPrint(' ⚠️ Recipient not found in recipients list!');
|
||||||
debugPrint(' Looking for key: ${recipientPublicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}');
|
debugPrint(
|
||||||
|
' Looking for key: ${recipientPublicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}',
|
||||||
|
);
|
||||||
debugPrint(' Available recipients:');
|
debugPrint(' Available recipients:');
|
||||||
for (final r in message.recipients!) {
|
for (final r in message.recipients!) {
|
||||||
debugPrint(' - ${r.displayName}: ${r.publicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}');
|
debugPrint(
|
||||||
|
' - ${r.displayName}: ${r.publicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1115,14 +1203,26 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Update overall message status based on recipients
|
// Update overall message status based on recipients
|
||||||
MessageDeliveryStatus overallStatus;
|
MessageDeliveryStatus overallStatus;
|
||||||
final allDelivered = updatedRecipients.every((r) => r.deliveryStatus == MessageDeliveryStatus.delivered);
|
final allDelivered = updatedRecipients.every(
|
||||||
final anyFailed = updatedRecipients.any((r) => r.deliveryStatus == MessageDeliveryStatus.failed);
|
(r) => r.deliveryStatus == MessageDeliveryStatus.delivered,
|
||||||
final anySending = updatedRecipients.any((r) => r.deliveryStatus == MessageDeliveryStatus.sending);
|
);
|
||||||
|
final anyFailed = updatedRecipients.any(
|
||||||
|
(r) => r.deliveryStatus == MessageDeliveryStatus.failed,
|
||||||
|
);
|
||||||
|
final anySending = updatedRecipients.any(
|
||||||
|
(r) => r.deliveryStatus == MessageDeliveryStatus.sending,
|
||||||
|
);
|
||||||
|
|
||||||
debugPrint(' Status counts:');
|
debugPrint(' Status counts:');
|
||||||
debugPrint(' Delivered: ${updatedRecipients.where((r) => r.deliveryStatus == MessageDeliveryStatus.delivered).length}');
|
debugPrint(
|
||||||
debugPrint(' Sent/Pending: ${updatedRecipients.where((r) => r.deliveryStatus == MessageDeliveryStatus.sent || r.deliveryStatus == MessageDeliveryStatus.sending).length}');
|
' Delivered: ${updatedRecipients.where((r) => r.deliveryStatus == MessageDeliveryStatus.delivered).length}',
|
||||||
debugPrint(' Failed: ${updatedRecipients.where((r) => r.deliveryStatus == MessageDeliveryStatus.failed).length}');
|
);
|
||||||
|
debugPrint(
|
||||||
|
' Sent/Pending: ${updatedRecipients.where((r) => r.deliveryStatus == MessageDeliveryStatus.sent || r.deliveryStatus == MessageDeliveryStatus.sending).length}',
|
||||||
|
);
|
||||||
|
debugPrint(
|
||||||
|
' Failed: ${updatedRecipients.where((r) => r.deliveryStatus == MessageDeliveryStatus.failed).length}',
|
||||||
|
);
|
||||||
|
|
||||||
if (allDelivered) {
|
if (allDelivered) {
|
||||||
overallStatus = MessageDeliveryStatus.delivered;
|
overallStatus = MessageDeliveryStatus.delivered;
|
||||||
@@ -1148,9 +1248,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
'🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms',
|
'🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms',
|
||||||
);
|
);
|
||||||
debugPrint(
|
debugPrint(' Checking recipient list for ACK $ackCode...');
|
||||||
' Checking recipient list for ACK $ackCode...',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if this ACK is for grouped message recipient(s)
|
// Check if this ACK is for grouped message recipient(s)
|
||||||
final recipients = _ackTagToRecipients[ackCode];
|
final recipients = _ackTagToRecipients[ackCode];
|
||||||
@@ -1158,13 +1256,18 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
// Pop the first recipient from the list (FIFO order)
|
// Pop the first recipient from the list (FIFO order)
|
||||||
// This matches the order in which messages were sent
|
// This matches the order in which messages were sent
|
||||||
final (groupId, recipientPublicKey) = recipients.removeAt(0);
|
final (groupId, recipientPublicKey) = recipients.removeAt(0);
|
||||||
debugPrint(' ✅ Found recipient in list: group $groupId, recipient ${recipientPublicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}');
|
debugPrint(
|
||||||
debugPrint(' 📊 Remaining recipients for ACK $ackCode: ${recipients.length}');
|
' ✅ Found recipient in list: group $groupId, recipient ${recipientPublicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}',
|
||||||
|
);
|
||||||
|
debugPrint(
|
||||||
|
' 📊 Remaining recipients for ACK $ackCode: ${recipients.length}',
|
||||||
|
);
|
||||||
|
|
||||||
// Find the message ID for this recipient to cancel its timeout
|
// Find the message ID for this recipient to cancel its timeout
|
||||||
String? messageIdToCancel;
|
String? messageIdToCancel;
|
||||||
for (final entry in _groupedMessageMapping.entries) {
|
for (final entry in _groupedMessageMapping.entries) {
|
||||||
if (entry.value.$1 == groupId && _listEquals(entry.value.$2, recipientPublicKey)) {
|
if (entry.value.$1 == groupId &&
|
||||||
|
_listEquals(entry.value.$2, recipientPublicKey)) {
|
||||||
messageIdToCancel = entry.key;
|
messageIdToCancel = entry.key;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1188,7 +1291,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Clean up if no more recipients for this ACK
|
// Clean up if no more recipients for this ACK
|
||||||
if (recipients.isEmpty) {
|
if (recipients.isEmpty) {
|
||||||
debugPrint(' 🧹 All recipients processed for ACK $ackCode, cleaning up');
|
debugPrint(
|
||||||
|
' 🧹 All recipients processed for ACK $ackCode, cleaning up',
|
||||||
|
);
|
||||||
_ackTagToRecipients.remove(ackCode);
|
_ackTagToRecipients.remove(ackCode);
|
||||||
_pendingSentMessages.remove(ackCode);
|
_pendingSentMessages.remove(ackCode);
|
||||||
}
|
}
|
||||||
@@ -1205,9 +1310,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Not a grouped message, check for single message
|
// Not a grouped message, check for single message
|
||||||
debugPrint(
|
debugPrint(' Not in simple mapping, checking pending messages...');
|
||||||
' Not in simple mapping, checking pending messages...',
|
|
||||||
);
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' Current pending messages: ${_pendingSentMessages.keys.toList()}',
|
' Current pending messages: ${_pendingSentMessages.keys.toList()}',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../models/contact.dart';
|
||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.dart';
|
||||||
import '../services/voice_codec_service.dart';
|
import '../services/voice_codec_service.dart';
|
||||||
import '../services/voice_player_service.dart';
|
import '../services/voice_player_service.dart';
|
||||||
@@ -32,8 +35,10 @@ class VoiceSession {
|
|||||||
|
|
||||||
/// Manages incoming voice packet sessions and coordinates playback.
|
/// Manages incoming voice packet sessions and coordinates playback.
|
||||||
class VoiceProvider with ChangeNotifier {
|
class VoiceProvider with ChangeNotifier {
|
||||||
|
static const String _voiceSessionsStorageKey = 'stored_voice_sessions_v1';
|
||||||
final VoiceCodecService _codec;
|
final VoiceCodecService _codec;
|
||||||
final VoicePlayerService _player;
|
final VoicePlayerService _player;
|
||||||
|
late final StreamSubscription<void> _playerEventsSub;
|
||||||
|
|
||||||
/// Active sessions keyed by sessionId.
|
/// Active sessions keyed by sessionId.
|
||||||
final Map<String, VoiceSession> _sessions = {};
|
final Map<String, VoiceSession> _sessions = {};
|
||||||
@@ -41,18 +46,52 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
/// Currently playing session ID, or null.
|
/// Currently playing session ID, or null.
|
||||||
String? _playingSessionId;
|
String? _playingSessionId;
|
||||||
|
|
||||||
|
/// Hook for sending a raw voice payload to a destination contact path.
|
||||||
|
Future<void> Function({
|
||||||
|
required Uint8List contactPath,
|
||||||
|
required int contactPathLen,
|
||||||
|
required Uint8List payload,
|
||||||
|
})?
|
||||||
|
sendRawPacketCallback;
|
||||||
|
|
||||||
|
final Map<String, _OutgoingVoiceSession> _outgoingSessions = {};
|
||||||
|
|
||||||
VoiceProvider({
|
VoiceProvider({
|
||||||
required VoiceCodecService codec,
|
required VoiceCodecService codec,
|
||||||
required VoicePlayerService player,
|
required VoicePlayerService player,
|
||||||
}) : _codec = codec,
|
}) : _codec = codec,
|
||||||
_player = player;
|
_player = player {
|
||||||
|
_playerEventsSub = _player.events.listen((_) {
|
||||||
|
if (_playingSessionId != null &&
|
||||||
|
!_player.isPlaying &&
|
||||||
|
_player.duration.inMilliseconds > 0 &&
|
||||||
|
_player.position >= _player.duration) {
|
||||||
|
_playingSessionId = null;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
});
|
||||||
|
_restorePersistedVoiceData();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Session accessors ────────────────────────────────────────────────────
|
// ── Session accessors ────────────────────────────────────────────────────
|
||||||
|
|
||||||
VoiceSession? session(String sessionId) => _sessions[sessionId];
|
VoiceSession? session(String sessionId) => _sessions[sessionId];
|
||||||
bool isComplete(String sessionId) => _sessions[sessionId]?.isComplete ?? false;
|
bool isComplete(String sessionId) =>
|
||||||
bool isPlaying(String sessionId) =>
|
_sessions[sessionId]?.isComplete ?? false;
|
||||||
_playingSessionId == sessionId && _player.isPlaying;
|
bool isPlaying(String sessionId) => _playingSessionId == sessionId;
|
||||||
|
Duration get playbackPosition => _player.position;
|
||||||
|
Duration get playbackDuration => _player.duration;
|
||||||
|
|
||||||
|
double playbackProgress(String sessionId) {
|
||||||
|
if (_playingSessionId != sessionId) return 0.0;
|
||||||
|
final totalMs = _player.duration.inMilliseconds;
|
||||||
|
if (totalMs <= 0) return 0.0;
|
||||||
|
final posMs = _player.position.inMilliseconds.clamp(0, totalMs);
|
||||||
|
return posMs / totalMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasOutgoingSession(String sessionId) =>
|
||||||
|
_outgoingSessions.containsKey(sessionId);
|
||||||
|
|
||||||
// ── Packet reception ─────────────────────────────────────────────────────
|
// ── Packet reception ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -74,10 +113,61 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final justComplete = session.isComplete;
|
final justComplete = session.isComplete;
|
||||||
|
_persistVoiceData();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return justComplete;
|
return justComplete;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cache encoded packets for deferred voice serving.
|
||||||
|
void cacheOutgoingSession(String sessionId, List<VoicePacket> packets) {
|
||||||
|
if (packets.isEmpty) return;
|
||||||
|
_outgoingSessions[sessionId] = _OutgoingVoiceSession(
|
||||||
|
sessionId: sessionId,
|
||||||
|
packets: List<VoicePacket>.from(packets),
|
||||||
|
);
|
||||||
|
_persistVoiceData();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream a cached voice session to a requester over raw direct packets.
|
||||||
|
Future<bool> serveSessionTo({
|
||||||
|
required String sessionId,
|
||||||
|
required Contact requester,
|
||||||
|
}) async {
|
||||||
|
final cached = _outgoingSessions[sessionId];
|
||||||
|
if (cached == null) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [VoiceProvider] No cached outgoing session for $sessionId',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (sendRawPacketCallback == null) {
|
||||||
|
debugPrint('⚠️ [VoiceProvider] sendRawPacketCallback is not set');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (requester.outPathLen < 0) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [VoiceProvider] Requester ${requester.advName} has no direct path',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final packet in cached.packets) {
|
||||||
|
try {
|
||||||
|
await sendRawPacketCallback!(
|
||||||
|
contactPath: requester.outPath,
|
||||||
|
contactPathLen: requester.outPathLen,
|
||||||
|
payload: packet.encodeBinary(),
|
||||||
|
);
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint(
|
||||||
|
'❌ [VoiceProvider] Failed serving packet for $sessionId: $e\n$st',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Playback ─────────────────────────────────────────────────────────────
|
// ── Playback ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Decode and play the voice session with [sessionId].
|
/// Decode and play the voice session with [sessionId].
|
||||||
@@ -85,11 +175,15 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
Future<void> play(String sessionId) async {
|
Future<void> play(String sessionId) async {
|
||||||
final session = _sessions[sessionId];
|
final session = _sessions[sessionId];
|
||||||
if (session == null) {
|
if (session == null) {
|
||||||
debugPrint('❌ [VoiceProvider] play($sessionId) — session not found, known: ${_sessions.keys.toList()}');
|
debugPrint(
|
||||||
|
'❌ [VoiceProvider] play($sessionId) — session not found, known: ${_sessions.keys.toList()}',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('🎙️ [VoiceProvider] play($sessionId): ${session.receivedCount}/${session.total} packets, mode=${session.mode.label}');
|
debugPrint(
|
||||||
|
'🎙️ [VoiceProvider] play($sessionId): ${session.receivedCount}/${session.total} packets, mode=${session.mode.label}',
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final pcm = await _codec.decodePackets(session.packets, session.mode);
|
final pcm = await _codec.decodePackets(session.packets, session.mode);
|
||||||
@@ -99,7 +193,6 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
await _player.play(pcm);
|
await _player.play(pcm);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
|
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
|
||||||
} finally {
|
|
||||||
if (_playingSessionId == sessionId) {
|
if (_playingSessionId == sessionId) {
|
||||||
_playingSessionId = null;
|
_playingSessionId = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -113,9 +206,127 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> clearStoredVoiceData() async {
|
||||||
|
_sessions.clear();
|
||||||
|
_outgoingSessions.clear();
|
||||||
|
_playingSessionId = null;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_voiceSessionsStorageKey);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [VoiceProvider] Failed to clear stored voice data: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistVoiceData() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final payload = <String, dynamic>{
|
||||||
|
'incoming': _sessions.values
|
||||||
|
.map(
|
||||||
|
(session) => {
|
||||||
|
'sessionId': session.sessionId,
|
||||||
|
'modeId': session.mode.id,
|
||||||
|
'total': session.total,
|
||||||
|
'packets': session.packets
|
||||||
|
.map((p) => p?.encodeText())
|
||||||
|
.toList(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
'outgoing': _outgoingSessions.values
|
||||||
|
.map(
|
||||||
|
(session) => {
|
||||||
|
'sessionId': session.sessionId,
|
||||||
|
'packets': session.packets.map((p) => p.encodeText()).toList(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
};
|
||||||
|
await prefs.setString(_voiceSessionsStorageKey, jsonEncode(payload));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [VoiceProvider] Failed to persist voice data: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _restorePersistedVoiceData() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = prefs.getString(_voiceSessionsStorageKey);
|
||||||
|
if (raw == null || raw.isEmpty) return;
|
||||||
|
|
||||||
|
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||||
|
|
||||||
|
final incoming = parsed['incoming'] as List<dynamic>? ?? const [];
|
||||||
|
for (final item in incoming) {
|
||||||
|
final map = item as Map<String, dynamic>;
|
||||||
|
final sessionId = map['sessionId'] as String?;
|
||||||
|
final modeId = map['modeId'] as int?;
|
||||||
|
final total = map['total'] as int?;
|
||||||
|
if (sessionId == null || modeId == null || total == null || total <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final mode = VoicePacketMode.fromId(modeId);
|
||||||
|
final session = VoiceSession(
|
||||||
|
sessionId: sessionId,
|
||||||
|
mode: mode,
|
||||||
|
total: total,
|
||||||
|
);
|
||||||
|
final packets = map['packets'] as List<dynamic>? ?? const [];
|
||||||
|
for (var i = 0; i < packets.length && i < session.total; i++) {
|
||||||
|
final encoded = packets[i] as String?;
|
||||||
|
if (encoded == null || encoded.isEmpty) continue;
|
||||||
|
final packet = VoicePacket.tryParseText(encoded);
|
||||||
|
if (packet != null && packet.index < session.total) {
|
||||||
|
session.packets[packet.index] = packet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_sessions[sessionId] = session;
|
||||||
|
}
|
||||||
|
|
||||||
|
final outgoing = parsed['outgoing'] as List<dynamic>? ?? const [];
|
||||||
|
for (final item in outgoing) {
|
||||||
|
final map = item as Map<String, dynamic>;
|
||||||
|
final sessionId = map['sessionId'] as String?;
|
||||||
|
if (sessionId == null || sessionId.isEmpty) continue;
|
||||||
|
final packetsRaw = map['packets'] as List<dynamic>? ?? const [];
|
||||||
|
final packets = <VoicePacket>[];
|
||||||
|
for (final encoded in packetsRaw) {
|
||||||
|
final packet = VoicePacket.tryParseText((encoded ?? '') as String);
|
||||||
|
if (packet != null) packets.add(packet);
|
||||||
|
}
|
||||||
|
if (packets.isNotEmpty) {
|
||||||
|
_outgoingSessions[sessionId] = _OutgoingVoiceSession(
|
||||||
|
sessionId: sessionId,
|
||||||
|
packets: packets,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
debugPrint(
|
||||||
|
'🎙️ [VoiceProvider] Restored ${_sessions.length} incoming and ${_outgoingSessions.length} outgoing voice sessions',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [VoiceProvider] Failed to restore voice data: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_playerEventsSub.cancel();
|
||||||
_player.dispose();
|
_player.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _OutgoingVoiceSession {
|
||||||
|
final String sessionId;
|
||||||
|
final List<VoicePacket> packets;
|
||||||
|
|
||||||
|
const _OutgoingVoiceSession({
|
||||||
|
required this.sessionId,
|
||||||
|
required this.packets,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import '../widgets/messages/sar_update_sheet.dart';
|
|||||||
import '../widgets/messages/recipient_selector_sheet.dart';
|
import '../widgets/messages/recipient_selector_sheet.dart';
|
||||||
import '../widgets/messages/message_bubble.dart';
|
import '../widgets/messages/message_bubble.dart';
|
||||||
import '../services/message_destination_preferences.dart';
|
import '../services/message_destination_preferences.dart';
|
||||||
|
import '../services/voice_bitrate_preferences.dart';
|
||||||
import '../services/voice_recorder_service.dart';
|
import '../services/voice_recorder_service.dart';
|
||||||
import '../services/voice_codec_service.dart';
|
import '../services/voice_codec_service.dart';
|
||||||
import '../utils/toast_logger.dart';
|
import '../utils/toast_logger.dart';
|
||||||
@@ -54,11 +55,15 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
bool _isRecording = false;
|
bool _isRecording = false;
|
||||||
bool _isSendingVoice = false;
|
bool _isSendingVoice = false;
|
||||||
static const int _maxVoicePackets = 10;
|
static const int _maxVoicePackets = 10;
|
||||||
|
static const double _silenceRmsThreshold = 500.0;
|
||||||
|
static const double _silencePeakThreshold = 1400.0;
|
||||||
|
static const int _maxInteriorSilentChunks = 1;
|
||||||
bool get _voiceSupported => Platform.isIOS;
|
bool get _voiceSupported => Platform.isIOS;
|
||||||
StreamSubscription<Int16List>? _voiceStreamSub;
|
StreamSubscription<Int16List>? _voiceStreamSub;
|
||||||
String? _currentVoiceSessionId;
|
String? _currentVoiceSessionId;
|
||||||
final List<Int16List> _recordedChunks = [];
|
final List<Int16List> _recordedChunks = [];
|
||||||
VoicePacketMode? _activeVoiceMode;
|
VoicePacketMode? _activeVoiceMode;
|
||||||
|
int _selectedVoiceBitrate = VoiceBitratePreferences.defaultBitrate;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -66,6 +71,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
_textController.addListener(_updateCharacterCount);
|
_textController.addListener(_updateCharacterCount);
|
||||||
// Load saved message destination
|
// Load saved message destination
|
||||||
_loadSavedDestination();
|
_loadSavedDestination();
|
||||||
|
_loadVoiceBitrate();
|
||||||
// Mark all messages as read when tab is opened
|
// Mark all messages as read when tab is opened
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.read<MessagesProvider>().markAllAsRead();
|
context.read<MessagesProvider>().markAllAsRead();
|
||||||
@@ -73,6 +79,14 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadVoiceBitrate() async {
|
||||||
|
final bitrate = await VoiceBitratePreferences.getBitrate();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_selectedVoiceBitrate = bitrate;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
@@ -409,6 +423,15 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
Future<void> _startVoiceRecording() async {
|
Future<void> _startVoiceRecording() async {
|
||||||
if (_isSendingVoice || _isRecording) return;
|
if (_isSendingVoice || _isRecording) return;
|
||||||
debugPrint('🎙️ [Voice] _startVoiceRecording called');
|
debugPrint('🎙️ [Voice] _startVoiceRecording called');
|
||||||
|
// Read fresh bitrate preference so settings changes apply immediately.
|
||||||
|
final selectedBitrate = await VoiceBitratePreferences.getBitrate();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_selectedVoiceBitrate = selectedBitrate;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_selectedVoiceBitrate = selectedBitrate;
|
||||||
|
}
|
||||||
final hasPermission = await _voiceRecorder.requestPermission();
|
final hasPermission = await _voiceRecorder.requestPermission();
|
||||||
debugPrint('🎙️ [Voice] hasPermission=$hasPermission');
|
debugPrint('🎙️ [Voice] hasPermission=$hasPermission');
|
||||||
if (!hasPermission) {
|
if (!hasPermission) {
|
||||||
@@ -418,6 +441,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
final appProvider = context.read<AppProvider>();
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🎙️ [Voice] isConnected=${connectionProvider.deviceInfo.isConnected}',
|
'🎙️ [Voice] isConnected=${connectionProvider.deviceInfo.isConnected}',
|
||||||
@@ -434,8 +458,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
(_) => rng.nextInt(256),
|
(_) => rng.nextInt(256),
|
||||||
).map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
).map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
|
||||||
final radioBwKhz = connectionProvider.deviceInfo.radioBw ?? 125;
|
_activeVoiceMode = VoiceBitratePreferences.toVoiceMode(
|
||||||
_activeVoiceMode = voiceModeForBandwidth(radioBwKhz * 1000);
|
_selectedVoiceBitrate,
|
||||||
|
);
|
||||||
final packetDuration = Duration(
|
final packetDuration = Duration(
|
||||||
milliseconds: codec2ModeFor(_activeVoiceMode!).packetDurationMs,
|
milliseconds: codec2ModeFor(_activeVoiceMode!).packetDurationMs,
|
||||||
);
|
);
|
||||||
@@ -448,7 +473,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
setState(() => _isRecording = true);
|
setState(() => _isRecording = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final stream = _voiceRecorder.startCapture(chunkDuration: packetDuration);
|
final stream = _voiceRecorder.startCapture(
|
||||||
|
chunkDuration: packetDuration,
|
||||||
|
enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled,
|
||||||
|
);
|
||||||
debugPrint('🎙️ [Voice] capture started, listening for chunks...');
|
debugPrint('🎙️ [Voice] capture started, listening for chunks...');
|
||||||
_voiceStreamSub = stream.listen(
|
_voiceStreamSub = stream.listen(
|
||||||
(pcmChunk) {
|
(pcmChunk) {
|
||||||
@@ -477,6 +505,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
|
|
||||||
Future<void> _stopAndSendVoice() async {
|
Future<void> _stopAndSendVoice() async {
|
||||||
if (!_isRecording) return;
|
if (!_isRecording) return;
|
||||||
|
final trimSilenceEnabled = context
|
||||||
|
.read<AppProvider>()
|
||||||
|
.isVoiceSilenceTrimmingEnabled;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🎙️ [Voice] _stopAndSendVoice: ${_recordedChunks.length} chunks buffered',
|
'🎙️ [Voice] _stopAndSendVoice: ${_recordedChunks.length} chunks buffered',
|
||||||
);
|
);
|
||||||
@@ -485,11 +516,16 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
_voiceStreamSub = null;
|
_voiceStreamSub = null;
|
||||||
await _voiceRecorder.stopCapture();
|
await _voiceRecorder.stopCapture();
|
||||||
|
|
||||||
final chunks = List<Int16List>.from(_recordedChunks);
|
final rawChunks = List<Int16List>.from(_recordedChunks);
|
||||||
|
final chunks = trimSilenceEnabled ? _trimSilence(rawChunks) : rawChunks;
|
||||||
final sessionId = _currentVoiceSessionId;
|
final sessionId = _currentVoiceSessionId;
|
||||||
final mode = _activeVoiceMode;
|
final mode = _activeVoiceMode;
|
||||||
_recordedChunks.clear();
|
_recordedChunks.clear();
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'🎙️ [Voice] silence trim enabled=$trimSilenceEnabled: raw=${rawChunks.length} chunks -> kept=${chunks.length} chunks',
|
||||||
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isRecording = false;
|
_isRecording = false;
|
||||||
@@ -498,7 +534,12 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (chunks.isEmpty || sessionId == null || mode == null || !mounted) {
|
if (chunks.isEmpty || sessionId == null || mode == null || !mounted) {
|
||||||
if (mounted) setState(() { _isSendingVoice = false; _currentVoiceSessionId = null; });
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isSendingVoice = false;
|
||||||
|
_currentVoiceSessionId = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,7 +552,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('❌ [Voice] _encodeAndSendAllPackets threw: $e\n$st');
|
debugPrint('❌ [Voice] _encodeAndSendAllPackets threw: $e\n$st');
|
||||||
} finally {
|
} finally {
|
||||||
debugPrint('🎙️ [Voice] _stopAndSendVoice finally: resetting _isSendingVoice');
|
debugPrint(
|
||||||
|
'🎙️ [Voice] _stopAndSendVoice finally: resetting _isSendingVoice',
|
||||||
|
);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isSendingVoice = false;
|
_isSendingVoice = false;
|
||||||
@@ -532,10 +575,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
final messagesProvider = context.read<MessagesProvider>();
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
final voiceProvider = context.read<VoiceProvider>();
|
final voiceProvider = context.read<VoiceProvider>();
|
||||||
|
|
||||||
// Insert the chat placeholder before sending (so it appears immediately)
|
// Insert the chat placeholder before sending (so it appears immediately).
|
||||||
final msgId = 'voice_${sessionId}_sent';
|
final msgId = 'voice_${sessionId}_sent';
|
||||||
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
|
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
|
||||||
final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6);
|
final senderPublicKeyPrefix =
|
||||||
|
devicePublicKey != null && devicePublicKey.length >= 6
|
||||||
|
? devicePublicKey.sublist(0, 6)
|
||||||
|
: null;
|
||||||
final isChannel =
|
final isChannel =
|
||||||
_destinationType ==
|
_destinationType ==
|
||||||
MessageDestinationPreferences.destinationTypeChannel;
|
MessageDestinationPreferences.destinationTypeChannel;
|
||||||
@@ -558,8 +604,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
);
|
);
|
||||||
messagesProvider.addSentMessage(sentMsg);
|
messagesProvider.addSentMessage(sentMsg);
|
||||||
|
|
||||||
|
final encodedPackets = <VoicePacket>[];
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🎙️ [Voice] encoding+sending $total packets, mode=${mode.label}, session=$sessionId',
|
'🎙️ [Voice] encoding $total packets for deferred voice fetch, mode=${mode.label}, session=$sessionId',
|
||||||
);
|
);
|
||||||
for (var i = 0; i < total; i++) {
|
for (var i = 0; i < total; i++) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -576,44 +623,127 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
codec2Data: codec2Data,
|
codec2Data: codec2Data,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
encodedPackets.add(packet);
|
||||||
voiceProvider.addPacket(packet);
|
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) {
|
} catch (e, st) {
|
||||||
debugPrint('❌ [Voice] packet $i send error: $e\n$st');
|
debugPrint('❌ [Voice] packet $i encode error: $e\n$st');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
debugPrint('🎙️ [Voice] all packets sent for session $sessionId');
|
|
||||||
|
if (encodedPackets.isEmpty) {
|
||||||
|
debugPrint('❌ [Voice] No packets encoded for session $sessionId');
|
||||||
|
messagesProvider.markMessageFailed(msgId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
voiceProvider.cacheOutgoingSession(sessionId, encodedPackets);
|
||||||
|
|
||||||
|
if (senderPublicKeyPrefix == null || senderPublicKeyPrefix.length < 6) {
|
||||||
|
debugPrint('❌ [Voice] Missing device public key prefix for envelope');
|
||||||
|
messagesProvider.markMessageFailed(msgId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final senderKey6 = senderPublicKeyPrefix
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join('');
|
||||||
|
final durationMs = encodedPackets.fold<int>(
|
||||||
|
0,
|
||||||
|
(sum, p) => sum + p.durationMs,
|
||||||
|
);
|
||||||
|
final envelope = VoiceEnvelope(
|
||||||
|
sessionId: sessionId,
|
||||||
|
mode: mode,
|
||||||
|
total: encodedPackets.length,
|
||||||
|
durationMs: durationMs,
|
||||||
|
senderKey6: senderKey6,
|
||||||
|
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
version: 1,
|
||||||
|
);
|
||||||
|
final envelopeText = envelope.encodeText();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isChannel) {
|
||||||
|
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
|
||||||
|
await connectionProvider.sendChannelMessage(
|
||||||
|
channelIdx: channelIdx,
|
||||||
|
text: envelopeText,
|
||||||
|
messageId: msgId,
|
||||||
|
);
|
||||||
|
} else if (_selectedRecipient != null) {
|
||||||
|
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||||
|
contactPublicKey: _selectedRecipient!.publicKey,
|
||||||
|
text: envelopeText,
|
||||||
|
messageId: msgId,
|
||||||
|
contact: _selectedRecipient,
|
||||||
|
);
|
||||||
|
if (!sentSuccessfully) {
|
||||||
|
messagesProvider.markMessageFailed(msgId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to public channel if destination cannot be resolved.
|
||||||
|
await connectionProvider.sendChannelMessage(
|
||||||
|
channelIdx: 0,
|
||||||
|
text: envelopeText,
|
||||||
|
messageId: msgId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('❌ [Voice] envelope send error: $e\n$st');
|
||||||
|
messagesProvider.markMessageFailed(msgId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('🎙️ [Voice] envelope sent for session $sessionId');
|
||||||
// Mark the placeholder message as "sent" (ackTag=0, timeout=0 = no ACK tracking).
|
// Mark the placeholder message as "sent" (ackTag=0, timeout=0 = no ACK tracking).
|
||||||
// addSentMessage() forces deliveryStatus.sending; we upgrade it here so the
|
// addSentMessage() forces deliveryStatus.sending; we upgrade it here so the
|
||||||
// bubble shows "Sent" instead of "Sending" once all packets are on the wire.
|
// bubble shows "Sent" instead of "Sending" once all packets are on the wire.
|
||||||
|
// For channels this is also set by the onMessageSent callback, but this is harmless.
|
||||||
messagesProvider.markMessageSent(msgId, 0, 0);
|
messagesProvider.markMessageSent(msgId, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Int16List> _trimSilence(List<Int16List> chunks) {
|
||||||
|
if (chunks.isEmpty) return chunks;
|
||||||
|
|
||||||
|
final isSilent = chunks.map(_isSilentChunk).toList();
|
||||||
|
final firstVoice = isSilent.indexWhere((silent) => !silent);
|
||||||
|
if (firstVoice == -1) return const [];
|
||||||
|
|
||||||
|
final lastVoice = isSilent.lastIndexWhere((silent) => !silent);
|
||||||
|
if (lastVoice < firstVoice) return const [];
|
||||||
|
|
||||||
|
final trimmed = <Int16List>[];
|
||||||
|
var interiorSilentRun = 0;
|
||||||
|
for (var i = firstVoice; i <= lastVoice; i++) {
|
||||||
|
if (isSilent[i]) {
|
||||||
|
interiorSilentRun++;
|
||||||
|
if (interiorSilentRun <= _maxInteriorSilentChunks) {
|
||||||
|
trimmed.add(chunks[i]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
interiorSilentRun = 0;
|
||||||
|
trimmed.add(chunks[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isSilentChunk(Int16List chunk) {
|
||||||
|
if (chunk.isEmpty) return true;
|
||||||
|
|
||||||
|
var sumSquares = 0.0;
|
||||||
|
var peak = 0;
|
||||||
|
for (final sample in chunk) {
|
||||||
|
final absSample = sample.abs();
|
||||||
|
if (absSample > peak) peak = absSample;
|
||||||
|
sumSquares += sample * sample;
|
||||||
|
}
|
||||||
|
|
||||||
|
final rms = math.sqrt(sumSquares / chunk.length);
|
||||||
|
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
// ── SAR dialog ─────────────────────────────────────────────────────────────
|
// ── SAR dialog ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
void _showSarDialog() {
|
void _showSarDialog() {
|
||||||
@@ -1181,7 +1311,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
: Theme.of(context).textTheme.bodySmall?.color,
|
: Theme.of(context).textTheme.bodySmall?.color,
|
||||||
),
|
),
|
||||||
suffixIcon: GestureDetector(
|
suffixIcon: GestureDetector(
|
||||||
onLongPressStart: (_voiceSupported && !_isSendingVoice)
|
onLongPressStart:
|
||||||
|
(_voiceSupported && !_isSendingVoice)
|
||||||
? (_) => _startVoiceRecording()
|
? (_) => _startVoiceRecording()
|
||||||
: null,
|
: null,
|
||||||
onLongPressEnd: (_voiceSupported && _isRecording)
|
onLongPressEnd: (_voiceSupported && _isRecording)
|
||||||
@@ -1223,8 +1354,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
: (_isSendingVoice
|
: (_isSendingVoice
|
||||||
? 'Sending voice...'
|
? 'Sending voice...'
|
||||||
: _voiceSupported
|
: _voiceSupported
|
||||||
? 'Send (long press to record voice)'
|
? 'Send (long press to record voice)'
|
||||||
: 'Send'),
|
: 'Send'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,10 +9,7 @@ import '../l10n/app_localizations.dart';
|
|||||||
class PacketLogScreen extends StatefulWidget {
|
class PacketLogScreen extends StatefulWidget {
|
||||||
final MeshCoreBleService bleService;
|
final MeshCoreBleService bleService;
|
||||||
|
|
||||||
const PacketLogScreen({
|
const PacketLogScreen({super.key, required this.bleService});
|
||||||
super.key,
|
|
||||||
required this.bleService,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PacketLogScreen> createState() => _PacketLogScreenState();
|
State<PacketLogScreen> createState() => _PacketLogScreenState();
|
||||||
@@ -56,16 +53,18 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
|||||||
final logs = _filteredLogs;
|
final logs = _filteredLogs;
|
||||||
if (logs.isEmpty) {
|
if (logs.isEmpty) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
const SnackBar(content: Text('No logs to export')),
|
context,
|
||||||
);
|
).showSnackBar(const SnackBar(content: Text('No logs to export')));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create CSV content
|
// Create CSV content
|
||||||
final buffer = StringBuffer();
|
final buffer = StringBuffer();
|
||||||
buffer.writeln('Timestamp,Direction,Size (bytes),Opcode Name,Code,Hex Data,Description');
|
buffer.writeln(
|
||||||
|
'Timestamp,Direction,Size (bytes),Opcode Name,Code,Hex Data,Description',
|
||||||
|
);
|
||||||
for (final log in logs) {
|
for (final log in logs) {
|
||||||
buffer.writeln(log.toCsvRow());
|
buffer.writeln(log.toCsvRow());
|
||||||
}
|
}
|
||||||
@@ -73,7 +72,9 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
|||||||
// Save to temporary file
|
// Save to temporary file
|
||||||
final tempDir = await getTemporaryDirectory();
|
final tempDir = await getTemporaryDirectory();
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
final file = File('${tempDir.path}/ble_packets_${DateTime.now().millisecondsSinceEpoch}.csv');
|
final file = File(
|
||||||
|
'${tempDir.path}/ble_packets_${DateTime.now().millisecondsSinceEpoch}.csv',
|
||||||
|
);
|
||||||
await file.writeAsString(buffer.toString());
|
await file.writeAsString(buffer.toString());
|
||||||
|
|
||||||
// Share the file
|
// Share the file
|
||||||
@@ -87,9 +88,9 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('Export failed: $e')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('Export failed: $e')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,9 +100,9 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
|||||||
final logs = _filteredLogs;
|
final logs = _filteredLogs;
|
||||||
if (logs.isEmpty) {
|
if (logs.isEmpty) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
const SnackBar(content: Text('No logs to export')),
|
context,
|
||||||
);
|
).showSnackBar(const SnackBar(content: Text('No logs to export')));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -122,7 +123,9 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
|||||||
// Save to temporary file
|
// Save to temporary file
|
||||||
final tempDir = await getTemporaryDirectory();
|
final tempDir = await getTemporaryDirectory();
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
final file = File('${tempDir.path}/ble_packets_${DateTime.now().millisecondsSinceEpoch}.txt');
|
final file = File(
|
||||||
|
'${tempDir.path}/ble_packets_${DateTime.now().millisecondsSinceEpoch}.txt',
|
||||||
|
);
|
||||||
await file.writeAsString(buffer.toString());
|
await file.writeAsString(buffer.toString());
|
||||||
|
|
||||||
// Share the file
|
// Share the file
|
||||||
@@ -136,9 +139,9 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('Export failed: $e')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('Export failed: $e')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +162,9 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
title: Text(AppLocalizations.of(dialogContext)!.clearAllData),
|
title: Text(AppLocalizations.of(dialogContext)!.clearAllData),
|
||||||
content: const Text('Are you sure you want to clear all packet logs? This cannot be undone.'),
|
content: const Text(
|
||||||
|
'Are you sure you want to clear all packet logs? This cannot be undone.',
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(dialogContext),
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
@@ -204,11 +209,13 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
|||||||
actions: [
|
actions: [
|
||||||
// Direction filter
|
// Direction filter
|
||||||
PopupMenuButton<PacketDirection?>(
|
PopupMenuButton<PacketDirection?>(
|
||||||
icon: Icon(_filterDirection == null
|
icon: Icon(
|
||||||
? Icons.filter_list
|
_filterDirection == null
|
||||||
: _filterDirection == PacketDirection.rx
|
? Icons.filter_list
|
||||||
? Icons.arrow_downward
|
: _filterDirection == PacketDirection.rx
|
||||||
: Icons.arrow_upward),
|
? Icons.arrow_downward
|
||||||
|
: Icons.arrow_upward,
|
||||||
|
),
|
||||||
tooltip: 'Filter by direction',
|
tooltip: 'Filter by direction',
|
||||||
onSelected: (direction) {
|
onSelected: (direction) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -220,12 +227,21 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
|||||||
value: null,
|
value: null,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.filter_list,
|
Icon(
|
||||||
color: _filterDirection == null ? Theme.of(context).colorScheme.primary : null),
|
Icons.filter_list,
|
||||||
|
color: _filterDirection == null
|
||||||
|
? Theme.of(context).colorScheme.primary
|
||||||
|
: null,
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text('All',
|
Text(
|
||||||
style: TextStyle(
|
'All',
|
||||||
fontWeight: _filterDirection == null ? FontWeight.bold : FontWeight.normal)),
|
style: TextStyle(
|
||||||
|
fontWeight: _filterDirection == null
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -233,15 +249,21 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
|||||||
value: PacketDirection.rx,
|
value: PacketDirection.rx,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.arrow_downward,
|
Icon(
|
||||||
color: _filterDirection == PacketDirection.rx
|
Icons.arrow_downward,
|
||||||
? Theme.of(context).colorScheme.primary
|
color: _filterDirection == PacketDirection.rx
|
||||||
: null),
|
? Theme.of(context).colorScheme.primary
|
||||||
|
: null,
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text('RX (Received)',
|
Text(
|
||||||
style: TextStyle(
|
'RX (Received)',
|
||||||
fontWeight:
|
style: TextStyle(
|
||||||
_filterDirection == PacketDirection.rx ? FontWeight.bold : FontWeight.normal)),
|
fontWeight: _filterDirection == PacketDirection.rx
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -249,15 +271,21 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
|||||||
value: PacketDirection.tx,
|
value: PacketDirection.tx,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.arrow_upward,
|
Icon(
|
||||||
color: _filterDirection == PacketDirection.tx
|
Icons.arrow_upward,
|
||||||
? Theme.of(context).colorScheme.primary
|
color: _filterDirection == PacketDirection.tx
|
||||||
: null),
|
? Theme.of(context).colorScheme.primary
|
||||||
|
: null,
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text('TX (Sent)',
|
Text(
|
||||||
style: TextStyle(
|
'TX (Sent)',
|
||||||
fontWeight:
|
style: TextStyle(
|
||||||
_filterDirection == PacketDirection.tx ? FontWeight.bold : FontWeight.normal)),
|
fontWeight: _filterDirection == PacketDirection.tx
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -265,7 +293,11 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
|||||||
),
|
),
|
||||||
// Auto-scroll toggle
|
// Auto-scroll toggle
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center),
|
icon: Icon(
|
||||||
|
_autoScroll
|
||||||
|
? Icons.vertical_align_bottom
|
||||||
|
: Icons.vertical_align_center,
|
||||||
|
),
|
||||||
tooltip: _autoScroll ? 'Disable auto-scroll' : 'Enable auto-scroll',
|
tooltip: _autoScroll ? 'Disable auto-scroll' : 'Enable auto-scroll',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -352,11 +384,7 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(Icons.list_alt, size: 64, color: Colors.grey[400]),
|
||||||
Icons.list_alt,
|
|
||||||
size: 64,
|
|
||||||
color: Colors.grey[400],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
_searchQuery.isNotEmpty || _filterDirection != null
|
_searchQuery.isNotEmpty || _filterDirection != null
|
||||||
@@ -367,7 +395,8 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
|||||||
color: Colors.grey[600],
|
color: Colors.grey[600],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_searchQuery.isNotEmpty || _filterDirection != null) ...[
|
if (_searchQuery.isNotEmpty ||
|
||||||
|
_filterDirection != null) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -420,15 +449,13 @@ class _PacketLogCard extends StatelessWidget {
|
|||||||
final BlePacketLog log;
|
final BlePacketLog log;
|
||||||
final VoidCallback onCopy;
|
final VoidCallback onCopy;
|
||||||
|
|
||||||
const _PacketLogCard({
|
const _PacketLogCard({required this.log, required this.onCopy});
|
||||||
required this.log,
|
|
||||||
required this.onCopy,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isRx = log.direction == PacketDirection.rx;
|
final isRx = log.direction == PacketDirection.rx;
|
||||||
final directionColor = isRx ? Colors.green : Colors.blue;
|
final directionColor = isRx ? Colors.green : Colors.blue;
|
||||||
|
final rxInfo = log.logRxDataInfo;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
@@ -480,67 +507,168 @@ class _PacketLogCard extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// Hex data
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Hex: ',
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.grey[700],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: SelectableText(
|
|
||||||
log.hexData,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.copy, size: 18),
|
|
||||||
tooltip: 'Copy hex data',
|
|
||||||
onPressed: onCopy,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
constraints: const BoxConstraints(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
// Metadata
|
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 16,
|
spacing: 10,
|
||||||
runSpacing: 8,
|
runSpacing: 10,
|
||||||
children: [
|
children: [
|
||||||
_InfoChip(
|
_FactCard(
|
||||||
icon: Icons.schedule,
|
icon: isRx ? Icons.call_received : Icons.call_made,
|
||||||
label: log.timestamp.toIso8601String(),
|
label: 'Direction',
|
||||||
|
value: isRx ? 'RX' : 'TX',
|
||||||
|
accent: directionColor,
|
||||||
),
|
),
|
||||||
_InfoChip(
|
_FactCard(
|
||||||
icon: Icons.data_usage,
|
icon: Icons.data_object,
|
||||||
label: '${log.rawData.length} bytes',
|
label: 'Size',
|
||||||
|
value: '${log.rawData.length} bytes',
|
||||||
|
),
|
||||||
|
_FactCard(
|
||||||
|
icon: Icons.schedule,
|
||||||
|
label: 'Captured',
|
||||||
|
value: _formatTimestamp(log.timestamp),
|
||||||
),
|
),
|
||||||
if (log.responseCode != null)
|
if (log.responseCode != null)
|
||||||
_InfoChip(
|
_FactCard(
|
||||||
icon: Icons.tag,
|
icon: Icons.sell,
|
||||||
label: log.opcodeDescription,
|
label: 'Opcode',
|
||||||
),
|
value: log.opcodeName,
|
||||||
// Show RSSI and SNR for LOG_RX_DATA packets
|
|
||||||
if (log.logRxDataInfo?.rssiDbm != null)
|
|
||||||
_InfoChip(
|
|
||||||
icon: Icons.signal_cellular_alt,
|
|
||||||
label: 'RSSI: ${log.logRxDataInfo!.rssiDbm} dBm',
|
|
||||||
),
|
|
||||||
if (log.logRxDataInfo?.snrDb != null)
|
|
||||||
_InfoChip(
|
|
||||||
icon: Icons.waves,
|
|
||||||
label: 'SNR: ${log.logRxDataInfo!.snrDb!.toStringAsFixed(1)} dB',
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (rxInfo?.rssiDbm != null || rxInfo?.snrDb != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Link Quality',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
if (rxInfo?.rssiDbm != null)
|
||||||
|
_SignalMeter(
|
||||||
|
label: 'RSSI',
|
||||||
|
valueLabel: '${rxInfo!.rssiDbm} dBm',
|
||||||
|
normalized: _normalizeRssi(
|
||||||
|
rxInfo.rssiDbm!.toDouble(),
|
||||||
|
),
|
||||||
|
color: _rssiColor(rxInfo.rssiDbm!.toDouble()),
|
||||||
|
),
|
||||||
|
if (rxInfo?.snrDb != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_SignalMeter(
|
||||||
|
label: 'SNR',
|
||||||
|
valueLabel:
|
||||||
|
'${rxInfo!.snrDb!.toStringAsFixed(1)} dB',
|
||||||
|
normalized: _normalizeSnr(rxInfo.snrDb!),
|
||||||
|
color: _snrColor(rxInfo.snrDb!),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).dividerColor.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.grid_view_rounded, size: 16),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
const Text(
|
||||||
|
'Hex Explorer',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
onPressed: onCopy,
|
||||||
|
tooltip: 'Copy full hex',
|
||||||
|
icon: const Icon(Icons.copy_all_rounded, size: 18),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < log.rawData.length; i++)
|
||||||
|
_HexByteChip(
|
||||||
|
index: i,
|
||||||
|
value: log.rawData[i],
|
||||||
|
onTap: () {
|
||||||
|
_copyText(
|
||||||
|
context,
|
||||||
|
log.rawData[i]
|
||||||
|
.toRadixString(16)
|
||||||
|
.padLeft(2, '0')
|
||||||
|
.toUpperCase(),
|
||||||
|
'Byte ${i.toString().padLeft(2, '0')} copied',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ExpansionTile(
|
||||||
|
tilePadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
title: const Text(
|
||||||
|
'Raw stream',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.35),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: SelectableText(
|
||||||
|
log.hexData,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -563,27 +691,176 @@ class _PacketLogCard extends StatelessWidget {
|
|||||||
return '${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}:${timestamp.second.toString().padLeft(2, '0')}';
|
return '${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}:${timestamp.second.toString().padLeft(2, '0')}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void _copyText(BuildContext context, String text, String message) {
|
||||||
|
Clipboard.setData(ClipboardData(text: text));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
duration: const Duration(milliseconds: 900),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _normalizeRssi(double rssi) {
|
||||||
|
return ((rssi + 120.0) / 70.0).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _normalizeSnr(double snr) {
|
||||||
|
return ((snr + 20.0) / 40.0).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color _rssiColor(double rssi) {
|
||||||
|
if (rssi >= -80) return Colors.green;
|
||||||
|
if (rssi >= -95) return Colors.amber;
|
||||||
|
return Colors.redAccent;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color _snrColor(double snr) {
|
||||||
|
if (snr >= 10) return Colors.green;
|
||||||
|
if (snr >= 0) return Colors.amber;
|
||||||
|
return Colors.redAccent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _InfoChip extends StatelessWidget {
|
class _FactCard extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final Color? accent;
|
||||||
|
|
||||||
const _InfoChip({
|
const _FactCard({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.label,
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
this.accent,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Chip(
|
final tileColor = accent ?? Theme.of(context).colorScheme.primary;
|
||||||
avatar: Icon(icon, size: 16),
|
return Container(
|
||||||
label: Text(
|
constraints: const BoxConstraints(minWidth: 108),
|
||||||
label,
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
style: const TextStyle(fontSize: 11),
|
decoration: BoxDecoration(
|
||||||
|
color: tileColor.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 14, color: tileColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(fontSize: 10, color: Colors.grey[700]),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SignalMeter extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String valueLabel;
|
||||||
|
final double normalized;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
const _SignalMeter({
|
||||||
|
required this.label,
|
||||||
|
required this.valueLabel,
|
||||||
|
required this.normalized,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 42,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
minHeight: 8,
|
||||||
|
value: normalized,
|
||||||
|
backgroundColor: color.withValues(alpha: 0.15),
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
SizedBox(
|
||||||
|
width: 74,
|
||||||
|
child: Text(
|
||||||
|
valueLabel,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HexByteChip extends StatelessWidget {
|
||||||
|
final int index;
|
||||||
|
final int value;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _HexByteChip({
|
||||||
|
required this.index,
|
||||||
|
required this.value,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final text = value.toRadixString(16).padLeft(2, '0').toUpperCase();
|
||||||
|
return Tooltip(
|
||||||
|
message: 'Byte $index',
|
||||||
|
waitDuration: const Duration(milliseconds: 250),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(7),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.8),
|
||||||
|
borderRadius: BorderRadius.circular(7),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(4),
|
|
||||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import '../providers/app_provider.dart';
|
|||||||
import '../services/location_tracking_service.dart';
|
import '../services/location_tracking_service.dart';
|
||||||
import '../services/locale_preferences.dart';
|
import '../services/locale_preferences.dart';
|
||||||
import '../services/update_checker_service.dart';
|
import '../services/update_checker_service.dart';
|
||||||
|
import '../services/voice_bitrate_preferences.dart';
|
||||||
import '../utils/sample_data_generator.dart';
|
import '../utils/sample_data_generator.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
@@ -45,6 +46,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
bool _isLoadingSampleData = false;
|
bool _isLoadingSampleData = false;
|
||||||
bool _showRxTxIndicators = true;
|
bool _showRxTxIndicators = true;
|
||||||
bool _isCheckingForUpdates = false;
|
bool _isCheckingForUpdates = false;
|
||||||
|
int _voiceBitrate = VoiceBitratePreferences.defaultBitrate;
|
||||||
final LocationTrackingService _locationService = LocationTrackingService();
|
final LocationTrackingService _locationService = LocationTrackingService();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -55,6 +57,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_loadPackageInfo();
|
_loadPackageInfo();
|
||||||
_initializeLocationService();
|
_initializeLocationService();
|
||||||
_loadRxTxPreference();
|
_loadRxTxPreference();
|
||||||
|
_loadVoiceBitratePreference();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -89,6 +92,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
await prefs.setBool('show_rx_tx_indicators', value);
|
await prefs.setBool('show_rx_tx_indicators', value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadVoiceBitratePreference() async {
|
||||||
|
final value = await VoiceBitratePreferences.getBitrate();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_voiceBitrate = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveVoiceBitratePreference(int value) async {
|
||||||
|
await VoiceBitratePreferences.setBitrate(value);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_voiceBitrate = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String _voiceBitrateSubtitle(int bitrate) {
|
||||||
|
return '$bitrate bps';
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _initializeLocationService() async {
|
Future<void> _initializeLocationService() async {
|
||||||
// Initialize location service with BLE service
|
// Initialize location service with BLE service
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
@@ -550,6 +573,54 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => _showLanguageDialog(),
|
onTap: () => _showLanguageDialog(),
|
||||||
),
|
),
|
||||||
|
const Divider(),
|
||||||
|
|
||||||
|
// Voice Settings Section
|
||||||
|
_buildSectionHeader('Voice'),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => _buildVoiceStatsCard(
|
||||||
|
bitrate: _voiceBitrate,
|
||||||
|
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
|
||||||
|
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.graphic_eq),
|
||||||
|
title: const Text('Voice bitrate'),
|
||||||
|
subtitle: Text(_voiceBitrateSubtitle(_voiceBitrate)),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: _showVoiceBitrateDialog,
|
||||||
|
),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.tune),
|
||||||
|
title: const Text('Band-pass filter voice'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Keeps speech frequencies and cuts low/high noise',
|
||||||
|
),
|
||||||
|
value: appProvider.isVoiceBandPassFilterEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.toggleVoiceBandPassFilterEnabled(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.content_cut),
|
||||||
|
title: const Text('Trim silence in voice messages'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Removes long silent parts before sending voice',
|
||||||
|
),
|
||||||
|
value: appProvider.isVoiceSilenceTrimmingEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.toggleVoiceSilenceTrimmingEnabled(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
|
||||||
|
// Templates Section
|
||||||
|
_buildSectionHeader('Templates'),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.location_searching),
|
leading: const Icon(Icons.location_searching),
|
||||||
title: Text(AppLocalizations.of(context)!.sarTemplates),
|
title: Text(AppLocalizations.of(context)!.sarTemplates),
|
||||||
@@ -708,7 +779,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
child: Text(
|
child: Text(
|
||||||
AppLocalizations.of(context)!.sampleDataDescription,
|
AppLocalizations.of(context)!.sampleDataDescription,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -765,6 +838,102 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildVoiceStatsCard({
|
||||||
|
required int bitrate,
|
||||||
|
required bool bandPassEnabled,
|
||||||
|
required bool silenceTrimEnabled,
|
||||||
|
}) {
|
||||||
|
final supported = VoiceBitratePreferences.supportedBitrates;
|
||||||
|
final minBitrate = supported.reduce((a, b) => a < b ? a : b).toDouble();
|
||||||
|
final maxBitrate = supported.reduce((a, b) => a > b ? a : b).toDouble();
|
||||||
|
final normalized = maxBitrate > minBitrate
|
||||||
|
? ((bitrate - minBitrate) / (maxBitrate - minBitrate)).clamp(0.0, 1.0)
|
||||||
|
: 1.0;
|
||||||
|
final enabledCount = (bandPassEnabled ? 1 : 0) + (silenceTrimEnabled ? 1 : 0);
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Voice Processing Stats',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Bitrate: $bitrate bps',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: normalized,
|
||||||
|
minHeight: 8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _voiceStatChip(
|
||||||
|
label: 'Band-pass',
|
||||||
|
enabled: bandPassEnabled,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _voiceStatChip(
|
||||||
|
label: 'Silence trim',
|
||||||
|
enabled: silenceTrimEnabled,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Processing enabled: $enabledCount/2',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _voiceStatChip({required String label, required bool enabled}) {
|
||||||
|
final color = enabled ? Colors.green : Colors.grey;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.4)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
enabled ? Icons.check_circle : Icons.radio_button_unchecked,
|
||||||
|
size: 16,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: color, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _showThemeDialog() {
|
void _showThemeDialog() {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -828,7 +997,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
subtitle: Text(AppLocalizations.of(context)!.safeAllClearMode),
|
subtitle: Text(
|
||||||
|
AppLocalizations.of(context)!.safeAllClearMode,
|
||||||
|
),
|
||||||
value: AppThemeMode.sarGreen,
|
value: AppThemeMode.sarGreen,
|
||||||
),
|
),
|
||||||
RadioListTile<AppThemeMode>(
|
RadioListTile<AppThemeMode>(
|
||||||
@@ -855,7 +1026,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
const Divider(),
|
const Divider(),
|
||||||
RadioListTile<AppThemeMode>(
|
RadioListTile<AppThemeMode>(
|
||||||
title: Text(AppLocalizations.of(context)!.autoSystem),
|
title: Text(AppLocalizations.of(context)!.autoSystem),
|
||||||
subtitle: Text(AppLocalizations.of(context)!.followSystemTheme),
|
subtitle: Text(
|
||||||
|
AppLocalizations.of(context)!.followSystemTheme,
|
||||||
|
),
|
||||||
value: AppThemeMode.system,
|
value: AppThemeMode.system,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -913,6 +1086,46 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showVoiceBitrateDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Voice bitrate'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: RadioGroup<int>(
|
||||||
|
groupValue: _voiceBitrate,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
_saveVoiceBitratePreference(value);
|
||||||
|
}
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: VoiceBitratePreferences.supportedBitrates
|
||||||
|
.map(
|
||||||
|
(bitrate) => RadioListTile<int>(
|
||||||
|
value: bitrate,
|
||||||
|
title: Text('$bitrate bps'),
|
||||||
|
subtitle: bitrate == VoiceBitratePreferences.defaultBitrate
|
||||||
|
? const Text('Default')
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(AppLocalizations.of(context)!.cancel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _showAboutDialog() {
|
void _showAboutDialog() {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -133,20 +133,30 @@ class MessageStorageService {
|
|||||||
// Echo detection for channel messages
|
// Echo detection for channel messages
|
||||||
'echoCount': message.echoCount,
|
'echoCount': message.echoCount,
|
||||||
'firstEchoAtMillis': message.firstEchoAt?.millisecondsSinceEpoch,
|
'firstEchoAtMillis': message.firstEchoAt?.millisecondsSinceEpoch,
|
||||||
|
'lastEchoSnrRaw': message.lastEchoSnrRaw,
|
||||||
|
'lastEchoRssiDbm': message.lastEchoRssiDbm,
|
||||||
|
'lastEchoAtMillis': message.lastEchoAt?.millisecondsSinceEpoch,
|
||||||
// Drawing message tracking
|
// Drawing message tracking
|
||||||
'isDrawing': message.isDrawing,
|
'isDrawing': message.isDrawing,
|
||||||
'drawingId': message.drawingId,
|
'drawingId': message.drawingId,
|
||||||
|
// Voice message tracking
|
||||||
|
'isVoice': message.isVoice,
|
||||||
|
'voiceId': message.voiceId,
|
||||||
// Message grouping (for bulk sends)
|
// Message grouping (for bulk sends)
|
||||||
'groupId': message.groupId,
|
'groupId': message.groupId,
|
||||||
'recipients': message.recipients?.map((r) => {
|
'recipients': message.recipients
|
||||||
'publicKey': base64Encode(r.publicKey),
|
?.map(
|
||||||
'displayName': r.displayName,
|
(r) => {
|
||||||
'deliveryStatus': r.deliveryStatus.name,
|
'publicKey': base64Encode(r.publicKey),
|
||||||
'expectedAckTag': r.expectedAckTag,
|
'displayName': r.displayName,
|
||||||
'roundTripTimeMs': r.roundTripTimeMs,
|
'deliveryStatus': r.deliveryStatus.name,
|
||||||
'deliveredAtMillis': r.deliveredAt?.millisecondsSinceEpoch,
|
'expectedAckTag': r.expectedAckTag,
|
||||||
'sentAtMillis': r.sentAt.millisecondsSinceEpoch,
|
'roundTripTimeMs': r.roundTripTimeMs,
|
||||||
}).toList(),
|
'deliveredAtMillis': r.deliveredAt?.millisecondsSinceEpoch,
|
||||||
|
'sentAtMillis': r.sentAt.millisecondsSinceEpoch,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,34 +226,46 @@ class MessageStorageService {
|
|||||||
json['firstEchoAtMillis'] as int,
|
json['firstEchoAtMillis'] as int,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
lastEchoSnrRaw: json['lastEchoSnrRaw'] as int?,
|
||||||
|
lastEchoRssiDbm: json['lastEchoRssiDbm'] as int?,
|
||||||
|
lastEchoAt: json['lastEchoAtMillis'] != null
|
||||||
|
? DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
json['lastEchoAtMillis'] as int,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
// Drawing message tracking
|
// Drawing message tracking
|
||||||
isDrawing: json['isDrawing'] as bool? ?? false,
|
isDrawing: json['isDrawing'] as bool? ?? false,
|
||||||
drawingId: json['drawingId'] as String?,
|
drawingId: json['drawingId'] as String?,
|
||||||
|
// Voice message tracking
|
||||||
|
isVoice: json['isVoice'] as bool? ?? false,
|
||||||
|
voiceId: json['voiceId'] as String?,
|
||||||
// Message grouping
|
// Message grouping
|
||||||
groupId: json['groupId'] as String?,
|
groupId: json['groupId'] as String?,
|
||||||
recipients: json['recipients'] != null
|
recipients: json['recipients'] != null
|
||||||
? (json['recipients'] as List<dynamic>)
|
? (json['recipients'] as List<dynamic>)
|
||||||
.map((r) => MessageRecipient(
|
.map(
|
||||||
publicKey: Uint8List.fromList(
|
(r) => MessageRecipient(
|
||||||
base64Decode(r['publicKey'] as String),
|
publicKey: Uint8List.fromList(
|
||||||
),
|
base64Decode(r['publicKey'] as String),
|
||||||
displayName: r['displayName'] as String,
|
),
|
||||||
deliveryStatus: MessageDeliveryStatus.values.firstWhere(
|
displayName: r['displayName'] as String,
|
||||||
(e) => e.name == r['deliveryStatus'],
|
deliveryStatus: MessageDeliveryStatus.values.firstWhere(
|
||||||
orElse: () => MessageDeliveryStatus.sending,
|
(e) => e.name == r['deliveryStatus'],
|
||||||
),
|
orElse: () => MessageDeliveryStatus.sending,
|
||||||
expectedAckTag: r['expectedAckTag'] as int?,
|
),
|
||||||
roundTripTimeMs: r['roundTripTimeMs'] as int?,
|
expectedAckTag: r['expectedAckTag'] as int?,
|
||||||
deliveredAt: r['deliveredAtMillis'] != null
|
roundTripTimeMs: r['roundTripTimeMs'] as int?,
|
||||||
? DateTime.fromMillisecondsSinceEpoch(
|
deliveredAt: r['deliveredAtMillis'] != null
|
||||||
r['deliveredAtMillis'] as int,
|
? DateTime.fromMillisecondsSinceEpoch(
|
||||||
)
|
r['deliveredAtMillis'] as int,
|
||||||
: null,
|
)
|
||||||
sentAt: DateTime.fromMillisecondsSinceEpoch(
|
: null,
|
||||||
r['sentAtMillis'] as int,
|
sentAt: DateTime.fromMillisecondsSinceEpoch(
|
||||||
),
|
r['sentAtMillis'] as int,
|
||||||
))
|
),
|
||||||
.toList()
|
),
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class NotificationService {
|
|||||||
|
|
||||||
// Initialize plugin
|
// Initialize plugin
|
||||||
await _notificationsPlugin.initialize(
|
await _notificationsPlugin.initialize(
|
||||||
initSettings,
|
settings: initSettings,
|
||||||
onDidReceiveNotificationResponse: _onNotificationResponse,
|
onDidReceiveNotificationResponse: _onNotificationResponse,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -286,10 +286,10 @@ class NotificationService {
|
|||||||
|
|
||||||
// Show notification
|
// Show notification
|
||||||
await _notificationsPlugin.show(
|
await _notificationsPlugin.show(
|
||||||
notificationId,
|
id: notificationId,
|
||||||
title,
|
title: title,
|
||||||
body,
|
body: body,
|
||||||
notificationDetails,
|
notificationDetails: notificationDetails,
|
||||||
payload: 'sar:${type.name}:$coordinates',
|
payload: 'sar:${type.name}:$coordinates',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -457,10 +457,10 @@ class NotificationService {
|
|||||||
|
|
||||||
// Show notification
|
// Show notification
|
||||||
await _notificationsPlugin.show(
|
await _notificationsPlugin.show(
|
||||||
notificationId,
|
id: notificationId,
|
||||||
title,
|
title: title,
|
||||||
body,
|
body: body,
|
||||||
notificationDetails,
|
notificationDetails: notificationDetails,
|
||||||
payload: 'message:${isChannelMessage ? "channel" : "contact"}',
|
payload: 'message:${isChannelMessage ? "channel" : "contact"}',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -487,7 +487,7 @@ class NotificationService {
|
|||||||
/// Cancel specific notification
|
/// Cancel specific notification
|
||||||
Future<void> cancel(int id) async {
|
Future<void> cancel(int id) async {
|
||||||
try {
|
try {
|
||||||
await _notificationsPlugin.cancel(id);
|
await _notificationsPlugin.cancel(id: id);
|
||||||
debugPrint('✅ [NotificationService] Cancelled notification: $id');
|
debugPrint('✅ [NotificationService] Cancelled notification: $id');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [NotificationService] Error canceling notification: $e');
|
debugPrint('❌ [NotificationService] Error canceling notification: $e');
|
||||||
@@ -601,10 +601,10 @@ class NotificationService {
|
|||||||
|
|
||||||
// Show notification
|
// Show notification
|
||||||
await _notificationsPlugin.show(
|
await _notificationsPlugin.show(
|
||||||
_updateNotificationId,
|
id: _updateNotificationId,
|
||||||
title,
|
title: title,
|
||||||
body,
|
body: body,
|
||||||
notificationDetails,
|
notificationDetails: notificationDetails,
|
||||||
payload: 'update:$downloadUrl',
|
payload: 'update:$downloadUrl',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ class SseClientService {
|
|||||||
StreamSubscription? _contactSubscription;
|
StreamSubscription? _contactSubscription;
|
||||||
bool _isConnected = false;
|
bool _isConnected = false;
|
||||||
bool _isConnecting = false;
|
bool _isConnecting = false;
|
||||||
bool _hasConnectedBefore = false; // Track if we've ever successfully connected
|
bool _hasConnectedBefore =
|
||||||
|
false; // Track if we've ever successfully connected
|
||||||
Timer? _reconnectTimer;
|
Timer? _reconnectTimer;
|
||||||
Timer? _heartbeatTimer;
|
Timer? _heartbeatTimer;
|
||||||
int _reconnectAttempts = 0;
|
int _reconnectAttempts = 0;
|
||||||
@@ -56,10 +57,7 @@ class SseClientService {
|
|||||||
String? get serverUrl => _serverUrl;
|
String? get serverUrl => _serverUrl;
|
||||||
|
|
||||||
/// Connect to SSE server
|
/// Connect to SSE server
|
||||||
Future<void> connect({
|
Future<void> connect({required String serverUrl, String? authToken}) async {
|
||||||
required String serverUrl,
|
|
||||||
String? authToken,
|
|
||||||
}) async {
|
|
||||||
if (_isConnected) {
|
if (_isConnected) {
|
||||||
debugPrint('⚠️ [SseClient] Already connected');
|
debugPrint('⚠️ [SseClient] Already connected');
|
||||||
return;
|
return;
|
||||||
@@ -69,14 +67,18 @@ class SseClientService {
|
|||||||
_authToken = authToken;
|
_authToken = authToken;
|
||||||
_isConnecting = true;
|
_isConnecting = true;
|
||||||
|
|
||||||
debugPrint('🔌 [SseClient] Connecting to $serverUrl (attempt ${_reconnectAttempts + 1}/$_maxReconnectAttempts)');
|
debugPrint(
|
||||||
|
'🔌 [SseClient] Connecting to $serverUrl (attempt ${_reconnectAttempts + 1}/$_maxReconnectAttempts)',
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create a new HTTP client with custom configuration for SSE streaming
|
// Create a new HTTP client with custom configuration for SSE streaming
|
||||||
// Using IOClient with custom HttpClient for better control over connection settings
|
// Using IOClient with custom HttpClient for better control over connection settings
|
||||||
final ioHttpClient = io.HttpClient();
|
final ioHttpClient = io.HttpClient();
|
||||||
ioHttpClient.connectionTimeout = const Duration(seconds: 10);
|
ioHttpClient.connectionTimeout = const Duration(seconds: 10);
|
||||||
ioHttpClient.idleTimeout = const Duration(hours: 1); // Keep SSE connections alive
|
ioHttpClient.idleTimeout = const Duration(
|
||||||
|
hours: 1,
|
||||||
|
); // Keep SSE connections alive
|
||||||
_httpClient = io_client.IOClient(ioHttpClient);
|
_httpClient = io_client.IOClient(ioHttpClient);
|
||||||
|
|
||||||
// Test server availability
|
// Test server availability
|
||||||
@@ -90,7 +92,9 @@ class SseClientService {
|
|||||||
|
|
||||||
// Subscribe to SSE streams
|
// Subscribe to SSE streams
|
||||||
debugPrint('🔗 [SseClient] Subscribing to message stream...');
|
debugPrint('🔗 [SseClient] Subscribing to message stream...');
|
||||||
debugPrint('🔗 [SseClient] Using HTTP client type: ${_httpClient.runtimeType}');
|
debugPrint(
|
||||||
|
'🔗 [SseClient] Using HTTP client type: ${_httpClient.runtimeType}',
|
||||||
|
);
|
||||||
await _subscribeToMessages();
|
await _subscribeToMessages();
|
||||||
debugPrint('🔗 [SseClient] Subscribing to contact stream...');
|
debugPrint('🔗 [SseClient] Subscribing to contact stream...');
|
||||||
await _subscribeToContacts();
|
await _subscribeToContacts();
|
||||||
@@ -149,9 +153,9 @@ class SseClientService {
|
|||||||
final url = Uri.parse('$_serverUrl/api/status');
|
final url = Uri.parse('$_serverUrl/api/status');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await http.get(url, headers: _getHeaders()).timeout(
|
final response = await http
|
||||||
const Duration(seconds: 5),
|
.get(url, headers: _getHeaders())
|
||||||
);
|
.timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
throw Exception('Server returned ${response.statusCode}');
|
throw Exception('Server returned ${response.statusCode}');
|
||||||
@@ -179,7 +183,8 @@ class SseClientService {
|
|||||||
|
|
||||||
if (errorStr.contains('Connection refused')) {
|
if (errorStr.contains('Connection refused')) {
|
||||||
return 'Server not available at $host:$port. The server may be offline or not running.';
|
return 'Server not available at $host:$port. The server may be offline or not running.';
|
||||||
} else if (errorStr.contains('TimeoutException') || errorStr.contains('timed out')) {
|
} else if (errorStr.contains('TimeoutException') ||
|
||||||
|
errorStr.contains('timed out')) {
|
||||||
return 'Connection to $host:$port timed out. Check your network connection.';
|
return 'Connection to $host:$port timed out. Check your network connection.';
|
||||||
} else if (errorStr.contains('SocketException')) {
|
} else if (errorStr.contains('SocketException')) {
|
||||||
return 'Network error connecting to $host:$port. Check your network connection.';
|
return 'Network error connecting to $host:$port. Check your network connection.';
|
||||||
@@ -195,18 +200,22 @@ class SseClientService {
|
|||||||
Future<void> _fetchMessageHistory() async {
|
Future<void> _fetchMessageHistory() async {
|
||||||
try {
|
try {
|
||||||
final url = Uri.parse('$_serverUrl/api/messages/history');
|
final url = Uri.parse('$_serverUrl/api/messages/history');
|
||||||
final response = await http.get(url, headers: _getHeaders()).timeout(
|
final response = await http
|
||||||
const Duration(seconds: 10),
|
.get(url, headers: _getHeaders())
|
||||||
);
|
.timeout(const Duration(seconds: 10));
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
throw Exception('Failed to fetch message history: ${response.statusCode}');
|
throw Exception(
|
||||||
|
'Failed to fetch message history: ${response.statusCode}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
final messages = data['messages'] as List;
|
final messages = data['messages'] as List;
|
||||||
|
|
||||||
debugPrint('📥 [SseClient] Received ${messages.length} messages from history');
|
debugPrint(
|
||||||
|
'📥 [SseClient] Received ${messages.length} messages from history',
|
||||||
|
);
|
||||||
|
|
||||||
for (final msgJson in messages) {
|
for (final msgJson in messages) {
|
||||||
try {
|
try {
|
||||||
@@ -226,9 +235,9 @@ class SseClientService {
|
|||||||
Future<void> _fetchContacts() async {
|
Future<void> _fetchContacts() async {
|
||||||
try {
|
try {
|
||||||
final url = Uri.parse('$_serverUrl/api/contacts');
|
final url = Uri.parse('$_serverUrl/api/contacts');
|
||||||
final response = await http.get(url, headers: _getHeaders()).timeout(
|
final response = await http
|
||||||
const Duration(seconds: 10),
|
.get(url, headers: _getHeaders())
|
||||||
);
|
.timeout(const Duration(seconds: 10));
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
throw Exception('Failed to fetch contacts: ${response.statusCode}');
|
throw Exception('Failed to fetch contacts: ${response.statusCode}');
|
||||||
@@ -270,45 +279,63 @@ class SseClientService {
|
|||||||
debugPrint('📡 [SseClient] Sending message stream request to $url');
|
debugPrint('📡 [SseClient] Sending message stream request to $url');
|
||||||
debugPrint('📡 [SseClient] Request headers: ${request.headers}');
|
debugPrint('📡 [SseClient] Request headers: ${request.headers}');
|
||||||
|
|
||||||
final streamedResponse = await _httpClient!.send(request).timeout(
|
final streamedResponse = await _httpClient!
|
||||||
const Duration(seconds: 10),
|
.send(request)
|
||||||
onTimeout: () {
|
.timeout(
|
||||||
debugPrint('❌ [SseClient] Timeout waiting for response headers');
|
const Duration(seconds: 10),
|
||||||
throw TimeoutException('Message stream connection timed out after 10 seconds');
|
onTimeout: () {
|
||||||
},
|
debugPrint('❌ [SseClient] Timeout waiting for response headers');
|
||||||
|
throw TimeoutException(
|
||||||
|
'Message stream connection timed out after 10 seconds',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'📡 [SseClient] Received response with status: ${streamedResponse.statusCode}',
|
||||||
|
);
|
||||||
|
debugPrint(
|
||||||
|
'📡 [SseClient] Response headers: ${streamedResponse.headers}',
|
||||||
|
);
|
||||||
|
debugPrint(
|
||||||
|
'📡 [SseClient] Response content length: ${streamedResponse.contentLength}',
|
||||||
|
);
|
||||||
|
debugPrint(
|
||||||
|
'📡 [SseClient] Response is redirect: ${streamedResponse.isRedirect}',
|
||||||
);
|
);
|
||||||
|
|
||||||
debugPrint('📡 [SseClient] Received response with status: ${streamedResponse.statusCode}');
|
|
||||||
debugPrint('📡 [SseClient] Response headers: ${streamedResponse.headers}');
|
|
||||||
debugPrint('📡 [SseClient] Response content length: ${streamedResponse.contentLength}');
|
|
||||||
debugPrint('📡 [SseClient] Response is redirect: ${streamedResponse.isRedirect}');
|
|
||||||
|
|
||||||
if (streamedResponse.statusCode != 200) {
|
if (streamedResponse.statusCode != 200) {
|
||||||
throw Exception('SSE messages subscription failed: ${streamedResponse.statusCode}');
|
throw Exception(
|
||||||
|
'SSE messages subscription failed: ${streamedResponse.statusCode}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('📡 [SseClient] Message stream response received, status: ${streamedResponse.statusCode}');
|
debugPrint(
|
||||||
|
'📡 [SseClient] Message stream response received, status: ${streamedResponse.statusCode}',
|
||||||
|
);
|
||||||
debugPrint('📡 [SseClient] Setting up stream listener...');
|
debugPrint('📡 [SseClient] Setting up stream listener...');
|
||||||
|
|
||||||
_messageSubscription = streamedResponse.stream
|
_messageSubscription = streamedResponse.stream
|
||||||
.transform(utf8.decoder)
|
.transform(utf8.decoder)
|
||||||
.transform(const LineSplitter())
|
.transform(const LineSplitter())
|
||||||
.listen(
|
.listen(
|
||||||
(line) {
|
(line) {
|
||||||
debugPrint('📨 [SseClient] Received line: "$line"');
|
debugPrint('📨 [SseClient] Received line: "$line"');
|
||||||
_handleSseLine(line, 'message');
|
_handleSseLine(line, 'message');
|
||||||
},
|
},
|
||||||
onError: (error, stackTrace) {
|
onError: (error, stackTrace) {
|
||||||
debugPrint('❌ [SseClient] Message stream error: $error');
|
debugPrint('❌ [SseClient] Message stream error: $error');
|
||||||
debugPrint(' Stack trace: $stackTrace');
|
debugPrint(' Stack trace: $stackTrace');
|
||||||
_handleDisconnect();
|
_handleDisconnect();
|
||||||
},
|
},
|
||||||
onDone: () {
|
onDone: () {
|
||||||
debugPrint('⚠️ [SseClient] Message stream closed (onDone called)');
|
debugPrint(
|
||||||
_handleDisconnect();
|
'⚠️ [SseClient] Message stream closed (onDone called)',
|
||||||
},
|
);
|
||||||
cancelOnError: false,
|
_handleDisconnect();
|
||||||
);
|
},
|
||||||
|
cancelOnError: false,
|
||||||
|
);
|
||||||
|
|
||||||
debugPrint('✅ [SseClient] Message stream listener set up successfully');
|
debugPrint('✅ [SseClient] Message stream listener set up successfully');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -332,39 +359,49 @@ class SseClientService {
|
|||||||
request.headers['Cache-Control'] = 'no-cache';
|
request.headers['Cache-Control'] = 'no-cache';
|
||||||
|
|
||||||
debugPrint('📡 [SseClient] Sending contact stream request to $url');
|
debugPrint('📡 [SseClient] Sending contact stream request to $url');
|
||||||
final streamedResponse = await _httpClient!.send(request).timeout(
|
final streamedResponse = await _httpClient!
|
||||||
const Duration(seconds: 10),
|
.send(request)
|
||||||
onTimeout: () {
|
.timeout(
|
||||||
throw TimeoutException('Contact stream connection timed out after 10 seconds');
|
const Duration(seconds: 10),
|
||||||
},
|
onTimeout: () {
|
||||||
);
|
throw TimeoutException(
|
||||||
|
'Contact stream connection timed out after 10 seconds',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (streamedResponse.statusCode != 200) {
|
if (streamedResponse.statusCode != 200) {
|
||||||
throw Exception('SSE contacts subscription failed: ${streamedResponse.statusCode}');
|
throw Exception(
|
||||||
|
'SSE contacts subscription failed: ${streamedResponse.statusCode}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('📡 [SseClient] Contact stream response received, status: ${streamedResponse.statusCode}');
|
debugPrint(
|
||||||
|
'📡 [SseClient] Contact stream response received, status: ${streamedResponse.statusCode}',
|
||||||
|
);
|
||||||
debugPrint('📡 [SseClient] Setting up contact stream listener...');
|
debugPrint('📡 [SseClient] Setting up contact stream listener...');
|
||||||
|
|
||||||
_contactSubscription = streamedResponse.stream
|
_contactSubscription = streamedResponse.stream
|
||||||
.transform(utf8.decoder)
|
.transform(utf8.decoder)
|
||||||
.transform(const LineSplitter())
|
.transform(const LineSplitter())
|
||||||
.listen(
|
.listen(
|
||||||
(line) {
|
(line) {
|
||||||
debugPrint('📨 [SseClient] Received contact line: "$line"');
|
debugPrint('📨 [SseClient] Received contact line: "$line"');
|
||||||
_handleSseLine(line, 'contact');
|
_handleSseLine(line, 'contact');
|
||||||
},
|
},
|
||||||
onError: (error, stackTrace) {
|
onError: (error, stackTrace) {
|
||||||
debugPrint('❌ [SseClient] Contact stream error: $error');
|
debugPrint('❌ [SseClient] Contact stream error: $error');
|
||||||
debugPrint(' Stack trace: $stackTrace');
|
debugPrint(' Stack trace: $stackTrace');
|
||||||
_handleDisconnect();
|
_handleDisconnect();
|
||||||
},
|
},
|
||||||
onDone: () {
|
onDone: () {
|
||||||
debugPrint('⚠️ [SseClient] Contact stream closed (onDone called)');
|
debugPrint(
|
||||||
_handleDisconnect();
|
'⚠️ [SseClient] Contact stream closed (onDone called)',
|
||||||
},
|
);
|
||||||
cancelOnError: false,
|
_handleDisconnect();
|
||||||
);
|
},
|
||||||
|
cancelOnError: false,
|
||||||
|
);
|
||||||
|
|
||||||
debugPrint('✅ [SseClient] Contact stream listener set up successfully');
|
debugPrint('✅ [SseClient] Contact stream listener set up successfully');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -423,7 +460,9 @@ class SseClientService {
|
|||||||
_reconnectAttempts++;
|
_reconnectAttempts++;
|
||||||
final delay = _reconnectDelay * _reconnectAttempts;
|
final delay = _reconnectDelay * _reconnectAttempts;
|
||||||
|
|
||||||
debugPrint('🔄 [SseClient] Scheduling reconnect attempt $_reconnectAttempts in ${delay.inSeconds}s');
|
debugPrint(
|
||||||
|
'🔄 [SseClient] Scheduling reconnect attempt $_reconnectAttempts in ${delay.inSeconds}s',
|
||||||
|
);
|
||||||
|
|
||||||
_reconnectTimer?.cancel();
|
_reconnectTimer?.cancel();
|
||||||
_reconnectTimer = Timer(delay, () {
|
_reconnectTimer = Timer(delay, () {
|
||||||
@@ -436,7 +475,9 @@ class SseClientService {
|
|||||||
/// Start heartbeat to detect connection loss
|
/// Start heartbeat to detect connection loss
|
||||||
void _startHeartbeat() {
|
void _startHeartbeat() {
|
||||||
_heartbeatTimer?.cancel();
|
_heartbeatTimer?.cancel();
|
||||||
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (timer) async {
|
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (
|
||||||
|
timer,
|
||||||
|
) async {
|
||||||
try {
|
try {
|
||||||
await _checkServerStatus();
|
await _checkServerStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -457,17 +498,16 @@ class SseClientService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final url = Uri.parse('$_serverUrl/api/messages');
|
final url = Uri.parse('$_serverUrl/api/messages');
|
||||||
final response = await http.post(
|
final response = await http
|
||||||
url,
|
.post(
|
||||||
headers: {
|
url,
|
||||||
..._getHeaders(),
|
headers: {..._getHeaders(), 'Content-Type': 'application/json'},
|
||||||
'Content-Type': 'application/json',
|
body: jsonEncode({
|
||||||
},
|
'recipientPublicKey': recipientPublicKey,
|
||||||
body: jsonEncode({
|
'text': text,
|
||||||
'recipientPublicKey': recipientPublicKey,
|
}),
|
||||||
'text': text,
|
)
|
||||||
}),
|
.timeout(const Duration(seconds: 10));
|
||||||
).timeout(const Duration(seconds: 10));
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
throw Exception('Send message failed: ${response.statusCode}');
|
throw Exception('Send message failed: ${response.statusCode}');
|
||||||
@@ -492,17 +532,13 @@ class SseClientService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final url = Uri.parse('$_serverUrl/api/messages/channel');
|
final url = Uri.parse('$_serverUrl/api/messages/channel');
|
||||||
final response = await http.post(
|
final response = await http
|
||||||
url,
|
.post(
|
||||||
headers: {
|
url,
|
||||||
..._getHeaders(),
|
headers: {..._getHeaders(), 'Content-Type': 'application/json'},
|
||||||
'Content-Type': 'application/json',
|
body: jsonEncode({'channelIdx': channelIdx, 'text': text}),
|
||||||
},
|
)
|
||||||
body: jsonEncode({
|
.timeout(const Duration(seconds: 10));
|
||||||
'channelIdx': channelIdx,
|
|
||||||
'text': text,
|
|
||||||
}),
|
|
||||||
).timeout(const Duration(seconds: 10));
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
throw Exception('Send channel message failed: ${response.statusCode}');
|
throw Exception('Send channel message failed: ${response.statusCode}');
|
||||||
@@ -521,10 +557,9 @@ class SseClientService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final url = Uri.parse('$_serverUrl/api/contacts/sync');
|
final url = Uri.parse('$_serverUrl/api/contacts/sync');
|
||||||
final response = await http.post(
|
final response = await http
|
||||||
url,
|
.post(url, headers: _getHeaders())
|
||||||
headers: _getHeaders(),
|
.timeout(const Duration(seconds: 10));
|
||||||
).timeout(const Duration(seconds: 10));
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
throw Exception('Contact sync failed: ${response.statusCode}');
|
throw Exception('Contact sync failed: ${response.statusCode}');
|
||||||
@@ -555,7 +590,9 @@ class SseClientService {
|
|||||||
orElse: () => MessageType.contact,
|
orElse: () => MessageType.contact,
|
||||||
),
|
),
|
||||||
senderPublicKeyPrefix: json['senderPublicKeyPrefix'] != null
|
senderPublicKeyPrefix: json['senderPublicKeyPrefix'] != null
|
||||||
? Uint8List.fromList((json['senderPublicKeyPrefix'] as List).cast<int>())
|
? Uint8List.fromList(
|
||||||
|
(json['senderPublicKeyPrefix'] as List).cast<int>(),
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
channelIdx: json['channelIdx'] as int?,
|
channelIdx: json['channelIdx'] as int?,
|
||||||
pathLen: json['pathLen'] as int,
|
pathLen: json['pathLen'] as int,
|
||||||
@@ -597,6 +634,11 @@ class SseClientService {
|
|||||||
firstEchoAt: json['firstEchoAt'] != null
|
firstEchoAt: json['firstEchoAt'] != null
|
||||||
? DateTime.parse(json['firstEchoAt'] as String)
|
? DateTime.parse(json['firstEchoAt'] as String)
|
||||||
: null,
|
: null,
|
||||||
|
lastEchoSnrRaw: json['lastEchoSnrRaw'] as int?,
|
||||||
|
lastEchoRssiDbm: json['lastEchoRssiDbm'] as int?,
|
||||||
|
lastEchoAt: json['lastEchoAt'] != null
|
||||||
|
? DateTime.parse(json['lastEchoAt'] as String)
|
||||||
|
: null,
|
||||||
isDrawing: json['isDrawing'] as bool? ?? false,
|
isDrawing: json['isDrawing'] as bool? ?? false,
|
||||||
drawingId: json['drawingId'] as String?,
|
drawingId: json['drawingId'] as String?,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -76,11 +76,14 @@ class SseServerService {
|
|||||||
static shelf.Middleware get _corsHeaders {
|
static shelf.Middleware get _corsHeaders {
|
||||||
return shelf.createMiddleware(
|
return shelf.createMiddleware(
|
||||||
responseHandler: (shelf.Response response) {
|
responseHandler: (shelf.Response response) {
|
||||||
return response.change(headers: {
|
return response.change(
|
||||||
'Access-Control-Allow-Origin': '*',
|
headers: {
|
||||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Access-Control-Allow-Headers': 'Origin, Content-Type, Authorization',
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
});
|
'Access-Control-Allow-Headers':
|
||||||
|
'Origin, Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -95,7 +98,9 @@ class SseServerService {
|
|||||||
_config = config;
|
_config = config;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
debugPrint('🚀 [SseServer] Starting server on ${config.host}:${config.port}');
|
debugPrint(
|
||||||
|
'🚀 [SseServer] Starting server on ${config.host}:${config.port}',
|
||||||
|
);
|
||||||
|
|
||||||
// Create shelf handler with CORS support
|
// Create shelf handler with CORS support
|
||||||
final handler = const shelf.Pipeline()
|
final handler = const shelf.Pipeline()
|
||||||
@@ -104,11 +109,7 @@ class SseServerService {
|
|||||||
.addHandler(_handleRequest);
|
.addHandler(_handleRequest);
|
||||||
|
|
||||||
// Start HTTP server
|
// Start HTTP server
|
||||||
_server = await io.serve(
|
_server = await io.serve(handler, config.host, config.port);
|
||||||
handler,
|
|
||||||
config.host,
|
|
||||||
config.port,
|
|
||||||
);
|
|
||||||
|
|
||||||
debugPrint('✅ [SseServer] Server started on ${config.getServerUrl()}');
|
debugPrint('✅ [SseServer] Server started on ${config.getServerUrl()}');
|
||||||
|
|
||||||
@@ -127,7 +128,9 @@ class SseServerService {
|
|||||||
/// Register Bonjour/mDNS service for network discovery
|
/// Register Bonjour/mDNS service for network discovery
|
||||||
Future<void> _registerBonjourService(SseServerConfig config) async {
|
Future<void> _registerBonjourService(SseServerConfig config) async {
|
||||||
try {
|
try {
|
||||||
debugPrint('📡 [SseServer] Registering Bonjour service ${NetworkScannerService.serviceType}...');
|
debugPrint(
|
||||||
|
'📡 [SseServer] Registering Bonjour service ${NetworkScannerService.serviceType}...',
|
||||||
|
);
|
||||||
|
|
||||||
_bonjourRegistration = await register(
|
_bonjourRegistration = await register(
|
||||||
const Service(
|
const Service(
|
||||||
@@ -148,7 +151,9 @@ class SseServerService {
|
|||||||
port: config.port,
|
port: config.port,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
debugPrint('✅ [SseServer] Bonjour service registered on port ${config.port}');
|
debugPrint(
|
||||||
|
'✅ [SseServer] Bonjour service registered on port ${config.port}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('⚠️ [SseServer] Failed to register Bonjour service: $e');
|
debugPrint('⚠️ [SseServer] Failed to register Bonjour service: $e');
|
||||||
@@ -168,20 +173,28 @@ class SseServerService {
|
|||||||
/// Clean up dead/closed connections
|
/// Clean up dead/closed connections
|
||||||
void _cleanupDeadConnections() {
|
void _cleanupDeadConnections() {
|
||||||
// Clean up message streams
|
// Clean up message streams
|
||||||
final deadMessageStreams = _messageStreams.where((s) => s.isClosed).toList();
|
final deadMessageStreams = _messageStreams
|
||||||
|
.where((s) => s.isClosed)
|
||||||
|
.toList();
|
||||||
for (final stream in deadMessageStreams) {
|
for (final stream in deadMessageStreams) {
|
||||||
_messageStreams.remove(stream);
|
_messageStreams.remove(stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up contact streams
|
// Clean up contact streams
|
||||||
final deadContactStreams = _contactStreams.where((s) => s.isClosed).toList();
|
final deadContactStreams = _contactStreams
|
||||||
|
.where((s) => s.isClosed)
|
||||||
|
.toList();
|
||||||
for (final stream in deadContactStreams) {
|
for (final stream in deadContactStreams) {
|
||||||
_contactStreams.remove(stream);
|
_contactStreams.remove(stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deadMessageStreams.isNotEmpty || deadContactStreams.isNotEmpty) {
|
if (deadMessageStreams.isNotEmpty || deadContactStreams.isNotEmpty) {
|
||||||
debugPrint('🧹 [SseServer] Cleaned up ${deadMessageStreams.length} dead message streams, ${deadContactStreams.length} dead contact streams');
|
debugPrint(
|
||||||
debugPrint(' Active: ${_messageStreams.length} message clients, ${_contactStreams.length} contact clients');
|
'🧹 [SseServer] Cleaned up ${deadMessageStreams.length} dead message streams, ${deadContactStreams.length} dead contact streams',
|
||||||
|
);
|
||||||
|
debugPrint(
|
||||||
|
' Active: ${_messageStreams.length} message clients, ${_contactStreams.length} contact clients',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +282,9 @@ class SseServerService {
|
|||||||
/// Handle SSE messages stream
|
/// Handle SSE messages stream
|
||||||
shelf.Response _handleSseMessages(shelf.Request request) {
|
shelf.Response _handleSseMessages(shelf.Request request) {
|
||||||
return request.hijack((channel) async {
|
return request.hijack((channel) async {
|
||||||
debugPrint('📥 [SseServer] New SSE client connected (messages) via hijack');
|
debugPrint(
|
||||||
|
'📥 [SseServer] New SSE client connected (messages) via hijack',
|
||||||
|
);
|
||||||
|
|
||||||
// Set up the sink for sending data
|
// Set up the sink for sending data
|
||||||
final sink = utf8.encoder.startChunkedConversion(channel.sink);
|
final sink = utf8.encoder.startChunkedConversion(channel.sink);
|
||||||
@@ -297,7 +312,9 @@ class SseServerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start keep-alive timer
|
// Start keep-alive timer
|
||||||
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
|
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (
|
||||||
|
timer,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
sink.add(': keepalive\n\n');
|
sink.add(': keepalive\n\n');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -337,7 +354,9 @@ class SseServerService {
|
|||||||
/// Handle SSE contacts stream
|
/// Handle SSE contacts stream
|
||||||
shelf.Response _handleSseContacts(shelf.Request request) {
|
shelf.Response _handleSseContacts(shelf.Request request) {
|
||||||
return request.hijack((channel) async {
|
return request.hijack((channel) async {
|
||||||
debugPrint('📥 [SseServer] New SSE client connected (contacts) via hijack');
|
debugPrint(
|
||||||
|
'📥 [SseServer] New SSE client connected (contacts) via hijack',
|
||||||
|
);
|
||||||
|
|
||||||
// Set up the sink for sending data
|
// Set up the sink for sending data
|
||||||
final sink = utf8.encoder.startChunkedConversion(channel.sink);
|
final sink = utf8.encoder.startChunkedConversion(channel.sink);
|
||||||
@@ -365,7 +384,9 @@ class SseServerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start keep-alive timer
|
// Start keep-alive timer
|
||||||
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
|
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (
|
||||||
|
timer,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
sink.add(': keepalive\n\n');
|
sink.add(': keepalive\n\n');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -432,7 +453,9 @@ class SseServerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handle POST channel message request
|
/// Handle POST channel message request
|
||||||
Future<shelf.Response> _handlePostChannelMessage(shelf.Request request) async {
|
Future<shelf.Response> _handlePostChannelMessage(
|
||||||
|
shelf.Request request,
|
||||||
|
) async {
|
||||||
try {
|
try {
|
||||||
final body = await request.readAsString();
|
final body = await request.readAsString();
|
||||||
final json = jsonDecode(body) as Map<String, dynamic>;
|
final json = jsonDecode(body) as Map<String, dynamic>;
|
||||||
@@ -442,7 +465,9 @@ class SseServerService {
|
|||||||
|
|
||||||
if (onSendChannelMessage == null) {
|
if (onSendChannelMessage == null) {
|
||||||
return shelf.Response.internalServerError(
|
return shelf.Response.internalServerError(
|
||||||
body: jsonEncode({'error': 'Send channel message callback not configured'}),
|
body: jsonEncode({
|
||||||
|
'error': 'Send channel message callback not configured',
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,10 +599,7 @@ class SseServerService {
|
|||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
''';
|
''';
|
||||||
return shelf.Response.ok(
|
return shelf.Response.ok(html, headers: {'content-type': 'text/html'});
|
||||||
html,
|
|
||||||
headers: {'content-type': 'text/html'},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Broadcast a new message to all SSE clients
|
/// Broadcast a new message to all SSE clients
|
||||||
@@ -599,7 +621,9 @@ class SseServerService {
|
|||||||
try {
|
try {
|
||||||
stream.add(event);
|
stream.add(event);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('⚠️ [SseServer] Failed to send to stream, marking as dead: $e');
|
debugPrint(
|
||||||
|
'⚠️ [SseServer] Failed to send to stream, marking as dead: $e',
|
||||||
|
);
|
||||||
deadStreams.add(stream);
|
deadStreams.add(stream);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -608,14 +632,20 @@ class SseServerService {
|
|||||||
// Remove dead streams
|
// Remove dead streams
|
||||||
for (final stream in deadStreams) {
|
for (final stream in deadStreams) {
|
||||||
_messageStreams.remove(stream);
|
_messageStreams.remove(stream);
|
||||||
stream.close().catchError((e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'));
|
stream.close().catchError(
|
||||||
|
(e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deadStreams.isNotEmpty) {
|
if (deadStreams.isNotEmpty) {
|
||||||
debugPrint('🧹 [SseServer] Removed ${deadStreams.length} dead message streams during broadcast');
|
debugPrint(
|
||||||
|
'🧹 [SseServer] Removed ${deadStreams.length} dead message streams during broadcast',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('📢 [SseServer] Broadcasted message to ${_messageStreams.length} clients');
|
debugPrint(
|
||||||
|
'📢 [SseServer] Broadcasted message to ${_messageStreams.length} clients',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Broadcast a new or updated contact to all SSE clients
|
/// Broadcast a new or updated contact to all SSE clients
|
||||||
@@ -634,7 +664,9 @@ class SseServerService {
|
|||||||
try {
|
try {
|
||||||
stream.add(event);
|
stream.add(event);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('⚠️ [SseServer] Failed to send to stream, marking as dead: $e');
|
debugPrint(
|
||||||
|
'⚠️ [SseServer] Failed to send to stream, marking as dead: $e',
|
||||||
|
);
|
||||||
deadStreams.add(stream);
|
deadStreams.add(stream);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -643,14 +675,20 @@ class SseServerService {
|
|||||||
// Remove dead streams
|
// Remove dead streams
|
||||||
for (final stream in deadStreams) {
|
for (final stream in deadStreams) {
|
||||||
_contactStreams.remove(stream);
|
_contactStreams.remove(stream);
|
||||||
stream.close().catchError((e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'));
|
stream.close().catchError(
|
||||||
|
(e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deadStreams.isNotEmpty) {
|
if (deadStreams.isNotEmpty) {
|
||||||
debugPrint('🧹 [SseServer] Removed ${deadStreams.length} dead contact streams during broadcast');
|
debugPrint(
|
||||||
|
'🧹 [SseServer] Removed ${deadStreams.length} dead contact streams during broadcast',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('📢 [SseServer] Broadcasted contact to ${_contactStreams.length} clients');
|
debugPrint(
|
||||||
|
'📢 [SseServer] Broadcasted contact to ${_contactStreams.length} clients',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format SSE event
|
/// Format SSE event
|
||||||
@@ -694,6 +732,9 @@ class SseServerService {
|
|||||||
'isRead': message.isRead,
|
'isRead': message.isRead,
|
||||||
'echoCount': message.echoCount,
|
'echoCount': message.echoCount,
|
||||||
'firstEchoAt': message.firstEchoAt?.toIso8601String(),
|
'firstEchoAt': message.firstEchoAt?.toIso8601String(),
|
||||||
|
'lastEchoSnrRaw': message.lastEchoSnrRaw,
|
||||||
|
'lastEchoRssiDbm': message.lastEchoRssiDbm,
|
||||||
|
'lastEchoAt': message.lastEchoAt?.toIso8601String(),
|
||||||
'isDrawing': message.isDrawing,
|
'isDrawing': message.isDrawing,
|
||||||
'drawingId': message.drawingId,
|
'drawingId': message.drawingId,
|
||||||
};
|
};
|
||||||
|
|||||||
41
lib/services/voice_bitrate_preferences.dart
Normal file
41
lib/services/voice_bitrate_preferences.dart
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../utils/voice_message_parser.dart';
|
||||||
|
|
||||||
|
/// Stores user-selected voice bitrate and maps it to supported codec modes.
|
||||||
|
class VoiceBitratePreferences {
|
||||||
|
static const String _bitrateKey = 'voice_bitrate';
|
||||||
|
static const int defaultBitrate = 1300;
|
||||||
|
static const List<int> supportedBitrates = [700, 1200, 1300, 1400, 1600, 2400, 3200];
|
||||||
|
|
||||||
|
static Future<int> getBitrate() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final value = prefs.getInt(_bitrateKey) ?? defaultBitrate;
|
||||||
|
return supportedBitrates.contains(value) ? value : defaultBitrate;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setBitrate(int bitrate) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt(_bitrateKey, bitrate);
|
||||||
|
}
|
||||||
|
|
||||||
|
static VoicePacketMode toVoiceMode(int bitrate) {
|
||||||
|
switch (bitrate) {
|
||||||
|
case 1200:
|
||||||
|
return VoicePacketMode.mode1200;
|
||||||
|
case 1300:
|
||||||
|
return VoicePacketMode.mode1300;
|
||||||
|
case 1400:
|
||||||
|
return VoicePacketMode.mode1400;
|
||||||
|
case 1600:
|
||||||
|
return VoicePacketMode.mode1600;
|
||||||
|
case 2400:
|
||||||
|
return VoicePacketMode.mode2400;
|
||||||
|
case 3200:
|
||||||
|
return VoicePacketMode.mode3200;
|
||||||
|
case 700:
|
||||||
|
return VoicePacketMode.mode700c;
|
||||||
|
default:
|
||||||
|
return VoicePacketMode.mode1300;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ export 'package:codec2_flutter/codec2_flutter.dart' show Codec2Mode;
|
|||||||
/// Maps [VoicePacketMode] to the [Codec2Mode] enum from the FFI plugin.
|
/// Maps [VoicePacketMode] to the [Codec2Mode] enum from the FFI plugin.
|
||||||
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
||||||
switch (pktMode) {
|
switch (pktMode) {
|
||||||
|
case VoicePacketMode.mode3200: return Codec2Mode.mode3200;
|
||||||
|
case VoicePacketMode.mode1600: return Codec2Mode.mode1600;
|
||||||
|
case VoicePacketMode.mode1400: return Codec2Mode.mode1400;
|
||||||
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
|
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
|
||||||
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
|
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
|
||||||
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;
|
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
import 'dart:async';
|
||||||
import 'package:audioplayers/audioplayers.dart';
|
import 'package:audioplayers/audioplayers.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
@@ -8,36 +9,74 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
/// to the system temp directory and using [AudioPlayer].
|
/// to the system temp directory and using [AudioPlayer].
|
||||||
class VoicePlayerService {
|
class VoicePlayerService {
|
||||||
final AudioPlayer _player = AudioPlayer();
|
final AudioPlayer _player = AudioPlayer();
|
||||||
|
final StreamController<void> _events = StreamController<void>.broadcast();
|
||||||
bool _isPlaying = false;
|
bool _isPlaying = false;
|
||||||
|
Duration _position = Duration.zero;
|
||||||
|
Duration _duration = Duration.zero;
|
||||||
|
Timer? _fallbackTicker;
|
||||||
|
DateTime? _playbackStartedAt;
|
||||||
|
|
||||||
bool get isPlaying => _isPlaying;
|
bool get isPlaying => _isPlaying;
|
||||||
|
Duration get position => _position;
|
||||||
|
Duration get duration => _duration;
|
||||||
|
Stream<void> get events => _events.stream;
|
||||||
|
|
||||||
VoicePlayerService() {
|
VoicePlayerService() {
|
||||||
_player.onPlayerStateChanged.listen((state) {
|
_player.onPlayerStateChanged.listen((state) {
|
||||||
debugPrint('🔊 [VoicePlayer] state → $state');
|
debugPrint('🔊 [VoicePlayer] state → $state');
|
||||||
_isPlaying = state == PlayerState.playing;
|
_isPlaying = state == PlayerState.playing;
|
||||||
|
if (_isPlaying) {
|
||||||
|
_startFallbackTicker();
|
||||||
|
} else {
|
||||||
|
_stopFallbackTicker();
|
||||||
|
}
|
||||||
|
_events.add(null);
|
||||||
});
|
});
|
||||||
_player.onLog.listen((msg) => debugPrint('🔊 [VoicePlayer] log: $msg'));
|
_player.onLog.listen((msg) => debugPrint('🔊 [VoicePlayer] log: $msg'));
|
||||||
|
_player.onPositionChanged.listen((position) {
|
||||||
|
_position = position;
|
||||||
|
_events.add(null);
|
||||||
|
});
|
||||||
|
_player.onDurationChanged.listen((duration) {
|
||||||
|
_duration = duration;
|
||||||
|
_events.add(null);
|
||||||
|
});
|
||||||
|
_player.onPlayerComplete.listen((_) {
|
||||||
|
_isPlaying = false;
|
||||||
|
_position = _duration;
|
||||||
|
_stopFallbackTicker();
|
||||||
|
_events.add(null);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Play [pcmSamples] (Int16, 8000 Hz, mono).
|
/// Play [pcmSamples] (Int16, 8000 Hz, mono).
|
||||||
Future<void> play(Int16List pcmSamples) async {
|
Future<void> play(Int16List pcmSamples) async {
|
||||||
debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples');
|
debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples');
|
||||||
if (_isPlaying) await stop();
|
if (_isPlaying) await stop();
|
||||||
|
_position = Duration.zero;
|
||||||
|
_duration = Duration(milliseconds: (pcmSamples.length * 1000) ~/ 8000);
|
||||||
|
_playbackStartedAt = DateTime.now();
|
||||||
|
_events.add(null);
|
||||||
|
|
||||||
final wavBytes = _buildWav(pcmSamples, sampleRate: 8000);
|
final wavBytes = _buildWav(pcmSamples, sampleRate: 8000);
|
||||||
final tmpDir = await getTemporaryDirectory();
|
final tmpDir = await getTemporaryDirectory();
|
||||||
final file = File('${tmpDir.path}/vc_voice.wav');
|
final file = File('${tmpDir.path}/vc_voice.wav');
|
||||||
await file.writeAsBytes(wavBytes);
|
await file.writeAsBytes(wavBytes);
|
||||||
debugPrint('🔊 [VoicePlayer] WAV written: ${wavBytes.length} bytes → ${file.path}');
|
debugPrint(
|
||||||
|
'🔊 [VoicePlayer] WAV written: ${wavBytes.length} bytes → ${file.path}',
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_isPlaying = true;
|
_isPlaying = true;
|
||||||
|
_startFallbackTicker();
|
||||||
|
_events.add(null);
|
||||||
await _player.play(DeviceFileSource(file.path));
|
await _player.play(DeviceFileSource(file.path));
|
||||||
debugPrint('🔊 [VoicePlayer] play() returned (audio playing)');
|
debugPrint('🔊 [VoicePlayer] play() returned (audio playing)');
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('❌ [VoicePlayer] play() error: $e\n$st');
|
debugPrint('❌ [VoicePlayer] play() error: $e\n$st');
|
||||||
_isPlaying = false;
|
_isPlaying = false;
|
||||||
|
_stopFallbackTicker();
|
||||||
|
_events.add(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,40 +84,76 @@ class VoicePlayerService {
|
|||||||
debugPrint('🔊 [VoicePlayer] stop()');
|
debugPrint('🔊 [VoicePlayer] stop()');
|
||||||
await _player.stop();
|
await _player.stop();
|
||||||
_isPlaying = false;
|
_isPlaying = false;
|
||||||
|
_position = Duration.zero;
|
||||||
|
_playbackStartedAt = null;
|
||||||
|
_stopFallbackTicker();
|
||||||
|
_events.add(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_stopFallbackTicker();
|
||||||
|
_events.close();
|
||||||
_player.dispose();
|
_player.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _startFallbackTicker() {
|
||||||
|
if (_fallbackTicker != null) return;
|
||||||
|
_fallbackTicker = Timer.periodic(const Duration(milliseconds: 100), (_) {
|
||||||
|
if (!_isPlaying || _duration.inMilliseconds <= 0) return;
|
||||||
|
final startedAt = _playbackStartedAt;
|
||||||
|
if (startedAt == null) return;
|
||||||
|
final elapsed = DateTime.now().difference(startedAt);
|
||||||
|
final clamped = elapsed > _duration ? _duration : elapsed;
|
||||||
|
if (clamped > _position) {
|
||||||
|
_position = clamped;
|
||||||
|
_events.add(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopFallbackTicker() {
|
||||||
|
_fallbackTicker?.cancel();
|
||||||
|
_fallbackTicker = null;
|
||||||
|
}
|
||||||
|
|
||||||
// ── WAV file builder ─────────────────────────────────────────────────────
|
// ── WAV file builder ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Constructs a minimal WAV (RIFF/PCM) file from Int16 mono samples.
|
/// Constructs a minimal WAV (RIFF/PCM) file from Int16 mono samples.
|
||||||
static Uint8List _buildWav(Int16List samples, {required int sampleRate}) {
|
static Uint8List _buildWav(Int16List samples, {required int sampleRate}) {
|
||||||
const int numChannels = 1;
|
const int numChannels = 1;
|
||||||
const int bitsPerSample = 16;
|
const int bitsPerSample = 16;
|
||||||
const int audioFormat = 1; // PCM
|
const int audioFormat = 1; // PCM
|
||||||
|
|
||||||
final dataSize = samples.length * 2; // 2 bytes per Int16 sample
|
final dataSize = samples.length * 2; // 2 bytes per Int16 sample
|
||||||
final byteRate = sampleRate * numChannels * bitsPerSample ~/ 8;
|
final byteRate = sampleRate * numChannels * bitsPerSample ~/ 8;
|
||||||
final blockAlign = numChannels * bitsPerSample ~/ 8;
|
final blockAlign = numChannels * bitsPerSample ~/ 8;
|
||||||
final totalSize = 36 + dataSize;
|
final totalSize = 36 + dataSize;
|
||||||
|
|
||||||
final buf = ByteData(44 + dataSize);
|
final buf = ByteData(44 + dataSize);
|
||||||
var offset = 0;
|
var offset = 0;
|
||||||
|
|
||||||
void writeStr(String s) {
|
void writeStr(String s) {
|
||||||
for (final c in s.codeUnits) { buf.setUint8(offset++, c); }
|
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;
|
||||||
}
|
}
|
||||||
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');
|
writeStr('RIFF');
|
||||||
writeU32(totalSize);
|
writeU32(totalSize);
|
||||||
writeStr('WAVE');
|
writeStr('WAVE');
|
||||||
writeStr('fmt ');
|
writeStr('fmt ');
|
||||||
writeU32(16); // subchunk1 size
|
writeU32(16); // subchunk1 size
|
||||||
writeU16(audioFormat); // 1 = PCM
|
writeU16(audioFormat); // 1 = PCM
|
||||||
writeU16(numChannels);
|
writeU16(numChannels);
|
||||||
writeU32(sampleRate);
|
writeU32(sampleRate);
|
||||||
writeU32(byteRate);
|
writeU32(byteRate);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:record/record.dart';
|
import 'package:record/record.dart';
|
||||||
@@ -23,9 +24,11 @@ class VoiceRecorderService {
|
|||||||
/// Start capturing PCM audio.
|
/// Start capturing PCM audio.
|
||||||
///
|
///
|
||||||
/// [chunkDuration] controls how often samples are emitted (default 1 s).
|
/// [chunkDuration] controls how often samples are emitted (default 1 s).
|
||||||
|
/// [enableBandPassFilter] applies voice-tuned band-pass filtering when true.
|
||||||
/// The returned stream emits [Int16List] chunks that are ready for Codec2 encoding.
|
/// The returned stream emits [Int16List] chunks that are ready for Codec2 encoding.
|
||||||
Stream<Int16List> startCapture({
|
Stream<Int16List> startCapture({
|
||||||
Duration chunkDuration = const Duration(seconds: 1),
|
Duration chunkDuration = const Duration(seconds: 1),
|
||||||
|
bool enableBandPassFilter = true,
|
||||||
}) {
|
}) {
|
||||||
if (_isRecording) {
|
if (_isRecording) {
|
||||||
throw StateError('VoiceRecorderService: already recording');
|
throw StateError('VoiceRecorderService: already recording');
|
||||||
@@ -36,11 +39,17 @@ class VoiceRecorderService {
|
|||||||
);
|
);
|
||||||
_isRecording = true;
|
_isRecording = true;
|
||||||
|
|
||||||
_startRecording(chunkDuration);
|
_startRecording(
|
||||||
|
chunkDuration,
|
||||||
|
enableBandPassFilter: enableBandPassFilter,
|
||||||
|
);
|
||||||
return _controller!.stream;
|
return _controller!.stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _startRecording(Duration chunkDuration) async {
|
Future<void> _startRecording(
|
||||||
|
Duration chunkDuration, {
|
||||||
|
required bool enableBandPassFilter,
|
||||||
|
}) async {
|
||||||
final config = const RecordConfig(
|
final config = const RecordConfig(
|
||||||
encoder: AudioEncoder.pcm16bits,
|
encoder: AudioEncoder.pcm16bits,
|
||||||
sampleRate: 8000,
|
sampleRate: 8000,
|
||||||
@@ -50,6 +59,11 @@ class VoiceRecorderService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final stream = await _recorder.startStream(config);
|
final stream = await _recorder.startStream(config);
|
||||||
|
final voiceFilter = _VoiceBandPassFilter(
|
||||||
|
sampleRate: 8000,
|
||||||
|
lowCutHz: 250.0,
|
||||||
|
highCutHz: 3400.0,
|
||||||
|
);
|
||||||
final chunkBytes = 8000 * 2 * chunkDuration.inMilliseconds ~/ 1000;
|
final chunkBytes = 8000 * 2 * chunkDuration.inMilliseconds ~/ 1000;
|
||||||
final buffer = <int>[];
|
final buffer = <int>[];
|
||||||
|
|
||||||
@@ -59,13 +73,19 @@ class VoiceRecorderService {
|
|||||||
while (buffer.length >= chunkBytes) {
|
while (buffer.length >= chunkBytes) {
|
||||||
final chunk = buffer.sublist(0, chunkBytes);
|
final chunk = buffer.sublist(0, chunkBytes);
|
||||||
buffer.removeRange(0, chunkBytes);
|
buffer.removeRange(0, chunkBytes);
|
||||||
_controller?.add(_bytesToInt16(Uint8List.fromList(chunk)));
|
final pcm = _bytesToInt16(Uint8List.fromList(chunk));
|
||||||
|
_controller?.add(
|
||||||
|
enableBandPassFilter ? voiceFilter.process(pcm) : pcm,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDone: () {
|
onDone: () {
|
||||||
if (buffer.isNotEmpty) {
|
if (buffer.isNotEmpty) {
|
||||||
final padded = _padToEven(buffer);
|
final padded = _padToEven(buffer);
|
||||||
_controller?.add(_bytesToInt16(Uint8List.fromList(padded)));
|
final pcm = _bytesToInt16(Uint8List.fromList(padded));
|
||||||
|
_controller?.add(
|
||||||
|
enableBandPassFilter ? voiceFilter.process(pcm) : pcm,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_controller?.close();
|
_controller?.close();
|
||||||
},
|
},
|
||||||
@@ -117,3 +137,121 @@ class VoiceRecorderService {
|
|||||||
return buf;
|
return buf;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Band-pass filter tuned for human voice at 8 kHz input.
|
||||||
|
///
|
||||||
|
/// Uses a cascaded high-pass + low-pass biquad to attenuate very low-frequency
|
||||||
|
/// rumble and high-frequency noise outside the speech band.
|
||||||
|
class _VoiceBandPassFilter {
|
||||||
|
final _BiquadFilter _highPass;
|
||||||
|
final _BiquadFilter _lowPass;
|
||||||
|
|
||||||
|
_VoiceBandPassFilter({
|
||||||
|
required int sampleRate,
|
||||||
|
required double lowCutHz,
|
||||||
|
required double highCutHz,
|
||||||
|
}) : _highPass = _BiquadFilter.highPass(
|
||||||
|
sampleRate: sampleRate.toDouble(),
|
||||||
|
cutoffHz: lowCutHz,
|
||||||
|
),
|
||||||
|
_lowPass = _BiquadFilter.lowPass(
|
||||||
|
sampleRate: sampleRate.toDouble(),
|
||||||
|
cutoffHz: highCutHz,
|
||||||
|
);
|
||||||
|
|
||||||
|
Int16List process(Int16List input) {
|
||||||
|
final output = Int16List(input.length);
|
||||||
|
for (var i = 0; i < input.length; i++) {
|
||||||
|
var sample = input[i].toDouble();
|
||||||
|
sample = _highPass.process(sample);
|
||||||
|
sample = _lowPass.process(sample);
|
||||||
|
output[i] = sample.clamp(-32768.0, 32767.0).round();
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard biquad IIR filter (Direct Form I).
|
||||||
|
class _BiquadFilter {
|
||||||
|
final double _b0;
|
||||||
|
final double _b1;
|
||||||
|
final double _b2;
|
||||||
|
final double _a1;
|
||||||
|
final double _a2;
|
||||||
|
|
||||||
|
double _x1 = 0.0;
|
||||||
|
double _x2 = 0.0;
|
||||||
|
double _y1 = 0.0;
|
||||||
|
double _y2 = 0.0;
|
||||||
|
|
||||||
|
_BiquadFilter._({
|
||||||
|
required double b0,
|
||||||
|
required double b1,
|
||||||
|
required double b2,
|
||||||
|
required double a1,
|
||||||
|
required double a2,
|
||||||
|
}) : _b0 = b0,
|
||||||
|
_b1 = b1,
|
||||||
|
_b2 = b2,
|
||||||
|
_a1 = a1,
|
||||||
|
_a2 = a2;
|
||||||
|
|
||||||
|
factory _BiquadFilter.lowPass({
|
||||||
|
required double sampleRate,
|
||||||
|
required double cutoffHz,
|
||||||
|
}) {
|
||||||
|
const q = math.sqrt1_2; // Butterworth response (Q = 1/sqrt(2))
|
||||||
|
final omega = 2.0 * math.pi * cutoffHz / sampleRate;
|
||||||
|
final cosOmega = math.cos(omega);
|
||||||
|
final alpha = math.sin(omega) / (2.0 * q);
|
||||||
|
|
||||||
|
final b0 = (1.0 - cosOmega) / 2.0;
|
||||||
|
final b1 = 1.0 - cosOmega;
|
||||||
|
final b2 = (1.0 - cosOmega) / 2.0;
|
||||||
|
final a0 = 1.0 + alpha;
|
||||||
|
final a1 = -2.0 * cosOmega;
|
||||||
|
final a2 = 1.0 - alpha;
|
||||||
|
|
||||||
|
return _BiquadFilter._(
|
||||||
|
b0: b0 / a0,
|
||||||
|
b1: b1 / a0,
|
||||||
|
b2: b2 / a0,
|
||||||
|
a1: a1 / a0,
|
||||||
|
a2: a2 / a0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory _BiquadFilter.highPass({
|
||||||
|
required double sampleRate,
|
||||||
|
required double cutoffHz,
|
||||||
|
}) {
|
||||||
|
const q = math.sqrt1_2; // Butterworth response (Q = 1/sqrt(2))
|
||||||
|
final omega = 2.0 * math.pi * cutoffHz / sampleRate;
|
||||||
|
final cosOmega = math.cos(omega);
|
||||||
|
final alpha = math.sin(omega) / (2.0 * q);
|
||||||
|
|
||||||
|
final b0 = (1.0 + cosOmega) / 2.0;
|
||||||
|
final b1 = -(1.0 + cosOmega);
|
||||||
|
final b2 = (1.0 + cosOmega) / 2.0;
|
||||||
|
final a0 = 1.0 + alpha;
|
||||||
|
final a1 = -2.0 * cosOmega;
|
||||||
|
final a2 = 1.0 - alpha;
|
||||||
|
|
||||||
|
return _BiquadFilter._(
|
||||||
|
b0: b0 / a0,
|
||||||
|
b1: b1 / a0,
|
||||||
|
b2: b2 / a0,
|
||||||
|
a1: a1 / a0,
|
||||||
|
a2: a2 / a0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
double process(double x) {
|
||||||
|
final y = _b0 * x + _b1 * _x1 + _b2 * _x2 - _a1 * _y1 - _a2 * _y2;
|
||||||
|
_x2 = _x1;
|
||||||
|
_x1 = x;
|
||||||
|
_y2 = _y1;
|
||||||
|
_y1 = y;
|
||||||
|
return y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ extension MessageLocalization on Message {
|
|||||||
|
|
||||||
// For channel messages, show echo count instead of delivery status
|
// For channel messages, show echo count instead of delivery status
|
||||||
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
|
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
|
||||||
|
final latestMeta = _formatEchoMeta(context);
|
||||||
if (echoCount == 0) {
|
if (echoCount == 0) {
|
||||||
return l10n.broadcast; // "Broadcast (no echoes yet)"
|
return l10n.broadcast; // "Broadcast (no echoes yet)"
|
||||||
} else if (echoCount == 1) {
|
} else if (echoCount == 1) {
|
||||||
return 'Rebroadcast by 1 node';
|
return latestMeta == null ? '1 node' : '1 node • $latestMeta';
|
||||||
} else {
|
} else {
|
||||||
return 'Rebroadcast by $echoCount nodes';
|
return latestMeta == null
|
||||||
|
? '$echoCount nodes'
|
||||||
|
: '$echoCount nodes • $latestMeta';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,4 +49,53 @@ extension MessageLocalization on Message {
|
|||||||
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
|
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
|
||||||
return l10n.daysAgo(diff.inDays);
|
return l10n.daysAgo(diff.inDays);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _formatEchoMeta(BuildContext context) {
|
||||||
|
if (lastEchoSnrRaw == null &&
|
||||||
|
lastEchoRssiDbm == null &&
|
||||||
|
lastEchoAt == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final parts = <String>[];
|
||||||
|
if (lastEchoRssiDbm != null) {
|
||||||
|
parts.add('R ${_barsForRssi(lastEchoRssiDbm!)} $lastEchoRssiDbm dBm');
|
||||||
|
}
|
||||||
|
if (lastEchoSnrRaw != null) {
|
||||||
|
final snrDb = lastEchoSnrRaw!.toSigned(8) / 4.0;
|
||||||
|
parts.add('S ${_barsForSnr(snrDb)} ${snrDb.toStringAsFixed(1)} dB');
|
||||||
|
}
|
||||||
|
if (lastEchoAt != null) {
|
||||||
|
final diff = DateTime.now().difference(lastEchoAt!);
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
if (diff.inMinutes < 1) {
|
||||||
|
parts.add(l10n.justNow);
|
||||||
|
} else if (diff.inMinutes < 60) {
|
||||||
|
parts.add(l10n.minutesAgo(diff.inMinutes));
|
||||||
|
} else if (diff.inHours < 24) {
|
||||||
|
parts.add(l10n.hoursAgo(diff.inHours));
|
||||||
|
} else {
|
||||||
|
parts.add(l10n.daysAgo(diff.inDays));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.isEmpty) return null;
|
||||||
|
return parts.join(' • ');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _barsForRssi(int rssiDbm) {
|
||||||
|
// Approximate useful RSSI range: -120..-70 dBm
|
||||||
|
final score = ((rssiDbm + 120) / 10).round().clamp(0, 5);
|
||||||
|
return _asciiBars(score, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _barsForSnr(double snrDb) {
|
||||||
|
// Approximate useful SNR range: -5..+20 dB
|
||||||
|
final score = ((snrDb + 5.0) / 5.0).round().clamp(0, 5);
|
||||||
|
return _asciiBars(score, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _asciiBars(int filled, int total) {
|
||||||
|
return '[${'#' * filled}${'-' * (total - filled)}]';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,19 @@ enum VoicePacketMode {
|
|||||||
mode700c(0, '700C'),
|
mode700c(0, '700C'),
|
||||||
mode1200(1, '1200'),
|
mode1200(1, '1200'),
|
||||||
mode2400(2, '2400'),
|
mode2400(2, '2400'),
|
||||||
mode1300(3, '1300');
|
mode1300(3, '1300'),
|
||||||
|
mode1400(4, '1400'),
|
||||||
|
mode1600(5, '1600'),
|
||||||
|
mode3200(6, '3200');
|
||||||
|
|
||||||
const VoicePacketMode(this.id, this.label);
|
const VoicePacketMode(this.id, this.label);
|
||||||
final int id;
|
final int id;
|
||||||
final String label;
|
final String label;
|
||||||
|
|
||||||
static VoicePacketMode fromId(int id) =>
|
static VoicePacketMode fromId(int id) => VoicePacketMode.values.firstWhere(
|
||||||
VoicePacketMode.values.firstWhere((m) => m.id == id, orElse: () => VoicePacketMode.mode700c);
|
(m) => m.id == id,
|
||||||
|
orElse: () => VoicePacketMode.mode1300,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single Codec2-encoded chunk belonging to a multi-packet voice session.
|
/// A single Codec2-encoded chunk belonging to a multi-packet voice session.
|
||||||
@@ -27,8 +32,8 @@ enum VoicePacketMode {
|
|||||||
class VoicePacket {
|
class VoicePacket {
|
||||||
final String sessionId; // 8 hex chars (4 bytes)
|
final String sessionId; // 8 hex chars (4 bytes)
|
||||||
final VoicePacketMode mode;
|
final VoicePacketMode mode;
|
||||||
final int index; // 0-based
|
final int index; // 0-based
|
||||||
final int total; // total packet count
|
final int total; // total packet count
|
||||||
final Uint8List codec2Data;
|
final Uint8List codec2Data;
|
||||||
|
|
||||||
const VoicePacket({
|
const VoicePacket({
|
||||||
@@ -89,7 +94,8 @@ class VoicePacket {
|
|||||||
// ── Binary format ────────────────────────────────────────────────────────
|
// ── Binary format ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
static const int _binaryMagic = 0x56; // 'V'
|
static const int _binaryMagic = 0x56; // 'V'
|
||||||
static const int _binaryHeaderLen = 8; // magic(1)+session(4)+mode(1)+idx(1)+total(1)
|
static const int _binaryHeaderLen =
|
||||||
|
8; // magic(1)+session(4)+mode(1)+idx(1)+total(1)
|
||||||
|
|
||||||
static bool isVoiceBinary(Uint8List payload) =>
|
static bool isVoiceBinary(Uint8List payload) =>
|
||||||
payload.isNotEmpty && payload[0] == _binaryMagic;
|
payload.isNotEmpty && payload[0] == _binaryMagic;
|
||||||
@@ -100,10 +106,12 @@ class VoicePacket {
|
|||||||
if (payload[0] != _binaryMagic) return null;
|
if (payload[0] != _binaryMagic) return null;
|
||||||
try {
|
try {
|
||||||
final sessionBytes = payload.sublist(1, 5);
|
final sessionBytes = payload.sublist(1, 5);
|
||||||
final sessionId = sessionBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
final sessionId = sessionBytes
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
final modeId = payload[5];
|
final modeId = payload[5];
|
||||||
final index = payload[6];
|
final index = payload[6];
|
||||||
final total = payload[7];
|
final total = payload[7];
|
||||||
if (total < 1) return null;
|
if (total < 1) return null;
|
||||||
final codec2Data = payload.sublist(_binaryHeaderLen);
|
final codec2Data = payload.sublist(_binaryHeaderLen);
|
||||||
return VoicePacket(
|
return VoicePacket(
|
||||||
@@ -122,7 +130,10 @@ class VoicePacket {
|
|||||||
Uint8List encodeBinary() {
|
Uint8List encodeBinary() {
|
||||||
final sessionBytes = Uint8List(4);
|
final sessionBytes = Uint8List(4);
|
||||||
for (var i = 0; i < 4; i++) {
|
for (var i = 0; i < 4; i++) {
|
||||||
sessionBytes[i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
|
sessionBytes[i] = int.parse(
|
||||||
|
sessionId.substring(i * 2, i * 2 + 2),
|
||||||
|
radix: 16,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
final out = Uint8List(_binaryHeaderLen + codec2Data.length);
|
final out = Uint8List(_binaryHeaderLen + codec2Data.length);
|
||||||
out[0] = _binaryMagic;
|
out[0] = _binaryMagic;
|
||||||
@@ -143,7 +154,10 @@ class VoicePacket {
|
|||||||
VoicePacketMode.mode700c => 100,
|
VoicePacketMode.mode700c => 100,
|
||||||
VoicePacketMode.mode1200 => 150,
|
VoicePacketMode.mode1200 => 150,
|
||||||
VoicePacketMode.mode1300 => 175,
|
VoicePacketMode.mode1300 => 175,
|
||||||
|
VoicePacketMode.mode1400 => 175,
|
||||||
|
VoicePacketMode.mode1600 => 200,
|
||||||
VoicePacketMode.mode2400 => 300,
|
VoicePacketMode.mode2400 => 300,
|
||||||
|
VoicePacketMode.mode3200 => 400,
|
||||||
};
|
};
|
||||||
if (bps == 0) return 0;
|
if (bps == 0) return 0;
|
||||||
return (codec2Data.length * 1000 ~/ bps).clamp(0, 1500);
|
return (codec2Data.length * 1000 ~/ bps).clamp(0, 1500);
|
||||||
@@ -153,3 +167,201 @@ class VoicePacket {
|
|||||||
String toString() =>
|
String toString() =>
|
||||||
'VoicePacket($sessionId ${mode.label} [$index/${total - 1}] ${codec2Data.length}B)';
|
'VoicePacket($sessionId ${mode.label} [$index/${total - 1}] ${codec2Data.length}B)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lightweight public/direct message envelope advertising voice availability.
|
||||||
|
///
|
||||||
|
/// Text format:
|
||||||
|
/// VE1:{sid}:{mode}:{total}:{durMs}:{senderKey6}:{ts}:{ver}
|
||||||
|
/// Example:
|
||||||
|
/// VE1:00112233:1:4:3200:aabbccddeeff:1234567890:1
|
||||||
|
class VoiceEnvelope {
|
||||||
|
static const String _prefix = 'VE1:';
|
||||||
|
|
||||||
|
final String sessionId;
|
||||||
|
final VoicePacketMode mode;
|
||||||
|
final int total;
|
||||||
|
final int durationMs;
|
||||||
|
final String senderKey6;
|
||||||
|
final int timestampSec;
|
||||||
|
final int version;
|
||||||
|
|
||||||
|
const VoiceEnvelope({
|
||||||
|
required this.sessionId,
|
||||||
|
required this.mode,
|
||||||
|
required this.total,
|
||||||
|
required this.durationMs,
|
||||||
|
required this.senderKey6,
|
||||||
|
required this.timestampSec,
|
||||||
|
this.version = 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
static bool isVoiceEnvelopeText(String text) => text.startsWith(_prefix);
|
||||||
|
|
||||||
|
static VoiceEnvelope? tryParseText(String text) {
|
||||||
|
if (!isVoiceEnvelopeText(text)) return null;
|
||||||
|
final body = text.substring(_prefix.length);
|
||||||
|
return _tryParseCompact(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
static VoiceEnvelope? _tryParseCompact(String body) {
|
||||||
|
final parts = body.split(':');
|
||||||
|
if (parts.length != 7) return null;
|
||||||
|
try {
|
||||||
|
final sid = parts[0];
|
||||||
|
final mode = int.tryParse(parts[1]);
|
||||||
|
final total = int.tryParse(parts[2]);
|
||||||
|
final durMs = int.tryParse(parts[3]);
|
||||||
|
final senderKey6 = parts[4];
|
||||||
|
final ts = int.tryParse(parts[5]);
|
||||||
|
final ver = int.tryParse(parts[6]);
|
||||||
|
|
||||||
|
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (mode == null || mode < 0 || mode >= VoicePacketMode.values.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (total == null || total < 1 || total > 255) return null;
|
||||||
|
if (durMs == null || durMs < 0 || durMs > 10 * 60 * 1000) return null;
|
||||||
|
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (ts == null || ts <= 0) return null;
|
||||||
|
if (ver == null || ver != 1) return null;
|
||||||
|
|
||||||
|
return VoiceEnvelope(
|
||||||
|
sessionId: sid.toLowerCase(),
|
||||||
|
mode: VoicePacketMode.fromId(mode),
|
||||||
|
total: total,
|
||||||
|
durationMs: durMs,
|
||||||
|
senderKey6: senderKey6.toLowerCase(),
|
||||||
|
timestampSec: ts,
|
||||||
|
version: ver,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String encodeText() {
|
||||||
|
return '$_prefix${sessionId.toLowerCase()}:${mode.id}:$total:$durationMs:${senderKey6.toLowerCase()}:$timestampSec:$version';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Direct control-plane request to fetch voice packets for a session.
|
||||||
|
///
|
||||||
|
/// Text format:
|
||||||
|
/// VR1:{sid}:{want}:{requesterKey6}:{ts}:{ver}
|
||||||
|
/// Example:
|
||||||
|
/// VR1:00112233:a:aabbccddeeff:1234567890:1
|
||||||
|
class VoiceFetchRequest {
|
||||||
|
static const String _prefix = 'VR1:';
|
||||||
|
|
||||||
|
final String sessionId;
|
||||||
|
final String want;
|
||||||
|
final String requesterKey6;
|
||||||
|
final int timestampSec;
|
||||||
|
final int version;
|
||||||
|
|
||||||
|
const VoiceFetchRequest({
|
||||||
|
required this.sessionId,
|
||||||
|
this.want = 'all',
|
||||||
|
required this.requesterKey6,
|
||||||
|
required this.timestampSec,
|
||||||
|
this.version = 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
static bool isVoiceFetchRequestText(String text) => text.startsWith(_prefix);
|
||||||
|
|
||||||
|
static VoiceFetchRequest? tryParseText(String text) {
|
||||||
|
if (!isVoiceFetchRequestText(text)) return null;
|
||||||
|
final body = text.substring(_prefix.length);
|
||||||
|
return _tryParseCompact(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
static VoiceFetchRequest? _tryParseCompact(String body) {
|
||||||
|
final parts = body.split(':');
|
||||||
|
if (parts.length != 5) return null;
|
||||||
|
try {
|
||||||
|
final sid = parts[0];
|
||||||
|
final wantToken = parts[1];
|
||||||
|
final requesterKey6 = parts[2];
|
||||||
|
final ts = int.tryParse(parts[3]);
|
||||||
|
final ver = int.tryParse(parts[4]);
|
||||||
|
final normalizedWant = wantToken == 'a' ? 'all' : wantToken;
|
||||||
|
|
||||||
|
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (normalizedWant != 'all') return null;
|
||||||
|
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (ts == null || ts <= 0) return null;
|
||||||
|
if (ver == null || ver != 1) return null;
|
||||||
|
|
||||||
|
return VoiceFetchRequest(
|
||||||
|
sessionId: sid.toLowerCase(),
|
||||||
|
want: normalizedWant,
|
||||||
|
requesterKey6: requesterKey6.toLowerCase(),
|
||||||
|
timestampSec: ts,
|
||||||
|
version: ver,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String encodeText() {
|
||||||
|
final wantToken = want == 'all' ? 'a' : want;
|
||||||
|
return '$_prefix${sessionId.toLowerCase()}:$wantToken:${requesterKey6.toLowerCase()}:$timestampSec:$version';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a compact visual waveform from real voice packet bytes.
|
||||||
|
///
|
||||||
|
/// Note: This uses the encoded Codec2 packet bytes as the source so it works
|
||||||
|
/// even before full PCM decode/playback is available.
|
||||||
|
class VoiceWaveform {
|
||||||
|
static List<double> buildBarsFromPackets(
|
||||||
|
Iterable<VoicePacket?> packets, {
|
||||||
|
int bars = 24,
|
||||||
|
}) {
|
||||||
|
if (bars <= 0) return const [];
|
||||||
|
|
||||||
|
final merged = <int>[];
|
||||||
|
for (final pkt in packets) {
|
||||||
|
if (pkt == null || pkt.codec2Data.isEmpty) continue;
|
||||||
|
merged.addAll(pkt.codec2Data);
|
||||||
|
}
|
||||||
|
if (merged.isEmpty) return List<double>.filled(bars, 0.0);
|
||||||
|
|
||||||
|
final out = List<double>.filled(bars, 0.0);
|
||||||
|
for (var i = 0; i < bars; i++) {
|
||||||
|
final start = (i * merged.length) ~/ bars;
|
||||||
|
var end = ((i + 1) * merged.length) ~/ bars;
|
||||||
|
if (end <= start) end = start + 1;
|
||||||
|
if (end > merged.length) end = merged.length;
|
||||||
|
|
||||||
|
var sum = 0.0;
|
||||||
|
for (var j = start; j < end; j++) {
|
||||||
|
final centered = (merged[j] - 128).abs();
|
||||||
|
sum += centered / 127.0;
|
||||||
|
}
|
||||||
|
out[i] = (sum / (end - start)).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Light smoothing to avoid jittery adjacent bars.
|
||||||
|
if (bars > 2) {
|
||||||
|
final smoothed = List<double>.from(out);
|
||||||
|
for (var i = 1; i < bars - 1; i++) {
|
||||||
|
smoothed[i] = ((out[i - 1] + out[i] + out[i + 1]) / 3.0).clamp(
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return smoothed;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ import '../../providers/messages_provider.dart';
|
|||||||
import '../../providers/contacts_provider.dart';
|
import '../../providers/contacts_provider.dart';
|
||||||
import '../../providers/connection_provider.dart';
|
import '../../providers/connection_provider.dart';
|
||||||
import '../../providers/drawing_provider.dart';
|
import '../../providers/drawing_provider.dart';
|
||||||
|
import '../../providers/voice_provider.dart';
|
||||||
import '../contacts/direct_message_sheet.dart';
|
import '../contacts/direct_message_sheet.dart';
|
||||||
import '../drawing_minimap_preview.dart';
|
import '../drawing_minimap_preview.dart';
|
||||||
import '../../services/sar_template_service.dart';
|
import '../../services/sar_template_service.dart';
|
||||||
import '../../utils/toast_logger.dart';
|
import '../../utils/toast_logger.dart';
|
||||||
import '../../utils/sar_message_parser.dart';
|
import '../../utils/sar_message_parser.dart';
|
||||||
import '../../utils/key_comparison.dart';
|
import '../../utils/key_comparison.dart';
|
||||||
|
import '../../utils/voice_message_parser.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../utils/message_extensions.dart';
|
import '../../utils/message_extensions.dart';
|
||||||
import 'voice_message_bubble.dart';
|
import 'voice_message_bubble.dart';
|
||||||
@@ -264,6 +266,15 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
_hideDrawingFromMap(context);
|
_hideDrawingFromMap(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
// Technical details option
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.data_object),
|
||||||
|
title: const Text('Technical details'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_showTechnicalDetails(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
// Delete message option
|
// Delete message option
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.delete, color: Colors.red),
|
leading: const Icon(Icons.delete, color: Colors.red),
|
||||||
@@ -282,6 +293,534 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showTechnicalDetails(BuildContext context) {
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
final voiceProvider = context.read<VoiceProvider>();
|
||||||
|
final selfPublicKey = connectionProvider.deviceInfo.publicKey;
|
||||||
|
final isOwnMessage =
|
||||||
|
widget.message.isSentMessage ||
|
||||||
|
widget.message.isFromSelf(selfPublicKey);
|
||||||
|
|
||||||
|
String? senderName;
|
||||||
|
if (widget.message.senderPublicKeyPrefix != null) {
|
||||||
|
final senderKeyHex = widget.message.senderPublicKeyPrefix!
|
||||||
|
.sublist(
|
||||||
|
0,
|
||||||
|
widget.message.senderPublicKeyPrefix!.length < 6
|
||||||
|
? widget.message.senderPublicKeyPrefix!.length
|
||||||
|
: 6,
|
||||||
|
)
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join('');
|
||||||
|
final senderContact = contactsProvider.contacts
|
||||||
|
.where((c) => c.publicKeyHex.startsWith(senderKeyHex))
|
||||||
|
.firstOrNull;
|
||||||
|
senderName = senderContact?.advName;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? recipientName;
|
||||||
|
if (widget.message.recipientPublicKey != null) {
|
||||||
|
final recipientKeyHex = widget.message.recipientPublicKey!
|
||||||
|
.sublist(
|
||||||
|
0,
|
||||||
|
widget.message.recipientPublicKey!.length < 6
|
||||||
|
? widget.message.recipientPublicKey!.length
|
||||||
|
: 6,
|
||||||
|
)
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join('');
|
||||||
|
final recipientContact = contactsProvider.contacts
|
||||||
|
.where((c) => c.publicKeyHex.startsWith(recipientKeyHex))
|
||||||
|
.firstOrNull;
|
||||||
|
recipientName = recipientContact?.advName;
|
||||||
|
}
|
||||||
|
|
||||||
|
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
|
||||||
|
final legacyVoicePacket = VoicePacket.tryParseText(widget.message.text);
|
||||||
|
final voiceSession = widget.message.voiceId != null
|
||||||
|
? voiceProvider.session(widget.message.voiceId!)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final senderPrefixHex = widget.message.senderPublicKeyPrefix
|
||||||
|
?.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join('');
|
||||||
|
final recipientKey = widget.message.recipientPublicKey;
|
||||||
|
final recipientPrefixHex = recipientKey
|
||||||
|
?.sublist(0, recipientKey.length < 6 ? recipientKey.length : 6)
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join('');
|
||||||
|
final snrDb = widget.message.lastEchoSnrRaw != null
|
||||||
|
? (widget.message.lastEchoSnrRaw!.toSigned(8) / 4.0)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final rawLines = <String>[
|
||||||
|
'Message ID: ${widget.message.id}',
|
||||||
|
'Type: ${widget.message.messageType.name}',
|
||||||
|
'Text type: ${widget.message.textType.name}',
|
||||||
|
'Own message: $isOwnMessage',
|
||||||
|
'Sent message: ${widget.message.isSentMessage}',
|
||||||
|
'Read: ${widget.message.isRead}',
|
||||||
|
'Status: ${widget.message.deliveryStatus.name}',
|
||||||
|
'Path length (nodes/hops): ${widget.message.pathLen}',
|
||||||
|
'Sender timestamp: ${widget.message.senderTimestamp} (${widget.message.sentAt.toIso8601String()})',
|
||||||
|
'Received at: ${widget.message.receivedAt.toIso8601String()}',
|
||||||
|
'Channel index: ${widget.message.channelIdx ?? '-'}',
|
||||||
|
'Echo count: ${widget.message.echoCount}',
|
||||||
|
'Last echo RSSI: ${widget.message.lastEchoRssiDbm ?? '-'}',
|
||||||
|
'Last echo SNR: ${snrDb?.toStringAsFixed(2) ?? '-'}',
|
||||||
|
'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}',
|
||||||
|
'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}',
|
||||||
|
'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}',
|
||||||
|
'Retry attempt: ${widget.message.retryAttempt}',
|
||||||
|
'Used flood fallback: ${widget.message.usedFloodFallback}',
|
||||||
|
'Sender key prefix: ${senderPrefixHex ?? '-'}',
|
||||||
|
'Sender name: ${senderName ?? widget.message.senderName ?? '-'}',
|
||||||
|
'Recipient key prefix: ${recipientPrefixHex ?? '-'}',
|
||||||
|
'Recipient name: ${recipientName ?? '-'}',
|
||||||
|
'Drawing flag: ${widget.message.isDrawing}',
|
||||||
|
'Drawing ID: ${widget.message.drawingId ?? '-'}',
|
||||||
|
'SAR flag: ${widget.message.isSarMarker}',
|
||||||
|
'Voice flag: ${widget.message.isVoice}',
|
||||||
|
'Voice ID: ${widget.message.voiceId ?? '-'}',
|
||||||
|
'Text length: ${widget.message.text.length}',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (widget.message.isVoice) {
|
||||||
|
rawLines.add('--- Voice Technical ---');
|
||||||
|
if (envelope != null) {
|
||||||
|
rawLines.add('Envelope format: VE1 compact');
|
||||||
|
rawLines.add(
|
||||||
|
'Voice mode: ${envelope.mode.label} (id=${envelope.mode.id})',
|
||||||
|
);
|
||||||
|
rawLines.add('Segments total (envelope): ${envelope.total}');
|
||||||
|
rawLines.add(
|
||||||
|
'Estimated duration ms (envelope): ${envelope.durationMs}',
|
||||||
|
);
|
||||||
|
rawLines.add('Envelope senderKey6: ${envelope.senderKey6}');
|
||||||
|
rawLines.add('Envelope ts: ${envelope.timestampSec}');
|
||||||
|
rawLines.add('Envelope ver: ${envelope.version}');
|
||||||
|
} else if (legacyVoicePacket != null) {
|
||||||
|
rawLines.add('Envelope format: legacy V packet');
|
||||||
|
rawLines.add(
|
||||||
|
'Legacy segment index/total: ${legacyVoicePacket.index + 1}/${legacyVoicePacket.total}',
|
||||||
|
);
|
||||||
|
rawLines.add(
|
||||||
|
'Legacy codec mode: ${legacyVoicePacket.mode.label} (id=${legacyVoicePacket.mode.id})',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
rawLines.add('Envelope format: unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (voiceSession != null) {
|
||||||
|
rawLines.add('Session present locally: yes');
|
||||||
|
rawLines.add('Session mode: ${voiceSession.mode.label}');
|
||||||
|
rawLines.add(
|
||||||
|
'Session segments received/total: ${voiceSession.receivedCount}/${voiceSession.total}',
|
||||||
|
);
|
||||||
|
rawLines.add('Session complete: ${voiceSession.isComplete}');
|
||||||
|
rawLines.add(
|
||||||
|
'Session estimated duration s: ${voiceSession.estimatedDurationSeconds.toStringAsFixed(2)}',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
rawLines.add('Session present locally: no');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void copyField(String label, String value) {
|
||||||
|
Clipboard.setData(ClipboardData(text: value));
|
||||||
|
ToastLogger.success(context, '$label copied');
|
||||||
|
}
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Message technical details'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: double.maxFinite,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
_techBadge(
|
||||||
|
context,
|
||||||
|
icon: Icons.message,
|
||||||
|
label: widget.message.messageType.name.toUpperCase(),
|
||||||
|
),
|
||||||
|
_techBadge(
|
||||||
|
context,
|
||||||
|
icon: Icons.route,
|
||||||
|
label:
|
||||||
|
'${widget.message.pathLen} hop${widget.message.pathLen == 1 ? '' : 's'}',
|
||||||
|
),
|
||||||
|
_techBadge(
|
||||||
|
context,
|
||||||
|
icon: Icons.account_tree_outlined,
|
||||||
|
label:
|
||||||
|
'${widget.message.echoCount} node${widget.message.echoCount == 1 ? '' : 's'}',
|
||||||
|
),
|
||||||
|
if (widget.message.channelIdx != null)
|
||||||
|
_techBadge(
|
||||||
|
context,
|
||||||
|
icon: Icons.group_work,
|
||||||
|
label: 'CH ${widget.message.channelIdx}',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (widget.message.lastEchoRssiDbm != null ||
|
||||||
|
snrDb != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_techSection(
|
||||||
|
context,
|
||||||
|
icon: Icons.network_check,
|
||||||
|
title: 'Link quality',
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (widget.message.lastEchoRssiDbm != null)
|
||||||
|
_signalRow(
|
||||||
|
context,
|
||||||
|
label: 'RSSI',
|
||||||
|
valueLabel: '${widget.message.lastEchoRssiDbm} dBm',
|
||||||
|
normalized:
|
||||||
|
((widget.message.lastEchoRssiDbm!.toDouble() +
|
||||||
|
120.0) /
|
||||||
|
70.0)
|
||||||
|
.clamp(0.0, 1.0),
|
||||||
|
color: widget.message.lastEchoRssiDbm! >= -80
|
||||||
|
? Colors.green
|
||||||
|
: widget.message.lastEchoRssiDbm! >= -95
|
||||||
|
? Colors.amber
|
||||||
|
: Colors.redAccent,
|
||||||
|
),
|
||||||
|
if (snrDb != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_signalRow(
|
||||||
|
context,
|
||||||
|
label: 'SNR',
|
||||||
|
valueLabel: '${snrDb.toStringAsFixed(1)} dB',
|
||||||
|
normalized: ((snrDb + 20.0) / 40.0).clamp(0.0, 1.0),
|
||||||
|
color: snrDb >= 10
|
||||||
|
? Colors.green
|
||||||
|
: snrDb >= 0
|
||||||
|
? Colors.amber
|
||||||
|
: Colors.redAccent,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_techSection(
|
||||||
|
context,
|
||||||
|
icon: Icons.tune,
|
||||||
|
title: 'Delivery',
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Status',
|
||||||
|
value: widget.message.deliveryStatus.name,
|
||||||
|
),
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Expected ACK tag',
|
||||||
|
value: widget.message.expectedAckTag?.toString() ?? '-',
|
||||||
|
),
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Round-trip',
|
||||||
|
value: widget.message.roundTripTimeMs != null
|
||||||
|
? '${widget.message.roundTripTimeMs} ms'
|
||||||
|
: '-',
|
||||||
|
),
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Retry attempt',
|
||||||
|
value: widget.message.retryAttempt.toString(),
|
||||||
|
),
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Flood fallback',
|
||||||
|
value: widget.message.usedFloodFallback ? 'Yes' : 'No',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_techSection(
|
||||||
|
context,
|
||||||
|
icon: Icons.badge,
|
||||||
|
title: 'Identity',
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Message ID',
|
||||||
|
value: widget.message.id,
|
||||||
|
onCopy: () =>
|
||||||
|
copyField('Message ID', widget.message.id),
|
||||||
|
),
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Sender',
|
||||||
|
value: senderName ?? widget.message.senderName ?? '-',
|
||||||
|
),
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Sender key',
|
||||||
|
value: senderPrefixHex ?? '-',
|
||||||
|
onCopy: senderPrefixHex != null
|
||||||
|
? () => copyField('Sender key', senderPrefixHex)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Recipient',
|
||||||
|
value: recipientName ?? '-',
|
||||||
|
),
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Recipient key',
|
||||||
|
value: recipientPrefixHex ?? '-',
|
||||||
|
onCopy: recipientPrefixHex != null
|
||||||
|
? () =>
|
||||||
|
copyField('Recipient key', recipientPrefixHex)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.message.isVoice) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_techSection(
|
||||||
|
context,
|
||||||
|
icon: Icons.graphic_eq,
|
||||||
|
title: 'Voice',
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Voice ID',
|
||||||
|
value: widget.message.voiceId ?? '-',
|
||||||
|
),
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Envelope',
|
||||||
|
value: envelope != null
|
||||||
|
? 'VE1 compact'
|
||||||
|
: legacyVoicePacket != null
|
||||||
|
? 'Legacy V packet'
|
||||||
|
: 'Unknown',
|
||||||
|
),
|
||||||
|
if (voiceSession != null)
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Session progress',
|
||||||
|
value:
|
||||||
|
'${voiceSession.receivedCount}/${voiceSession.total} segments',
|
||||||
|
),
|
||||||
|
if (voiceSession != null)
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Complete',
|
||||||
|
value: voiceSession.isComplete ? 'Yes' : 'No',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ExpansionTile(
|
||||||
|
tilePadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
title: const Text(
|
||||||
|
'Raw dump',
|
||||||
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.35),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: SelectableText(
|
||||||
|
rawLines.join('\n'),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(AppLocalizations.of(context)!.close),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _techSection(
|
||||||
|
BuildContext context, {
|
||||||
|
required IconData icon,
|
||||||
|
required String title,
|
||||||
|
required Widget child,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 14),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
child,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _techBadge(
|
||||||
|
BuildContext context, {
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 12, color: Theme.of(context).colorScheme.primary),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _detailRow(
|
||||||
|
BuildContext context, {
|
||||||
|
required String label,
|
||||||
|
required String value,
|
||||||
|
VoidCallback? onCopy,
|
||||||
|
}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 110,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall?.color?.withValues(alpha: 0.75),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (onCopy != null)
|
||||||
|
IconButton(
|
||||||
|
onPressed: onCopy,
|
||||||
|
icon: const Icon(Icons.copy, size: 14),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
tooltip: 'Copy $label',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _signalRow(
|
||||||
|
BuildContext context, {
|
||||||
|
required String label,
|
||||||
|
required String valueLabel,
|
||||||
|
required double normalized,
|
||||||
|
required Color color,
|
||||||
|
}) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 42,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
minHeight: 8,
|
||||||
|
value: normalized,
|
||||||
|
backgroundColor: color.withValues(alpha: 0.15),
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
SizedBox(
|
||||||
|
width: 74,
|
||||||
|
child: Text(
|
||||||
|
valueLabel,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _showReplySheet(BuildContext context) {
|
void _showReplySheet(BuildContext context) {
|
||||||
// Find the sender contact by public key prefix
|
// Find the sender contact by public key prefix
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
@@ -595,6 +1134,177 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildChannelEchoStatus(BuildContext context, Message message) {
|
||||||
|
final statusColor = _getDeliveryStatusColor(message.deliveryStatus);
|
||||||
|
final hasEcho = message.echoCount > 0;
|
||||||
|
|
||||||
|
if (!hasEcho) {
|
||||||
|
return Text(
|
||||||
|
message.getLocalizedDeliveryStatus(context),
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: statusColor,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final rssi = message.lastEchoRssiDbm;
|
||||||
|
final snr = message.lastEchoSnrRaw != null
|
||||||
|
? message.lastEchoSnrRaw!.toSigned(8) / 4.0
|
||||||
|
: null;
|
||||||
|
final quality = _linkQualityLabel(rssi, snr);
|
||||||
|
final qualityColor = _linkQualityColor(quality);
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
_techChip(
|
||||||
|
context,
|
||||||
|
icon: Icons.hub_outlined,
|
||||||
|
label: 'x${message.echoCount}',
|
||||||
|
color: statusColor,
|
||||||
|
),
|
||||||
|
if (message.expectedAckTag != null)
|
||||||
|
_techChip(
|
||||||
|
context,
|
||||||
|
icon: Icons.tag,
|
||||||
|
label:
|
||||||
|
'ACK ${message.expectedAckTag!.toRadixString(16).toUpperCase()}',
|
||||||
|
color: Colors.indigo,
|
||||||
|
),
|
||||||
|
_techChip(
|
||||||
|
context,
|
||||||
|
icon: Icons.bolt,
|
||||||
|
label: quality,
|
||||||
|
color: qualityColor,
|
||||||
|
),
|
||||||
|
if (message.lastEchoRssiDbm != null)
|
||||||
|
_signalCapsule(
|
||||||
|
context,
|
||||||
|
icon: Icons.network_cell,
|
||||||
|
label: message.lastEchoRssiDbm!.toString(),
|
||||||
|
filled: _rssiScore(message.lastEchoRssiDbm!),
|
||||||
|
color: Colors.blueGrey,
|
||||||
|
),
|
||||||
|
if (message.lastEchoSnrRaw != null)
|
||||||
|
_signalCapsule(
|
||||||
|
context,
|
||||||
|
icon: Icons.graphic_eq,
|
||||||
|
label: (message.lastEchoSnrRaw!.toSigned(8) / 4.0).toStringAsFixed(
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
filled: _snrScore(message.lastEchoSnrRaw!.toSigned(8) / 4.0),
|
||||||
|
color: Colors.teal,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _techChip(
|
||||||
|
BuildContext context, {
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required Color color,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 10, color: color),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: color,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _signalCapsule(
|
||||||
|
BuildContext context, {
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required int filled,
|
||||||
|
required Color color,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 10, color: color),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: List.generate(5, (i) {
|
||||||
|
final active = i < filled;
|
||||||
|
return Container(
|
||||||
|
width: 3,
|
||||||
|
height: (4 + i).toDouble(),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 0.5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: active ? color : color.withValues(alpha: 0.18),
|
||||||
|
borderRadius: BorderRadius.circular(1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: color,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _rssiScore(int rssiDbm) => ((rssiDbm + 120) / 10).round().clamp(0, 5);
|
||||||
|
|
||||||
|
int _snrScore(double snrDb) => ((snrDb + 5.0) / 5.0).round().clamp(0, 5);
|
||||||
|
|
||||||
|
String _linkQualityLabel(int? rssiDbm, double? snrDb) {
|
||||||
|
var score = 0;
|
||||||
|
if (rssiDbm != null) score += _rssiScore(rssiDbm);
|
||||||
|
if (snrDb != null) score += _snrScore(snrDb);
|
||||||
|
if (score >= 8) return 'Excellent';
|
||||||
|
if (score >= 6) return 'Good';
|
||||||
|
if (score >= 4) return 'Fair';
|
||||||
|
return 'Weak';
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _linkQualityColor(String quality) {
|
||||||
|
switch (quality) {
|
||||||
|
case 'Excellent':
|
||||||
|
return Colors.green;
|
||||||
|
case 'Good':
|
||||||
|
return Colors.lightGreen;
|
||||||
|
case 'Fair':
|
||||||
|
return Colors.orange;
|
||||||
|
default:
|
||||||
|
return Colors.redAccent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Display system messages with minimal styling
|
// Display system messages with minimal styling
|
||||||
@@ -1265,7 +1975,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
// Show single message delivery status
|
// Show single message delivery status
|
||||||
else
|
else
|
||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.max,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
_getDeliveryStatusIcon(message.deliveryStatus),
|
_getDeliveryStatusIcon(message.deliveryStatus),
|
||||||
@@ -1273,11 +1983,26 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
color: _getDeliveryStatusColor(message.deliveryStatus),
|
color: _getDeliveryStatusColor(message.deliveryStatus),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 3),
|
const SizedBox(width: 3),
|
||||||
Text(
|
Expanded(
|
||||||
message.getLocalizedDeliveryStatus(context),
|
child: Align(
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
alignment: Alignment.centerLeft,
|
||||||
color: _getDeliveryStatusColor(message.deliveryStatus),
|
child:
|
||||||
fontStyle: FontStyle.italic,
|
message.isChannelMessage &&
|
||||||
|
message.deliveryStatus ==
|
||||||
|
MessageDeliveryStatus.sent
|
||||||
|
? _buildChannelEchoStatus(context, message)
|
||||||
|
: Text(
|
||||||
|
message.getLocalizedDeliveryStatus(context),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall
|
||||||
|
?.copyWith(
|
||||||
|
color: _getDeliveryStatusColor(
|
||||||
|
message.deliveryStatus,
|
||||||
|
),
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Show retry button for failed messages
|
// Show retry button for failed messages
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../../providers/voice_provider.dart';
|
import '../../models/contact.dart';
|
||||||
import '../../models/message.dart';
|
import '../../models/message.dart';
|
||||||
|
import '../../providers/connection_provider.dart';
|
||||||
|
import '../../providers/contacts_provider.dart';
|
||||||
|
import '../../providers/voice_provider.dart';
|
||||||
|
import '../../utils/voice_message_parser.dart';
|
||||||
|
|
||||||
/// A message bubble that shows a voice recording with play/stop controls.
|
/// A message bubble that shows a voice recording with play/stop controls.
|
||||||
class VoiceMessageBubble extends StatelessWidget {
|
class VoiceMessageBubble extends StatefulWidget {
|
||||||
final Message message;
|
final Message message;
|
||||||
final bool isSentByMe;
|
final bool isSentByMe;
|
||||||
|
|
||||||
@@ -14,49 +19,91 @@ class VoiceMessageBubble extends StatelessWidget {
|
|||||||
required this.isSentByMe,
|
required this.isSentByMe,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<VoiceMessageBubble> createState() => _VoiceMessageBubbleState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||||
|
bool _isRequesting = false;
|
||||||
|
bool _autoPlayWhenReady = false;
|
||||||
|
String? _errorText;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final voiceId = message.voiceId;
|
final voiceId = widget.message.voiceId;
|
||||||
if (voiceId == null) return const SizedBox.shrink();
|
if (voiceId == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
return Consumer<VoiceProvider>(
|
return Consumer<VoiceProvider>(
|
||||||
builder: (context, voiceProvider, _) {
|
builder: (context, voiceProvider, _) {
|
||||||
final session = voiceProvider.session(voiceId);
|
final session = voiceProvider.session(voiceId);
|
||||||
|
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
|
||||||
final isPlaying = voiceProvider.isPlaying(voiceId);
|
final isPlaying = voiceProvider.isPlaying(voiceId);
|
||||||
final isComplete = voiceProvider.isComplete(voiceId);
|
final isComplete = voiceProvider.isComplete(voiceId);
|
||||||
|
|
||||||
|
if (_isRequesting && isComplete) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isRequesting = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_autoPlayWhenReady && isComplete && !isPlaying) {
|
||||||
|
_autoPlayWhenReady = false;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
if (!mounted) return;
|
||||||
|
await voiceProvider.play(voiceId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
final received = session?.receivedCount ?? 0;
|
final received = session?.receivedCount ?? 0;
|
||||||
final total = session?.total ?? 0;
|
final total = session?.total ?? envelope?.total ?? 0;
|
||||||
final durationSec = session?.estimatedDurationSeconds ?? 0.0;
|
final playbackProgress = voiceProvider.playbackProgress(voiceId);
|
||||||
|
final requestProgress = total > 0
|
||||||
|
? (received / total).clamp(0.0, 1.0)
|
||||||
|
: null;
|
||||||
|
final durationSec =
|
||||||
|
session?.estimatedDurationSeconds ??
|
||||||
|
((envelope?.durationMs ?? 0) / 1000.0);
|
||||||
final durationLabel = _formatDuration(durationSec);
|
final durationLabel = _formatDuration(durationSec);
|
||||||
final modeLabel = session?.mode.label ?? '?';
|
final modeLabel = session?.mode.label ?? envelope?.mode.label ?? '?';
|
||||||
|
final waveformBars = _resolveWaveformBars(
|
||||||
|
session: session,
|
||||||
|
messageText: widget.message.text,
|
||||||
|
);
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Play / Stop button
|
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await voiceProvider.stop();
|
await voiceProvider.stop();
|
||||||
} else {
|
return;
|
||||||
await voiceProvider.play(voiceId);
|
|
||||||
}
|
}
|
||||||
|
if (isComplete) {
|
||||||
|
await voiceProvider.play(voiceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _requestAndPlayVoice(voiceId);
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSentByMe
|
color: widget.isSentByMe
|
||||||
? Theme.of(context).colorScheme.primaryContainer
|
? Theme.of(context).colorScheme.primaryContainer
|
||||||
: Theme.of(context).colorScheme.secondaryContainer,
|
: Theme.of(context).colorScheme.secondaryContainer,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
isPlaying ? Icons.stop : Icons.play_arrow,
|
isPlaying
|
||||||
|
? Icons.stop
|
||||||
|
: (_isRequesting ? Icons.downloading : Icons.play_arrow),
|
||||||
size: 28,
|
size: 28,
|
||||||
color: isSentByMe
|
color: widget.isSentByMe
|
||||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||||
: Theme.of(context).colorScheme.onSecondaryContainer,
|
: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||||
),
|
),
|
||||||
@@ -67,18 +114,20 @@ class VoiceMessageBubble extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Waveform placeholder / progress indicator
|
if (isPlaying || _isRequesting)
|
||||||
if (isPlaying)
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 100,
|
width: 100,
|
||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(
|
||||||
|
value: isPlaying ? playbackProgress : requestProgress,
|
||||||
backgroundColor: Colors.grey.withValues(alpha: 0.3),
|
backgroundColor: Colors.grey.withValues(alpha: 0.3),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
_WaveformBar(isComplete: isComplete),
|
_WaveformBar(
|
||||||
|
isComplete: isComplete,
|
||||||
|
bars: waveformBars,
|
||||||
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
// Duration + mode + packet progress
|
|
||||||
Text(
|
Text(
|
||||||
_buildStatusText(
|
_buildStatusText(
|
||||||
durationLabel: durationLabel,
|
durationLabel: durationLabel,
|
||||||
@@ -86,10 +135,14 @@ class VoiceMessageBubble extends StatelessWidget {
|
|||||||
received: received,
|
received: received,
|
||||||
total: total,
|
total: total,
|
||||||
isComplete: isComplete,
|
isComplete: isComplete,
|
||||||
|
isRequesting: _isRequesting,
|
||||||
|
errorText: _errorText,
|
||||||
),
|
),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -100,6 +153,78 @@ class VoiceMessageBubble extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _requestAndPlayVoice(String sessionId) async {
|
||||||
|
if (_isRequesting) return;
|
||||||
|
final sender = _resolveSenderContact();
|
||||||
|
if (sender == null) {
|
||||||
|
_setUnavailable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final deviceKey = connectionProvider.deviceInfo.publicKey;
|
||||||
|
if (deviceKey == null || deviceKey.length < 6) {
|
||||||
|
_setUnavailable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final requesterKey6 = deviceKey
|
||||||
|
.sublist(0, 6)
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join('');
|
||||||
|
final request = VoiceFetchRequest(
|
||||||
|
sessionId: sessionId,
|
||||||
|
requesterKey6: requesterKey6,
|
||||||
|
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
version: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isRequesting = true;
|
||||||
|
_autoPlayWhenReady = true;
|
||||||
|
_errorText = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final sent = await connectionProvider.sendTextMessage(
|
||||||
|
contactPublicKey: sender.publicKey,
|
||||||
|
text: request.encodeText(),
|
||||||
|
contact: sender,
|
||||||
|
);
|
||||||
|
if (!sent) {
|
||||||
|
_setUnavailable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setUnavailable() {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isRequesting = false;
|
||||||
|
_autoPlayWhenReady = false;
|
||||||
|
_errorText = 'Voice unavailable right now';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Contact? _resolveSenderContact() {
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
final senderPrefix = widget.message.senderPublicKeyPrefix;
|
||||||
|
if (senderPrefix != null && senderPrefix.length >= 6) {
|
||||||
|
final contact = contactsProvider.findContactByPrefix(
|
||||||
|
Uint8List.fromList(senderPrefix.sublist(0, 6)),
|
||||||
|
);
|
||||||
|
if (contact != null) return contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
|
||||||
|
if (envelope != null) {
|
||||||
|
final contact = contactsProvider.findContactByPrefixHex(
|
||||||
|
envelope.senderKey6,
|
||||||
|
);
|
||||||
|
if (contact != null) return contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
static String _formatDuration(double seconds) {
|
static String _formatDuration(double seconds) {
|
||||||
final s = seconds.round();
|
final s = seconds.round();
|
||||||
if (s < 60) return '${s}s';
|
if (s < 60) return '${s}s';
|
||||||
@@ -112,37 +237,69 @@ class VoiceMessageBubble extends StatelessWidget {
|
|||||||
required int received,
|
required int received,
|
||||||
required int total,
|
required int total,
|
||||||
required bool isComplete,
|
required bool isComplete,
|
||||||
|
required bool isRequesting,
|
||||||
|
required String? errorText,
|
||||||
}) {
|
}) {
|
||||||
|
if (errorText != null) return errorText;
|
||||||
final progress = total > 0 ? ' ($received/$total)' : '';
|
final progress = total > 0 ? ' ($received/$total)' : '';
|
||||||
|
if (isRequesting) {
|
||||||
|
return 'Requesting voice$progress';
|
||||||
|
}
|
||||||
if (!isComplete && total > 0) {
|
if (!isComplete && total > 0) {
|
||||||
return '🎙️ $durationLabel · $modeLabel$progress';
|
return '🎙️ $durationLabel · $modeLabel$progress';
|
||||||
}
|
}
|
||||||
return '🎙️ $durationLabel · $modeLabel';
|
return '🎙️ $durationLabel · $modeLabel';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<double> _resolveWaveformBars({
|
||||||
|
required VoiceSession? session,
|
||||||
|
required String messageText,
|
||||||
|
}) {
|
||||||
|
if (session != null) {
|
||||||
|
final fromSession = VoiceWaveform.buildBarsFromPackets(session.packets);
|
||||||
|
if (fromSession.any((v) => v > 0.0)) return fromSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
final legacyPacket = VoicePacket.tryParseText(messageText);
|
||||||
|
if (legacyPacket != null) {
|
||||||
|
final fromLegacy = VoiceWaveform.buildBarsFromPackets([legacyPacket]);
|
||||||
|
if (fromLegacy.any((v) => v > 0.0)) return fromLegacy;
|
||||||
|
}
|
||||||
|
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simple static waveform bar using a row of rectangles.
|
/// Voice waveform rendered as a row of bars.
|
||||||
class _WaveformBar extends StatelessWidget {
|
class _WaveformBar extends StatelessWidget {
|
||||||
final bool isComplete;
|
final bool isComplete;
|
||||||
const _WaveformBar({required this.isComplete});
|
final List<double> bars;
|
||||||
|
const _WaveformBar({
|
||||||
|
required this.isComplete,
|
||||||
|
required this.bars,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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 heights = bars.isEmpty
|
||||||
|
? const [8.0, 12.0, 10.0, 14.0, 9.0, 12.0, 8.0, 11.0, 10.0, 13.0]
|
||||||
|
: bars.map((v) => 6.0 + (v.clamp(0.0, 1.0) * 14.0)).toList();
|
||||||
final color = isComplete
|
final color = isComplete
|
||||||
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.7)
|
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.7)
|
||||||
: Colors.grey.withValues(alpha: 0.5);
|
: Colors.grey.withValues(alpha: 0.5);
|
||||||
return Row(
|
return Row(
|
||||||
children: heights
|
children: heights
|
||||||
.map((h) => Container(
|
.map(
|
||||||
width: 3,
|
(h) => Container(
|
||||||
height: h,
|
width: 3,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 1),
|
height: h,
|
||||||
decoration: BoxDecoration(
|
margin: const EdgeInsets.symmetric(horizontal: 1),
|
||||||
color: color,
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(2),
|
color: color,
|
||||||
),
|
borderRadius: BorderRadius.circular(2),
|
||||||
))
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import geolocator_apple
|
|||||||
import nsd_macos
|
import nsd_macos
|
||||||
import objectbox_flutter_libs
|
import objectbox_flutter_libs
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
import path_provider_foundation
|
|
||||||
import record_macos
|
import record_macos
|
||||||
import share_plus
|
import share_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
@@ -30,7 +29,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
|
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
|
||||||
ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin"))
|
ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
|
||||||
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
||||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
|||||||
264
pubspec.lock
264
pubspec.lock
@@ -5,10 +5,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: archive
|
name: archive
|
||||||
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
|
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.7"
|
version: "4.0.9"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -101,10 +101,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.1"
|
||||||
checked_yaml:
|
checked_yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -129,6 +129,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "1.1.2"
|
||||||
|
code_assets:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: code_assets
|
||||||
|
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
codec2_flutter:
|
codec2_flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -148,10 +156,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: cross_file
|
name: cross_file
|
||||||
sha256: "942a4791cd385a68ccb3b32c71c427aba508a1bb949b86dff2adbe4049f16239"
|
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.5"
|
version: "0.3.5+2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -188,18 +196,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dbus
|
name: dbus
|
||||||
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
|
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.12"
|
||||||
device_info_plus:
|
device_info_plus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: device_info_plus
|
name: device_info_plus
|
||||||
sha256: dd0e8e02186b2196c7848c9d394a5fd6e5b57a43a546082c5820b1ec72317e33
|
sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "12.2.0"
|
version: "12.3.0"
|
||||||
device_info_plus_platform_interface:
|
device_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -228,10 +236,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: ffi
|
name: ffi
|
||||||
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
|
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.2.0"
|
||||||
file:
|
file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -244,10 +252,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: file_picker
|
name: file_picker
|
||||||
sha256: f8f4ea435f791ab1f817b4e338ed958cb3d04ba43d6736ffc39958d950754967
|
sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.3.6"
|
version: "10.3.10"
|
||||||
fixnum:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -305,50 +313,58 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_blue_plus
|
name: flutter_blue_plus
|
||||||
sha256: bfcfcd60cd39846d32944f1c1a84f270437ce2d8e7a3e8a1cf8bf9ac9c9a423c
|
sha256: "4fba86c513feab2c5cdb9497da0910ed5b50c0fa8d6cec4a26ffb1a558a24eb8"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "2.2.1"
|
||||||
flutter_blue_plus_android:
|
flutter_blue_plus_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_blue_plus_android
|
name: flutter_blue_plus_android
|
||||||
sha256: e62c1cfa4da3594cc8360333bc3f9208a84963bfcbae192fb95a61635caf75fe
|
sha256: "2a73e264685574d1d29dcdd565bad9ecfdf237630237c508ae8b47f5cc791f1d"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.0.1"
|
version: "8.2.1"
|
||||||
flutter_blue_plus_darwin:
|
flutter_blue_plus_darwin:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_blue_plus_darwin
|
name: flutter_blue_plus_darwin
|
||||||
sha256: d789861c37aee73101515df99f1d6d162b0ea69a13b961e1b3339f27ea06dcb6
|
sha256: cfef171db550670cf8110f6eb25baf15d9bc8bad2af29550f9bbc0d8fceaf285
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.0.1"
|
version: "8.2.1"
|
||||||
flutter_blue_plus_linux:
|
flutter_blue_plus_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_blue_plus_linux
|
name: flutter_blue_plus_linux
|
||||||
sha256: "1fd456e7f17f6c9e50a2bdfca8bcfd5dfee83e3c0a9fd7d493dd73d4e60b9755"
|
sha256: "5add6c14d2f90672c5e3ded1455b9ca8e6fe44adf9b53cdc60eb3417d38f34fe"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.0.1"
|
version: "8.2.1"
|
||||||
flutter_blue_plus_platform_interface:
|
flutter_blue_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_blue_plus_platform_interface
|
name: flutter_blue_plus_platform_interface
|
||||||
sha256: "8d8440360bed1dce921f3140510c9294ec81d21945c30607afd29bda9a8a87d6"
|
sha256: "226fb6753a74a407e3b9975c0fc00de02c490ae655b31c6508cb5790ad30965d"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.0.1"
|
version: "8.2.1"
|
||||||
flutter_blue_plus_web:
|
flutter_blue_plus_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_blue_plus_web
|
name: flutter_blue_plus_web
|
||||||
sha256: "87d4d63cd06e1e3e9c4b4f5774cea6d2a8516b5693dce0e4dbf7c9d4e63dbcfd"
|
sha256: "10a7465ccfc50138280abf32c8ab314f5029aa19039628ad9b4d0ed786e0021f"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.0.1"
|
version: "8.2.1"
|
||||||
|
flutter_blue_plus_winrt:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_blue_plus_winrt
|
||||||
|
sha256: ed894f0ab341f4cece8fa33edc381d46424a7c5bfd0e841d933d0f8c34c86521
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.0.18"
|
||||||
flutter_compass:
|
flutter_compass:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -377,34 +393,34 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications
|
name: flutter_local_notifications
|
||||||
sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875"
|
sha256: cf206c68707773b670340dfeca7fc95f95509f4ca91a544bdd77d03a8106b91e
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "19.5.0"
|
version: "21.0.0-dev.2"
|
||||||
flutter_local_notifications_linux:
|
flutter_local_notifications_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications_linux
|
name: flutter_local_notifications_linux
|
||||||
sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5
|
sha256: "223bdd6e389a5ec02597fb494c8b3d6b0a9119534f9f45b7feef69cc0ee838e5"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "8.0.0-dev.1"
|
||||||
flutter_local_notifications_platform_interface:
|
flutter_local_notifications_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications_platform_interface
|
name: flutter_local_notifications_platform_interface
|
||||||
sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe"
|
sha256: "2adc3c14a650af4e6235817fbf4335ba14bc3d36d5d497d7104d94e1475d5ddc"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.1.0"
|
version: "11.0.0-dev.1"
|
||||||
flutter_local_notifications_windows:
|
flutter_local_notifications_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications_windows
|
name: flutter_local_notifications_windows
|
||||||
sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf"
|
sha256: e7136082af98f3459552d2cc423f4b27e3010bbddcd1e575d9a270d47d2384d4
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.3"
|
version: "3.0.0-dev.1"
|
||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -430,10 +446,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_plugin_android_lifecycle
|
name: flutter_plugin_android_lifecycle
|
||||||
sha256: "306f0596590e077338312f38837f595c04f28d6cdeeac392d3d74df2f0003687"
|
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.32"
|
version: "2.0.33"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -480,10 +496,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: geolocator_linux
|
name: geolocator_linux
|
||||||
sha256: c4e966f0a7a87e70049eac7a2617f9e16fd4c585a26e4330bdfc3a71e6a721f3
|
sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.3"
|
version: "0.2.4"
|
||||||
geolocator_platform_interface:
|
geolocator_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -508,6 +524,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.5"
|
version: "0.2.5"
|
||||||
|
glob:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: glob
|
||||||
|
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
gsettings:
|
gsettings:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -516,6 +540,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.8"
|
version: "0.2.8"
|
||||||
|
hooks:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: hooks
|
||||||
|
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -544,18 +576,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: idb_shim
|
name: idb_shim
|
||||||
sha256: "071f3b05032fa62e60ca15db9939f8afbaf403b37e67747ac88f858c3e999228"
|
sha256: "921301da0a735f336a28fc35c3abdbd4498895cc205fa1ea9f7e785e7d854ceb"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.7+1"
|
version: "2.8.2+4"
|
||||||
image:
|
image:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: image
|
name: image
|
||||||
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
|
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.4"
|
version: "4.8.0"
|
||||||
intl:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -568,10 +600,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: json_annotation
|
name: json_annotation
|
||||||
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.9.0"
|
version: "4.11.0"
|
||||||
latlong2:
|
latlong2:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -608,10 +640,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: lints
|
name: lints
|
||||||
sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0
|
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.1.0"
|
||||||
lists:
|
lists:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -628,22 +660,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.2"
|
version: "2.6.2"
|
||||||
|
logging:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: logging
|
||||||
|
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.17"
|
version: "0.12.18"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.1"
|
version: "0.13.0"
|
||||||
mbtiles:
|
mbtiles:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -656,7 +696,7 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: HEAD
|
ref: main
|
||||||
resolved-ref: "6457853c57b20727f83f5932ee6684e16f20ff8f"
|
resolved-ref: "6457853c57b20727f83f5932ee6684e16f20ff8f"
|
||||||
url: "https://github.com/dz0ny/meshcore_client.git"
|
url: "https://github.com/dz0ny/meshcore_client.git"
|
||||||
source: git
|
source: git
|
||||||
@@ -685,6 +725,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
|
native_toolchain_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: native_toolchain_c
|
||||||
|
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.17.4"
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -697,18 +745,18 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: nsd
|
name: nsd
|
||||||
sha256: cae71ee9c23ea7f75d4610efe7ff335b1f575fb93ef7b7f9a5c6183a091cbb74
|
sha256: "1611a5c9f61d56ff2973e1488ae04112103e5203b4a7a1fb594b48cfb366fc14"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.3"
|
version: "4.1.0"
|
||||||
nsd_android:
|
nsd_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: nsd_android
|
name: nsd_android
|
||||||
sha256: "1309cd47d02c99bd305219f0a226644f9f4a964d341c3d6730cebb906bb1ec78"
|
sha256: "96d2d451c5db0319c37b1b2a38f2d55eb56ae54c0b0d3144c03c19c97436de4a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.2.0"
|
||||||
nsd_ios:
|
nsd_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -729,10 +777,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: nsd_platform_interface
|
name: nsd_platform_interface
|
||||||
sha256: "7220c8e0beeacd06c180fefcd6bb708415ed889c76f656e75ac633d09ceaa761"
|
sha256: b1a5ace6f01ea2ce37f373e52c3b7af4fd7c11de2582ddcc89f4fc00615d9dff
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.1"
|
version: "2.2.0"
|
||||||
nsd_windows:
|
nsd_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -757,14 +805,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.3.1"
|
version: "4.3.1"
|
||||||
|
objective_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: objective_c
|
||||||
|
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.3.0"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: package_info_plus
|
name: package_info_plus
|
||||||
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.3.1"
|
version: "9.0.0"
|
||||||
package_info_plus_platform_interface:
|
package_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -793,18 +849,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_android
|
name: path_provider_android
|
||||||
sha256: e122c5ea805bb6773bb12ce667611265980940145be920cd09a4b0ec0285cb16
|
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.20"
|
version: "2.2.22"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_foundation
|
name: path_provider_foundation
|
||||||
sha256: efaec349ddfc181528345c56f8eda9d6cccd71c177511b132c6a0ddaefaa2738
|
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.3"
|
version: "2.6.0"
|
||||||
path_provider_linux:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -881,10 +937,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: petitparser
|
name: petitparser
|
||||||
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
|
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.2"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -905,10 +961,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: posix
|
name: posix
|
||||||
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
|
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.3"
|
version: "6.5.0"
|
||||||
proj4dart:
|
proj4dart:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -933,6 +989,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.5+1"
|
version: "6.1.5+1"
|
||||||
|
pub_semver:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pub_semver
|
||||||
|
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
record:
|
record:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1009,10 +1073,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sembast
|
name: sembast
|
||||||
sha256: c8063c3146c3c8d5f5b04230de7682c768440a575fbda2634f14d22f263197c3
|
sha256: "139cf71496105de32e7a08a4e3a1ead0f81c4a616ec9703ed07e8f0d10cdd505"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.8.5+2"
|
version: "3.8.6"
|
||||||
share_plus:
|
share_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1033,26 +1097,26 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: shared_preferences
|
name: shared_preferences
|
||||||
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
|
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.3"
|
version: "2.5.4"
|
||||||
shared_preferences_android:
|
shared_preferences_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_android
|
name: shared_preferences_android
|
||||||
sha256: "34266009473bf71d748912da4bf62d439185226c03e01e2d9687bc65bbfcb713"
|
sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.15"
|
version: "2.4.21"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_foundation
|
name: shared_preferences_foundation
|
||||||
sha256: "1c33a907142607c40a7542768ec9badfd16293bac51da3a4482623d15845f88b"
|
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.5"
|
version: "2.5.6"
|
||||||
shared_preferences_linux:
|
shared_preferences_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1110,10 +1174,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: source_span
|
name: source_span
|
||||||
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.1"
|
version: "1.10.2"
|
||||||
sqlite3:
|
sqlite3:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1166,18 +1230,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.7"
|
version: "0.7.9"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: timezone
|
name: timezone
|
||||||
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.10.1"
|
version: "0.11.0"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1206,34 +1270,34 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_android
|
name: url_launcher_android
|
||||||
sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9"
|
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.24"
|
version: "6.3.28"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_ios
|
name: url_launcher_ios
|
||||||
sha256: "6b63f1441e4f653ae799166a72b50b1767321ecc263a57aadf825a7a2a5477d9"
|
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.5"
|
version: "6.4.1"
|
||||||
url_launcher_linux:
|
url_launcher_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_linux
|
name: url_launcher_linux
|
||||||
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
|
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.1"
|
version: "3.2.2"
|
||||||
url_launcher_macos:
|
url_launcher_macos:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_macos
|
name: url_launcher_macos
|
||||||
sha256: "8262208506252a3ed4ff5c0dc1e973d2c0e0ef337d0a074d35634da5d44397c9"
|
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.4"
|
version: "3.2.5"
|
||||||
url_launcher_platform_interface:
|
url_launcher_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1246,26 +1310,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_web
|
name: url_launcher_web
|
||||||
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
|
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.4.2"
|
||||||
url_launcher_windows:
|
url_launcher_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_windows
|
name: url_launcher_windows
|
||||||
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
|
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.4"
|
version: "3.1.5"
|
||||||
uuid:
|
uuid:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: uuid
|
name: uuid
|
||||||
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
|
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.2"
|
version: "4.5.3"
|
||||||
vector_map_tiles:
|
vector_map_tiles:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1279,7 +1343,7 @@ packages:
|
|||||||
description:
|
description:
|
||||||
path: vector_map_tiles_mbtiles
|
path: vector_map_tiles_mbtiles
|
||||||
ref: HEAD
|
ref: HEAD
|
||||||
resolved-ref: "6d0b7bd077c70c2013704074bd9cd7229a1bf072"
|
resolved-ref: a09543b7590b373f3ac53f4776e343fee41c7dc6
|
||||||
url: "https://github.com/josxha/flutter_map_plugins.git"
|
url: "https://github.com/josxha/flutter_map_plugins.git"
|
||||||
source: git
|
source: git
|
||||||
version: "1.2.1"
|
version: "1.2.1"
|
||||||
@@ -1319,10 +1383,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: vibration
|
name: vibration
|
||||||
sha256: "1fd51cb0f91c6d512734ca0e282dd87fbc7f389b6da5f03c77709ba2cf8fa901"
|
sha256: "9bb06614c69260f8bd11c80fe01ed7988905cf00e3417d656c2647e41f261d87"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.4"
|
version: "3.1.8"
|
||||||
vibration_platform_interface:
|
vibration_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1396,5 +1460,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.9.2 <4.0.0"
|
dart: ">=3.10.3 <4.0.0"
|
||||||
flutter: ">=3.35.0"
|
flutter: ">=3.38.4"
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ dependencies:
|
|||||||
meshcore_client:
|
meshcore_client:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/dz0ny/meshcore_client.git
|
url: https://github.com/dz0ny/meshcore_client.git
|
||||||
|
ref: main
|
||||||
|
|
||||||
# Codec2 ultra-low-bitrate speech codec (FFI plugin)
|
# Codec2 ultra-low-bitrate speech codec (FFI plugin)
|
||||||
codec2_flutter:
|
codec2_flutter:
|
||||||
@@ -98,14 +99,14 @@ dependencies:
|
|||||||
shared_preferences: ^2.3.3
|
shared_preferences: ^2.3.3
|
||||||
|
|
||||||
# Package info
|
# Package info
|
||||||
package_info_plus: ^8.1.2
|
package_info_plus: ^9.0.0
|
||||||
|
|
||||||
# Background services
|
# Background services
|
||||||
flutter_background_service: ^5.0.13
|
flutter_background_service: ^5.0.13
|
||||||
|
|
||||||
# Notifications
|
# Notifications
|
||||||
flutter_local_notifications: ^19.5.0
|
flutter_local_notifications: ^21.0.0-dev.2
|
||||||
timezone: ^0.10.0
|
timezone: ^0.11.0
|
||||||
|
|
||||||
# Vibration
|
# Vibration
|
||||||
vibration: ^3.1.4
|
vibration: ^3.1.4
|
||||||
|
|||||||
69
test/providers/messages_provider_voice_test.dart
Normal file
69
test/providers/messages_provider_voice_test.dart
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/message.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('MessagesProvider voice detection', () {
|
||||||
|
test('marks VE1 envelope messages as voice', () {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
final envelope = VoiceEnvelope(
|
||||||
|
sessionId: 'deafbead',
|
||||||
|
mode: VoicePacketMode.mode1200,
|
||||||
|
total: 3,
|
||||||
|
durationMs: 2400,
|
||||||
|
senderKey6: 'aabbccddeeff',
|
||||||
|
timestampSec: 1700000000,
|
||||||
|
);
|
||||||
|
|
||||||
|
final message = Message(
|
||||||
|
id: 'm1',
|
||||||
|
messageType: MessageType.channel,
|
||||||
|
channelIdx: 0,
|
||||||
|
pathLen: 0,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000000,
|
||||||
|
text: envelope.encodeText(),
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
|
||||||
|
deliveryStatus: MessageDeliveryStatus.sent,
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.addMessage(message);
|
||||||
|
final stored = provider.messages.single;
|
||||||
|
expect(stored.isVoice, isTrue);
|
||||||
|
expect(stored.voiceId, equals('deafbead'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks legacy V text packets as voice', () {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
final packet = VoicePacket(
|
||||||
|
sessionId: '00112233',
|
||||||
|
mode: VoicePacketMode.mode700c,
|
||||||
|
index: 0,
|
||||||
|
total: 1,
|
||||||
|
codec2Data: Uint8List.fromList([1, 2, 3]),
|
||||||
|
);
|
||||||
|
final message = Message(
|
||||||
|
id: 'm2',
|
||||||
|
messageType: MessageType.channel,
|
||||||
|
channelIdx: 0,
|
||||||
|
pathLen: 0,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000001,
|
||||||
|
text: packet.encodeText(),
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
|
||||||
|
deliveryStatus: MessageDeliveryStatus.sent,
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.addMessage(message);
|
||||||
|
final stored = provider.messages.single;
|
||||||
|
expect(stored.isVoice, isTrue);
|
||||||
|
expect(stored.voiceId, equals('00112233'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
152
test/utils/voice_message_parser_test.dart
Normal file
152
test/utils/voice_message_parser_test.dart
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('VoiceEnvelope', () {
|
||||||
|
test('encodes and parses valid envelope', () {
|
||||||
|
final env = VoiceEnvelope(
|
||||||
|
sessionId: 'deadbeef',
|
||||||
|
mode: VoicePacketMode.mode1200,
|
||||||
|
total: 4,
|
||||||
|
durationMs: 3200,
|
||||||
|
senderKey6: 'aabbccddeeff',
|
||||||
|
timestampSec: 1700000000,
|
||||||
|
);
|
||||||
|
|
||||||
|
final text = env.encodeText();
|
||||||
|
expect(VoiceEnvelope.isVoiceEnvelopeText(text), isTrue);
|
||||||
|
|
||||||
|
final parsed = VoiceEnvelope.tryParseText(text);
|
||||||
|
expect(parsed, isNotNull);
|
||||||
|
expect(parsed!.sessionId, equals('deadbeef'));
|
||||||
|
expect(parsed.mode, equals(VoicePacketMode.mode1200));
|
||||||
|
expect(parsed.total, equals(4));
|
||||||
|
expect(parsed.durationMs, equals(3200));
|
||||||
|
expect(parsed.senderKey6, equals('aabbccddeeff'));
|
||||||
|
expect(parsed.version, equals(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects invalid envelope payload', () {
|
||||||
|
final text = 'VE1:nothex:1:2:1000:aabbccddeeff:1700000000:1';
|
||||||
|
expect(VoiceEnvelope.tryParseText(text), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('VoiceFetchRequest', () {
|
||||||
|
test('encodes and parses valid request', () {
|
||||||
|
final req = VoiceFetchRequest(
|
||||||
|
sessionId: '00112233',
|
||||||
|
requesterKey6: 'ffeeddccbbaa',
|
||||||
|
timestampSec: 1700000001,
|
||||||
|
);
|
||||||
|
final text = req.encodeText();
|
||||||
|
expect(VoiceFetchRequest.isVoiceFetchRequestText(text), isTrue);
|
||||||
|
|
||||||
|
final parsed = VoiceFetchRequest.tryParseText(text);
|
||||||
|
expect(parsed, isNotNull);
|
||||||
|
expect(parsed!.sessionId, equals('00112233'));
|
||||||
|
expect(parsed.want, equals('all'));
|
||||||
|
expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
|
||||||
|
expect(parsed.version, equals(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects invalid request payload', () {
|
||||||
|
expect(
|
||||||
|
VoiceFetchRequest.tryParseText(
|
||||||
|
'VR1:00112233:chunk:ffeeddccbbaa:1700000001:1',
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('VoicePacket backward compatibility', () {
|
||||||
|
test('parses legacy V: text format', () {
|
||||||
|
final pkt = VoicePacket(
|
||||||
|
sessionId: 'a1b2c3d4',
|
||||||
|
mode: VoicePacketMode.mode700c,
|
||||||
|
index: 0,
|
||||||
|
total: 1,
|
||||||
|
codec2Data: Uint8List.fromList([1, 2, 3, 4]),
|
||||||
|
);
|
||||||
|
final encoded = pkt.encodeText();
|
||||||
|
final parsed = VoicePacket.tryParseText(encoded);
|
||||||
|
expect(parsed, isNotNull);
|
||||||
|
expect(parsed!.sessionId, equals('a1b2c3d4'));
|
||||||
|
expect(parsed.total, equals(1));
|
||||||
|
expect(parsed.codec2Data, equals(Uint8List.fromList([1, 2, 3, 4])));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('constructs binary datagram from actual packet data', () {
|
||||||
|
final actualCodec2 = Uint8List.fromList([
|
||||||
|
0xD3,
|
||||||
|
0x19,
|
||||||
|
0x7A,
|
||||||
|
0x00,
|
||||||
|
0xFE,
|
||||||
|
0x44,
|
||||||
|
0xC1,
|
||||||
|
0x2B,
|
||||||
|
0x88,
|
||||||
|
]);
|
||||||
|
|
||||||
|
final pkt = VoicePacket(
|
||||||
|
sessionId: '01020304',
|
||||||
|
mode: VoicePacketMode.mode1300,
|
||||||
|
index: 2,
|
||||||
|
total: 5,
|
||||||
|
codec2Data: actualCodec2,
|
||||||
|
);
|
||||||
|
|
||||||
|
final datagram = pkt.encodeBinary();
|
||||||
|
expect(datagram[0], equals(0x56)); // magic 'V'
|
||||||
|
expect(datagram.sublist(1, 5), equals(Uint8List.fromList([1, 2, 3, 4])));
|
||||||
|
expect(datagram[5], equals(VoicePacketMode.mode1300.id));
|
||||||
|
expect(datagram[6], equals(2));
|
||||||
|
expect(datagram[7], equals(5));
|
||||||
|
expect(datagram.sublist(8), equals(actualCodec2));
|
||||||
|
|
||||||
|
final parsed = VoicePacket.tryParseBinary(datagram);
|
||||||
|
expect(parsed, isNotNull);
|
||||||
|
expect(parsed!.sessionId, equals('01020304'));
|
||||||
|
expect(parsed.mode, equals(VoicePacketMode.mode1300));
|
||||||
|
expect(parsed.index, equals(2));
|
||||||
|
expect(parsed.total, equals(5));
|
||||||
|
expect(parsed.codec2Data, equals(actualCodec2));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('VoiceWaveform', () {
|
||||||
|
test('builds bars from packet bytes', () {
|
||||||
|
final packet = VoicePacket(
|
||||||
|
sessionId: '1234abcd',
|
||||||
|
mode: VoicePacketMode.mode1200,
|
||||||
|
index: 0,
|
||||||
|
total: 1,
|
||||||
|
codec2Data: Uint8List.fromList([
|
||||||
|
0,
|
||||||
|
255,
|
||||||
|
10,
|
||||||
|
245,
|
||||||
|
120,
|
||||||
|
130,
|
||||||
|
64,
|
||||||
|
192,
|
||||||
|
32,
|
||||||
|
224,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
final bars = VoiceWaveform.buildBarsFromPackets([packet], bars: 8);
|
||||||
|
expect(bars.length, equals(8));
|
||||||
|
expect(bars.every((v) => v >= 0.0 && v <= 1.0), isTrue);
|
||||||
|
expect(bars.any((v) => v > 0.2), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns zeros for missing packet data', () {
|
||||||
|
final bars = VoiceWaveform.buildBarsFromPackets(const [], bars: 6);
|
||||||
|
expect(bars, equals(List<double>.filled(6, 0.0)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,9 +9,18 @@ import 'package:ffi/ffi.dart';
|
|||||||
/// Codec2 operating modes.
|
/// Codec2 operating modes.
|
||||||
/// Numeric values match the C constants in codec2.h.
|
/// Numeric values match the C constants in codec2.h.
|
||||||
enum Codec2Mode {
|
enum Codec2Mode {
|
||||||
|
/// 3200 bps.
|
||||||
|
mode3200(0),
|
||||||
|
|
||||||
/// 2400 bps — higher quality, ~300 bytes/sec output at 8kHz.
|
/// 2400 bps — higher quality, ~300 bytes/sec output at 8kHz.
|
||||||
mode2400(1),
|
mode2400(1),
|
||||||
|
|
||||||
|
/// 1600 bps.
|
||||||
|
mode1600(2),
|
||||||
|
|
||||||
|
/// 1400 bps.
|
||||||
|
mode1400(3),
|
||||||
|
|
||||||
/// 1300 bps — good quality for LoRa, ~175 bytes/sec at 8kHz (25 fps × 7 B).
|
/// 1300 bps — good quality for LoRa, ~175 bytes/sec at 8kHz (25 fps × 7 B).
|
||||||
mode1300(4),
|
mode1300(4),
|
||||||
|
|
||||||
@@ -20,7 +29,7 @@ enum Codec2Mode {
|
|||||||
|
|
||||||
/// 700C bps — minimum bandwidth for very narrow LoRa / ham radio channels.
|
/// 700C bps — minimum bandwidth for very narrow LoRa / ham radio channels.
|
||||||
/// ~100 bytes/sec output at 8kHz.
|
/// ~100 bytes/sec output at 8kHz.
|
||||||
mode700c(6);
|
mode700c(8);
|
||||||
|
|
||||||
const Codec2Mode(this.c2ModeId);
|
const Codec2Mode(this.c2ModeId);
|
||||||
|
|
||||||
@@ -28,7 +37,8 @@ enum Codec2Mode {
|
|||||||
final int c2ModeId;
|
final int c2ModeId;
|
||||||
|
|
||||||
/// Audio frames per second for this mode (all modes use 8000 Hz sample rate).
|
/// Audio frames per second for this mode (all modes use 8000 Hz sample rate).
|
||||||
int get framesPerSecond => this == mode2400 ? 50 : 25;
|
int get framesPerSecond =>
|
||||||
|
(this == mode3200 || this == mode2400) ? 50 : 25;
|
||||||
|
|
||||||
/// Samples per frame (8000 Hz / framesPerSecond).
|
/// Samples per frame (8000 Hz / framesPerSecond).
|
||||||
int get samplesPerFrame => 8000 ~/ framesPerSecond;
|
int get samplesPerFrame => 8000 ~/ framesPerSecond;
|
||||||
@@ -37,12 +47,18 @@ enum Codec2Mode {
|
|||||||
/// Used to calculate packet duration for the 172-byte BLE frame limit.
|
/// Used to calculate packet duration for the 172-byte BLE frame limit.
|
||||||
int get bytesPerSecond {
|
int get bytesPerSecond {
|
||||||
switch (this) {
|
switch (this) {
|
||||||
|
case mode3200:
|
||||||
|
return 400; // 8 B × 50 fps
|
||||||
case mode700c:
|
case mode700c:
|
||||||
return 100; // ceil(28/8)=4 B × 25 fps
|
return 100; // ceil(28/8)=4 B × 25 fps
|
||||||
case mode1200:
|
case mode1200:
|
||||||
return 150; // 6 B × 25 fps
|
return 150; // 6 B × 25 fps
|
||||||
case mode1300:
|
case mode1300:
|
||||||
return 175; // ceil(52/8)=7 B × 25 fps
|
return 175; // ceil(52/8)=7 B × 25 fps
|
||||||
|
case mode1400:
|
||||||
|
return 175; // 7 B × 25 fps
|
||||||
|
case mode1600:
|
||||||
|
return 200; // 8 B × 25 fps
|
||||||
case mode2400:
|
case mode2400:
|
||||||
return 300; // 6 B × 50 fps
|
return 300; // 6 B × 50 fps
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <audioplayers_windows/audioplayers_windows_plugin.h>
|
#include <audioplayers_windows/audioplayers_windows_plugin.h>
|
||||||
|
#include <flutter_blue_plus_winrt/flutter_blue_plus_plugin.h>
|
||||||
#include <geolocator_windows/geolocator_windows.h>
|
#include <geolocator_windows/geolocator_windows.h>
|
||||||
#include <nsd_windows/nsd_windows_plugin_c_api.h>
|
#include <nsd_windows/nsd_windows_plugin_c_api.h>
|
||||||
#include <objectbox_flutter_libs/objectbox_flutter_libs_plugin.h>
|
#include <objectbox_flutter_libs/objectbox_flutter_libs_plugin.h>
|
||||||
@@ -18,6 +19,8 @@
|
|||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
AudioplayersWindowsPluginRegisterWithRegistrar(
|
AudioplayersWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
|
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
|
||||||
|
FlutterBluePlusPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FlutterBluePlusPlugin"));
|
||||||
GeolocatorWindowsRegisterWithRegistrar(
|
GeolocatorWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
||||||
NsdWindowsPluginCApiRegisterWithRegistrar(
|
NsdWindowsPluginCApiRegisterWithRegistrar(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
audioplayers_windows
|
audioplayers_windows
|
||||||
|
flutter_blue_plus_winrt
|
||||||
geolocator_windows
|
geolocator_windows
|
||||||
nsd_windows
|
nsd_windows
|
||||||
objectbox_flutter_libs
|
objectbox_flutter_libs
|
||||||
|
|||||||
Reference in New Issue
Block a user