Skip to content

Commit 66e2b46

Browse files
committed
Add audio service for sound notifications
- Implemented AudioService class to manage audio playback for transmit and receive notifications. - Integrated just_audio package for audio playback functionality. - Added methods to initialize audio service, load/save user preferences, and play sounds. - Included functionality to enable/disable sound notifications and toggle settings. - Pre-loaded audio assets for instant playback during notifications.
1 parent 42773f9 commit 66e2b46

File tree

15 files changed

+657
-104
lines changed

15 files changed

+657
-104
lines changed

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"Bash(flutter doctor:*)",
1414
"Bash(flutter create:*)",
1515
"Bash(find:*)",
16-
"Bash(grep:*)"
16+
"Bash(grep:*)",
17+
"Bash(flutter clean:*)"
1718
]
1819
}
1920
}

android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
public final class GeneratedPluginRegistrant {
1616
private static final String TAG = "GeneratedPluginRegistrant";
1717
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
18+
try {
19+
flutterEngine.getPlugins().add(new com.ryanheise.audio_session.AudioSessionPlugin());
20+
} catch (Exception e) {
21+
Log.e(TAG, "Error registering plugin audio_session, com.ryanheise.audio_session.AudioSessionPlugin", e);
22+
}
1823
try {
1924
flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin());
2025
} catch (Exception e) {
@@ -45,6 +50,11 @@ public static void registerWith(@NonNull FlutterEngine flutterEngine) {
4550
} catch (Exception e) {
4651
Log.e(TAG, "Error registering plugin geolocator_android, com.baseflow.geolocator.GeolocatorPlugin", e);
4752
}
53+
try {
54+
flutterEngine.getPlugins().add(new com.ryanheise.just_audio.JustAudioPlugin());
55+
} catch (Exception e) {
56+
Log.e(TAG, "Error registering plugin just_audio, com.ryanheise.just_audio.JustAudioPlugin", e);
57+
}
4858
try {
4959
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin());
5060
} catch (Exception e) {

android/local.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ sdk.dir=/opt/homebrew/share/android-commandlinetools
22
flutter.sdk=/opt/homebrew/share/flutter
33
flutter.buildMode=release
44
flutter.versionName=1.0.0
5-
flutter.versionCode=1769259963
5+
flutter.versionCode=1769283486

assets/received_packet.mp3

25.8 KB
Binary file not shown.

assets/transmitted_packet.mp3

10.9 KB
Binary file not shown.

ios/Podfile.lock

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
PODS:
2+
- audio_session (0.0.1):
3+
- Flutter
24
- DKImagePickerController/Core (4.3.9):
35
- DKImagePickerController/ImageDataManager
46
- DKImagePickerController/Resource
@@ -44,6 +46,9 @@ PODS:
4446
- geolocator_apple (1.2.0):
4547
- Flutter
4648
- FlutterMacOS
49+
- just_audio (0.0.1):
50+
- Flutter
51+
- FlutterMacOS
4752
- package_info_plus (0.4.5):
4853
- Flutter
4954
- permission_handler_apple (9.3.0):
@@ -63,12 +68,14 @@ PODS:
6368
- Flutter
6469

6570
DEPENDENCIES:
71+
- audio_session (from `.symlinks/plugins/audio_session/ios`)
6672
- file_picker (from `.symlinks/plugins/file_picker/ios`)
6773
- Flutter (from `Flutter`)
6874
- flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_ios/ios`)
6975
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
7076
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
7177
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
78+
- just_audio (from `.symlinks/plugins/just_audio/darwin`)
7279
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
7380
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
7481
- share_plus (from `.symlinks/plugins/share_plus/ios`)
@@ -84,6 +91,8 @@ SPEC REPOS:
8491
- SwiftyGif
8592

8693
EXTERNAL SOURCES:
94+
audio_session:
95+
:path: ".symlinks/plugins/audio_session/ios"
8796
file_picker:
8897
:path: ".symlinks/plugins/file_picker/ios"
8998
Flutter:
@@ -96,6 +105,8 @@ EXTERNAL SOURCES:
96105
:path: ".symlinks/plugins/flutter_local_notifications/ios"
97106
geolocator_apple:
98107
:path: ".symlinks/plugins/geolocator_apple/darwin"
108+
just_audio:
109+
:path: ".symlinks/plugins/just_audio/darwin"
99110
package_info_plus:
100111
:path: ".symlinks/plugins/package_info_plus/ios"
101112
permission_handler_apple:
@@ -110,6 +121,7 @@ EXTERNAL SOURCES:
110121
:path: ".symlinks/plugins/wakelock_plus/ios"
111122

112123
SPEC CHECKSUMS:
124+
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
113125
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
114126
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
115127
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
@@ -118,6 +130,7 @@ SPEC CHECKSUMS:
118130
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
119131
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
120132
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
133+
just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed
121134
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
122135
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
123136
SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838

ios/Runner/Info.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
<string>This app needs background GPS for continuous wardriving</string>
4141
<key>NSLocationWhenInUseUsageDescription</key>
4242
<string>This app needs GPS for wardriving location tracking</string>
43+
<key>NSMicrophoneUsageDescription</key>
44+
<string>This app uses audio for notification sounds</string>
4345
<key>NSPhotoLibraryUsageDescription</key>
4446
<string>This app may need access to photos when importing or exporting data files</string>
4547
<key>ITSAppUsesNonExemptEncryption</key>

lib/providers/app_state_provider.dart

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import '../models/repeater.dart';
1616
import '../models/user_preferences.dart';
1717
import '../services/api_queue_service.dart';
1818
import '../services/api_service.dart';
19+
import '../services/audio_service.dart';
1920
import '../services/background_service.dart';
2021
import '../services/debug_file_logger.dart';
2122
import '../services/offline_session_service.dart';
@@ -66,6 +67,7 @@ class AppStateProvider extends ChangeNotifier {
6667
late final ApiQueueService _apiQueueService;
6768
late final OfflineSessionService _offlineSessionService;
6869
late final DeviceModelService _deviceModelService;
70+
final AudioService _audioService = AudioService();
6971
late final CooldownTimer _cooldownTimer; // Shared cooldown for TX Ping and Active Mode
7072
late final AutoPingTimer _autoPingTimer;
7173
late final RxWindowTimer _rxWindowTimer;
@@ -230,6 +232,10 @@ class AppStateProvider extends ChangeNotifier {
230232
// Regional channels getter (for UI)
231233
List<String> get regionalChannels => List.unmodifiable(_regionalChannels);
232234

235+
// Audio service getters
236+
bool get isSoundEnabled => _audioService.isEnabled;
237+
AudioService get audioService => _audioService;
238+
233239
bool get isConnected => _connectionStep == ConnectionStep.connected;
234240
bool get hasGpsLock => _gpsStatus == GpsStatus.locked;
235241
bool get canPing => isConnected && hasGpsLock;
@@ -390,6 +396,9 @@ class AppStateProvider extends ChangeNotifier {
390396
// Start GPS (may skip if permissions not yet granted - disclosure flow handles that)
391397
await _gpsService.startWatching();
392398

399+
// Initialize audio service for sound notifications
400+
await _audioService.initialize();
401+
393402
notifyListeners();
394403
}
395404

@@ -625,6 +634,7 @@ class AppStateProvider extends ChangeNotifier {
625634
discoveryWindowTimer: _discoveryWindowTimer,
626635
deviceId: _deviceId,
627636
txTracker: _txTracker,
637+
audioService: _audioService,
628638
);
629639

630640
// Set validation callbacks
@@ -719,6 +729,8 @@ class AppStateProvider extends ChangeNotifier {
719729
if (isNew) {
720730
// Add new event
721731
existingEvents.add(newEvent);
732+
// Play receive sound for new repeater echo
733+
_audioService.playReceiveSound();
722734
} else {
723735
// Update existing event's SNR
724736
final idx = existingEvents.indexWhere((e) => e.repeaterId == repeater.repeaterId);
@@ -901,6 +913,8 @@ class AppStateProvider extends ChangeNotifier {
901913
debugLog('[APP] Created IMMEDIATE RX pin for repeater: ${observation.repeaterId} '
902914
'at ${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)} '
903915
'(batch tracking: ${_currentBatchRepeaters.length} repeaters, rxCount: ${_pingStats.rxCount})');
916+
// Play receive sound for new RX observation
917+
_audioService.playReceiveSound();
904918
notifyListeners();
905919
} else {
906920
debugLog('[APP] Repeater ${observation.repeaterId} already has pin in current batch, SNR will update on flush if better');
@@ -1599,6 +1613,18 @@ class AppStateProvider extends ChangeNotifier {
15991613
_savePreferences();
16001614
}
16011615

1616+
/// Toggle sound notifications on/off
1617+
Future<void> toggleSoundEnabled() async {
1618+
await _audioService.toggle();
1619+
notifyListeners();
1620+
}
1621+
1622+
/// Set sound notifications enabled state
1623+
Future<void> setSoundEnabled(bool enabled) async {
1624+
await _audioService.setEnabled(enabled);
1625+
notifyListeners();
1626+
}
1627+
16021628
/// Navigate to coordinates on map (triggered from log entries)
16031629
void navigateToMapCoordinates(double latitude, double longitude) {
16041630
_mapNavigationTarget = (lat: latitude, lon: longitude);
@@ -2253,6 +2279,7 @@ class AppStateProvider extends ChangeNotifier {
22532279
_offlineSessionService.dispose();
22542280
_apiService.dispose();
22552281
_bluetoothService.dispose();
2282+
_audioService.dispose();
22562283
_cooldownTimer.dispose();
22572284
_autoPingTimer.dispose();
22582285
_rxWindowTimer.dispose();

lib/screens/home_screen.dart

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -148,17 +148,15 @@ class _HomeScreenState extends State<HomeScreen> {
148148

149149
// Compact connection info
150150
const Padding(
151-
padding: EdgeInsets.all(12),
151+
padding: EdgeInsets.fromLTRB(12, 12, 12, 4),
152152
child: ConnectionPanel(compact: true),
153153
),
154154

155155
// Ping controls
156156
const Padding(
157-
padding: EdgeInsets.all(12),
157+
padding: EdgeInsets.fromLTRB(12, 4, 12, 12),
158158
child: PingControls(),
159159
),
160-
161-
const SizedBox(height: 8),
162160
],
163161
),
164162
);
@@ -225,7 +223,7 @@ class _HomeScreenState extends State<HomeScreen> {
225223
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
226224
),
227225
builder: (context) => DraggableScrollableSheet(
228-
initialChildSize: 0.6,
226+
initialChildSize: 0.85,
229227
minChildSize: 0.3,
230228
maxChildSize: 0.9,
231229
expand: false,
@@ -304,6 +302,14 @@ class _HomeScreenState extends State<HomeScreen> {
304302
description: 'Save pings locally instead of uploading immediately. Useful when you have poor connectivity. Upload saved sessions later from the Settings tab.',
305303
),
306304

305+
// Sound toggle
306+
_buildHelpItem(
307+
icon: Icons.volume_up,
308+
color: Colors.blue,
309+
title: 'Sound',
310+
description: 'Sonar tone when sending TX/Discovery pings. Message tone when receiving valid RX packets, heard repeaters, or discovery responses.',
311+
),
312+
307313
const SizedBox(height: 8),
308314
],
309315
),

lib/services/audio_service.dart

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import 'package:just_audio/just_audio.dart';
2+
import 'package:hive_flutter/hive_flutter.dart';
3+
4+
import '../utils/debug_logger_io.dart';
5+
6+
/// Audio service for playing sound notifications
7+
/// Plays sounds when TX pings are sent and RX packets are received
8+
class AudioService {
9+
static const String _prefsBoxName = 'audio_preferences';
10+
static const String _enabledKey = 'sound_enabled';
11+
12+
AudioPlayer? _txPlayer;
13+
AudioPlayer? _rxPlayer;
14+
bool _initialized = false;
15+
bool _enabled = false; // Disabled by default, remembered once user changes it
16+
17+
/// Whether the audio service is initialized
18+
bool get isInitialized => _initialized;
19+
20+
/// Whether sound notifications are enabled
21+
bool get isEnabled => _enabled;
22+
23+
/// Initialize the audio service and pre-load sounds
24+
Future<void> initialize() async {
25+
if (_initialized) return;
26+
27+
try {
28+
debugLog('[AUDIO] Initializing audio service');
29+
30+
// Load enabled state from preferences
31+
await _loadEnabledState();
32+
33+
// Create audio players
34+
_txPlayer = AudioPlayer();
35+
_rxPlayer = AudioPlayer();
36+
37+
// Pre-load the audio assets for instant playback
38+
// Using asset:// prefix for just_audio
39+
await _txPlayer!.setAsset('assets/transmitted_packet.mp3');
40+
await _rxPlayer!.setAsset('assets/received_packet.mp3');
41+
42+
_initialized = true;
43+
debugLog('[AUDIO] Audio service initialized, enabled=$_enabled');
44+
} catch (e) {
45+
debugError('[AUDIO] Failed to initialize audio service: $e');
46+
// Don't throw - audio is not critical functionality
47+
_initialized = false;
48+
}
49+
}
50+
51+
/// Load enabled state from Hive storage
52+
Future<void> _loadEnabledState() async {
53+
try {
54+
final box = await Hive.openBox(_prefsBoxName);
55+
final enabled = box.get(_enabledKey);
56+
if (enabled != null) {
57+
_enabled = enabled as bool;
58+
debugLog('[AUDIO] Loaded enabled state: $_enabled');
59+
} else {
60+
debugLog('[AUDIO] No saved preference, using default: $_enabled');
61+
}
62+
} catch (e) {
63+
debugError('[AUDIO] Failed to load enabled state: $e');
64+
// Keep default (disabled)
65+
}
66+
}
67+
68+
/// Save enabled state to Hive storage
69+
Future<void> _saveEnabledState() async {
70+
try {
71+
final box = await Hive.openBox(_prefsBoxName);
72+
await box.put(_enabledKey, _enabled);
73+
debugLog('[AUDIO] Saved enabled state: $_enabled');
74+
} catch (e) {
75+
debugError('[AUDIO] Failed to save enabled state: $e');
76+
}
77+
}
78+
79+
/// Play the transmit sound (when TX ping or Discovery request is sent)
80+
Future<void> playTransmitSound() async {
81+
debugLog('[AUDIO] playTransmitSound called - initialized=$_initialized, enabled=$_enabled');
82+
if (!_initialized || !_enabled) {
83+
debugLog('[AUDIO] playTransmitSound skipped - not initialized or disabled');
84+
return;
85+
}
86+
87+
try {
88+
debugLog('[AUDIO] Playing transmit sound...');
89+
// Seek to start and play
90+
await _txPlayer?.seek(Duration.zero);
91+
await _txPlayer?.play();
92+
debugLog('[AUDIO] Transmit sound played successfully');
93+
} catch (e) {
94+
debugError('[AUDIO] Failed to play transmit sound: $e');
95+
}
96+
}
97+
98+
/// Play the receive sound (when repeater echo or RX observation is detected)
99+
Future<void> playReceiveSound() async {
100+
if (!_initialized || !_enabled) return;
101+
102+
try {
103+
// Seek to start and play
104+
await _rxPlayer?.seek(Duration.zero);
105+
await _rxPlayer?.play();
106+
debugLog('[AUDIO] Played receive sound');
107+
} catch (e) {
108+
debugError('[AUDIO] Failed to play receive sound: $e');
109+
}
110+
}
111+
112+
/// Enable or disable sound notifications
113+
Future<void> setEnabled(bool enabled) async {
114+
if (_enabled == enabled) return;
115+
116+
_enabled = enabled;
117+
debugLog('[AUDIO] Sound notifications ${enabled ? 'enabled' : 'disabled'}');
118+
await _saveEnabledState();
119+
}
120+
121+
/// Toggle sound notifications
122+
Future<void> toggle() async {
123+
await setEnabled(!_enabled);
124+
}
125+
126+
/// Dispose of audio resources
127+
void dispose() {
128+
debugLog('[AUDIO] Disposing audio service');
129+
_txPlayer?.dispose();
130+
_rxPlayer?.dispose();
131+
_txPlayer = null;
132+
_rxPlayer = null;
133+
_initialized = false;
134+
}
135+
}

0 commit comments

Comments
 (0)