pumaguard 21.post27__py3-none-any.whl → 21.post83__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. pumaguard/presets.py +1 -0
  2. pumaguard/pumaguard-ui/.last_build_id +1 -1
  3. pumaguard/pumaguard-ui/assets/NOTICES +621 -71
  4. pumaguard/pumaguard-ui/assets/fonts/MaterialIcons-Regular.otf +0 -0
  5. pumaguard/pumaguard-ui/flutter_bootstrap.js +1 -1
  6. pumaguard/pumaguard-ui/main.dart.js +28869 -28787
  7. pumaguard/web_routes/dhcp.py +311 -54
  8. pumaguard/web_routes/diagnostics.py +6 -0
  9. pumaguard/web_routes/settings.py +13 -0
  10. pumaguard/web_ui.py +29 -0
  11. {pumaguard-21.post27.dist-info → pumaguard-21.post83.dist-info}/METADATA +1 -1
  12. pumaguard-21.post83.dist-info/RECORD +254 -0
  13. pumaguard-ui/.gitignore +48 -0
  14. pumaguard-ui/.metadata +45 -0
  15. pumaguard-ui/API_REFERENCE.md +717 -0
  16. pumaguard-ui/LICENSE +201 -0
  17. pumaguard-ui/Makefile +36 -0
  18. pumaguard-ui/README.md +371 -0
  19. pumaguard-ui/UI_DEVELOPMENT_CONTEXT.md +427 -0
  20. pumaguard-ui/analysis_options.yaml +28 -0
  21. pumaguard-ui/android/.gitignore +14 -0
  22. pumaguard-ui/android/app/build.gradle.kts +44 -0
  23. pumaguard-ui/android/app/src/debug/AndroidManifest.xml +7 -0
  24. pumaguard-ui/android/app/src/main/AndroidManifest.xml +45 -0
  25. pumaguard-ui/android/app/src/main/kotlin/com/example/pumaguard_ui/MainActivity.kt +5 -0
  26. pumaguard-ui/android/app/src/main/res/drawable/launch_background.xml +12 -0
  27. pumaguard-ui/android/app/src/main/res/drawable-v21/launch_background.xml +12 -0
  28. pumaguard-ui/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  29. pumaguard-ui/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  30. pumaguard-ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  31. pumaguard-ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  32. pumaguard-ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  33. pumaguard-ui/android/app/src/main/res/values/styles.xml +18 -0
  34. pumaguard-ui/android/app/src/main/res/values-night/styles.xml +18 -0
  35. pumaguard-ui/android/app/src/profile/AndroidManifest.xml +7 -0
  36. pumaguard-ui/android/build.gradle.kts +24 -0
  37. pumaguard-ui/android/gradle/wrapper/gradle-wrapper.properties +5 -0
  38. pumaguard-ui/android/gradle.properties +2 -0
  39. pumaguard-ui/android/settings.gradle.kts +26 -0
  40. pumaguard-ui/fonts/README.md +38 -0
  41. pumaguard-ui/fonts/Roboto-Bold.ttf +0 -0
  42. pumaguard-ui/fonts/Roboto-Light.ttf +0 -0
  43. pumaguard-ui/fonts/Roboto-Medium.ttf +0 -0
  44. pumaguard-ui/fonts/Roboto-Regular.ttf +0 -0
  45. pumaguard-ui/fonts/RobotoMono-Bold.ttf +0 -0
  46. pumaguard-ui/fonts/RobotoMono-Medium.ttf +0 -0
  47. pumaguard-ui/fonts/RobotoMono-Regular.ttf +0 -0
  48. pumaguard-ui/fonts/download_fonts.sh +76 -0
  49. pumaguard-ui/ios/.gitignore +34 -0
  50. pumaguard-ui/ios/Flutter/AppFrameworkInfo.plist +26 -0
  51. pumaguard-ui/ios/Flutter/Debug.xcconfig +1 -0
  52. pumaguard-ui/ios/Flutter/Release.xcconfig +1 -0
  53. pumaguard-ui/ios/Runner/AppDelegate.swift +13 -0
  54. pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +122 -0
  55. pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png +0 -0
  56. pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png +0 -0
  57. pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png +0 -0
  58. pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png +0 -0
  59. pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png +0 -0
  60. pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png +0 -0
  61. pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png +0 -0
  62. pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png +0 -0
  63. pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png +0 -0
  64. pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png +0 -0
  65. pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png +0 -0
  66. pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png +0 -0
  67. pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png +0 -0
  68. pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png +0 -0
  69. pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png +0 -0
  70. pumaguard-ui/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +23 -0
  71. pumaguard-ui/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png +0 -0
  72. pumaguard-ui/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png +0 -0
  73. pumaguard-ui/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png +0 -0
  74. pumaguard-ui/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +5 -0
  75. pumaguard-ui/ios/Runner/Base.lproj/LaunchScreen.storyboard +37 -0
  76. pumaguard-ui/ios/Runner/Base.lproj/Main.storyboard +26 -0
  77. pumaguard-ui/ios/Runner/Info.plist +49 -0
  78. pumaguard-ui/ios/Runner/Runner-Bridging-Header.h +1 -0
  79. pumaguard-ui/ios/Runner.xcodeproj/project.pbxproj +616 -0
  80. pumaguard-ui/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  81. pumaguard-ui/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  82. pumaguard-ui/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +8 -0
  83. pumaguard-ui/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +101 -0
  84. pumaguard-ui/ios/Runner.xcworkspace/contents.xcworkspacedata +7 -0
  85. pumaguard-ui/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  86. pumaguard-ui/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +8 -0
  87. pumaguard-ui/ios/RunnerTests/RunnerTests.swift +12 -0
  88. pumaguard-ui/lib/main.dart +56 -0
  89. pumaguard-ui/lib/models/camera.dart +45 -0
  90. pumaguard-ui/lib/models/plug.dart +45 -0
  91. pumaguard-ui/lib/models/settings.dart +112 -0
  92. pumaguard-ui/lib/models/status.dart +58 -0
  93. pumaguard-ui/lib/screens/directories_screen.dart +319 -0
  94. pumaguard-ui/lib/screens/home_screen.dart +545 -0
  95. pumaguard-ui/lib/screens/image_browser_screen.dart +1248 -0
  96. pumaguard-ui/lib/screens/server_discovery_screen.dart +390 -0
  97. pumaguard-ui/lib/screens/settings_screen.dart +1162 -0
  98. pumaguard-ui/lib/screens/wifi_settings_screen.dart +671 -0
  99. pumaguard-ui/lib/services/api_service.dart +717 -0
  100. pumaguard-ui/lib/services/camera_events_service.dart +195 -0
  101. pumaguard-ui/lib/services/mdns_service.dart +4 -0
  102. pumaguard-ui/lib/services/mdns_service_impl.dart +282 -0
  103. pumaguard-ui/lib/services/mdns_service_io.dart +1 -0
  104. pumaguard-ui/lib/services/mdns_service_web.dart +106 -0
  105. pumaguard-ui/lib/utils/download_helper.dart +2 -0
  106. pumaguard-ui/lib/utils/download_helper_stub.dart +6 -0
  107. pumaguard-ui/lib/utils/download_helper_web.dart +14 -0
  108. pumaguard-ui/lib/utils/platform_url.dart +10 -0
  109. pumaguard-ui/lib/utils/platform_url_stub.dart +11 -0
  110. pumaguard-ui/lib/utils/platform_url_web.dart +16 -0
  111. pumaguard-ui/linux/.gitignore +1 -0
  112. pumaguard-ui/linux/CMakeLists.txt +128 -0
  113. pumaguard-ui/linux/flutter/CMakeLists.txt +88 -0
  114. pumaguard-ui/linux/flutter/generated_plugin_registrant.cc +15 -0
  115. pumaguard-ui/linux/flutter/generated_plugin_registrant.h +15 -0
  116. pumaguard-ui/linux/flutter/generated_plugins.cmake +24 -0
  117. pumaguard-ui/linux/runner/CMakeLists.txt +26 -0
  118. pumaguard-ui/linux/runner/main.cc +6 -0
  119. pumaguard-ui/linux/runner/my_application.cc +148 -0
  120. pumaguard-ui/linux/runner/my_application.h +21 -0
  121. pumaguard-ui/macos/.gitignore +7 -0
  122. pumaguard-ui/macos/Flutter/Flutter-Debug.xcconfig +1 -0
  123. pumaguard-ui/macos/Flutter/Flutter-Release.xcconfig +1 -0
  124. pumaguard-ui/macos/Flutter/GeneratedPluginRegistrant.swift +16 -0
  125. pumaguard-ui/macos/Runner/AppDelegate.swift +13 -0
  126. pumaguard-ui/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +68 -0
  127. pumaguard-ui/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  128. pumaguard-ui/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  129. pumaguard-ui/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  130. pumaguard-ui/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  131. pumaguard-ui/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  132. pumaguard-ui/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  133. pumaguard-ui/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  134. pumaguard-ui/macos/Runner/Base.lproj/MainMenu.xib +343 -0
  135. pumaguard-ui/macos/Runner/Configs/AppInfo.xcconfig +14 -0
  136. pumaguard-ui/macos/Runner/Configs/Debug.xcconfig +2 -0
  137. pumaguard-ui/macos/Runner/Configs/Release.xcconfig +2 -0
  138. pumaguard-ui/macos/Runner/Configs/Warnings.xcconfig +13 -0
  139. pumaguard-ui/macos/Runner/DebugProfile.entitlements +12 -0
  140. pumaguard-ui/macos/Runner/Info.plist +32 -0
  141. pumaguard-ui/macos/Runner/MainFlutterWindow.swift +15 -0
  142. pumaguard-ui/macos/Runner/Release.entitlements +8 -0
  143. pumaguard-ui/macos/Runner.xcodeproj/project.pbxproj +705 -0
  144. pumaguard-ui/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  145. pumaguard-ui/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +99 -0
  146. pumaguard-ui/macos/Runner.xcworkspace/contents.xcworkspacedata +7 -0
  147. pumaguard-ui/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  148. pumaguard-ui/macos/RunnerTests/RunnerTests.swift +12 -0
  149. pumaguard-ui/pubspec.lock +882 -0
  150. pumaguard-ui/pubspec.yaml +125 -0
  151. pumaguard-ui/test/models/camera_test.dart +515 -0
  152. pumaguard-ui/test/models/plug_test.dart +499 -0
  153. pumaguard-ui/test/models/settings_test.dart +903 -0
  154. pumaguard-ui/test/models/status_test.dart +707 -0
  155. pumaguard-ui/test/screens/image_browser_grouping_test.dart +555 -0
  156. pumaguard-ui/test/services/api_service_cameras_test.dart +580 -0
  157. pumaguard-ui/test/services/api_service_image_browser_test.dart +512 -0
  158. pumaguard-ui/test/widget_test.dart.skip +38 -0
  159. pumaguard-ui/web/favicon.png +0 -0
  160. pumaguard-ui/web/icons/Icon-192.png +0 -0
  161. pumaguard-ui/web/icons/Icon-512.png +0 -0
  162. pumaguard-ui/web/icons/Icon-maskable-192.png +0 -0
  163. pumaguard-ui/web/icons/Icon-maskable-512.png +0 -0
  164. pumaguard-ui/web/index.html +38 -0
  165. pumaguard-ui/web/manifest.json +35 -0
  166. pumaguard-ui/windows/.gitignore +17 -0
  167. pumaguard-ui/windows/CMakeLists.txt +108 -0
  168. pumaguard-ui/windows/flutter/CMakeLists.txt +109 -0
  169. pumaguard-ui/windows/flutter/generated_plugin_registrant.cc +14 -0
  170. pumaguard-ui/windows/flutter/generated_plugin_registrant.h +15 -0
  171. pumaguard-ui/windows/flutter/generated_plugins.cmake +24 -0
  172. pumaguard-ui/windows/runner/CMakeLists.txt +40 -0
  173. pumaguard-ui/windows/runner/Runner.rc +121 -0
  174. pumaguard-ui/windows/runner/flutter_window.cpp +71 -0
  175. pumaguard-ui/windows/runner/flutter_window.h +33 -0
  176. pumaguard-ui/windows/runner/main.cpp +43 -0
  177. pumaguard-ui/windows/runner/resource.h +16 -0
  178. pumaguard-ui/windows/runner/resources/app_icon.ico +0 -0
  179. pumaguard-ui/windows/runner/runner.exe.manifest +14 -0
  180. pumaguard-ui/windows/runner/utils.cpp +65 -0
  181. pumaguard-ui/windows/runner/utils.h +19 -0
  182. pumaguard-ui/windows/runner/win32_window.cpp +288 -0
  183. pumaguard-ui/windows/runner/win32_window.h +102 -0
  184. pumaguard-21.post27.dist-info/RECORD +0 -83
  185. {pumaguard-21.post27.dist-info → pumaguard-21.post83.dist-info}/WHEEL +0 -0
  186. {pumaguard-21.post27.dist-info → pumaguard-21.post83.dist-info}/entry_points.txt +0 -0
  187. {pumaguard-21.post27.dist-info → pumaguard-21.post83.dist-info}/licenses/LICENSE +0 -0
  188. {pumaguard-21.post27.dist-info → pumaguard-21.post83.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1162 @@
1
+ import 'dart:async';
2
+ import 'package:flutter/material.dart';
3
+ import 'package:provider/provider.dart';
4
+ import '../models/settings.dart';
5
+ import '../services/api_service.dart';
6
+ import '../services/camera_events_service.dart';
7
+ import 'dart:developer' as developer;
8
+ import 'wifi_settings_screen.dart';
9
+ import 'package:file_picker/file_picker.dart';
10
+
11
+ class SettingsScreen extends StatefulWidget {
12
+ const SettingsScreen({super.key});
13
+
14
+ @override
15
+ State<SettingsScreen> createState() => _SettingsScreenState();
16
+ }
17
+
18
+ class _SettingsScreenState extends State<SettingsScreen> {
19
+ Settings? _settings;
20
+ bool _isLoading = true;
21
+ bool _isSaving = false;
22
+ bool _isTestingSound = false;
23
+ String? _error;
24
+
25
+ // Controllers for text fields
26
+ late TextEditingController _yoloMinSizeController;
27
+ late TextEditingController _yoloConfThreshController;
28
+ late TextEditingController _yoloMaxDetsController;
29
+ late TextEditingController _yoloModelController;
30
+ late TextEditingController _classifierModelController;
31
+ late TextEditingController _soundFileController;
32
+ late TextEditingController _fileStabilizationController;
33
+ bool _playSound = false;
34
+ double _volume = 80.0;
35
+ List<Map<String, dynamic>> _availableModels = [];
36
+ List<Map<String, dynamic>> _availableSounds = [];
37
+ Timer? _debounceTimer;
38
+ Timer? _cameraRefreshTimer;
39
+ CameraEventsService? _cameraEventsService;
40
+ StreamSubscription<CameraEvent>? _eventsSubscription;
41
+
42
+ // Camera refresh interval (in seconds)
43
+ static const int _cameraRefreshInterval = 30;
44
+
45
+ @override
46
+ void initState() {
47
+ super.initState();
48
+ _yoloMinSizeController = TextEditingController();
49
+ _yoloConfThreshController = TextEditingController();
50
+ _yoloMaxDetsController = TextEditingController();
51
+ _yoloModelController = TextEditingController();
52
+ _classifierModelController = TextEditingController();
53
+ _soundFileController = TextEditingController();
54
+ _fileStabilizationController = TextEditingController();
55
+ _loadSettings();
56
+ _initializeCameraEvents();
57
+ _startCameraRefresh();
58
+ }
59
+
60
+ @override
61
+ void dispose() {
62
+ _debounceTimer?.cancel();
63
+ _cameraRefreshTimer?.cancel();
64
+ _eventsSubscription?.cancel();
65
+ _cameraEventsService?.dispose();
66
+ _yoloMinSizeController.dispose();
67
+ _yoloConfThreshController.dispose();
68
+ _yoloMaxDetsController.dispose();
69
+ _yoloModelController.dispose();
70
+ _classifierModelController.dispose();
71
+ _soundFileController.dispose();
72
+ _fileStabilizationController.dispose();
73
+ super.dispose();
74
+ }
75
+
76
+ void _initializeCameraEvents() {
77
+ try {
78
+ final apiService = context.read<ApiService>();
79
+ final baseUrl = apiService.getApiUrl('').replaceAll('/api/', '');
80
+
81
+ _cameraEventsService = CameraEventsService(baseUrl);
82
+
83
+ // Subscribe to camera events
84
+ _eventsSubscription = _cameraEventsService!.events.listen(
85
+ (event) {
86
+ // Reload settings when camera status changes
87
+ if (event.type != CameraEventType.connected &&
88
+ event.type != CameraEventType.unknown) {
89
+ debugPrint(
90
+ '[SettingsScreen] Camera event: ${event.type}, reloading settings',
91
+ );
92
+ _loadSettings();
93
+ }
94
+ },
95
+ onError: (error) {
96
+ debugPrint('[SettingsScreen] Camera events error: $error');
97
+ },
98
+ );
99
+
100
+ // Start listening for events
101
+ _cameraEventsService!.startListening();
102
+ debugPrint('[SettingsScreen] Camera events service initialized');
103
+ } catch (e) {
104
+ debugPrint('[SettingsScreen] Error initializing camera events: $e');
105
+ }
106
+ }
107
+
108
+ void _startCameraRefresh() {
109
+ // Set up periodic timer to refresh camera status
110
+ _cameraRefreshTimer = Timer.periodic(
111
+ const Duration(seconds: _cameraRefreshInterval),
112
+ (_) => _loadSettings(),
113
+ );
114
+ }
115
+
116
+ Future<void> _loadSettings() async {
117
+ setState(() {
118
+ _isLoading = true;
119
+ _error = null;
120
+ });
121
+
122
+ try {
123
+ final apiService = context.read<ApiService>();
124
+ final settings = await apiService.getSettings();
125
+ setState(() {
126
+ _settings = settings;
127
+ _yoloMinSizeController.text = settings.yoloMinSize.toString();
128
+ _yoloConfThreshController.text = settings.yoloConfThresh.toString();
129
+ _yoloMaxDetsController.text = settings.yoloMaxDets.toString();
130
+ _yoloModelController.text = settings.yoloModelFilename;
131
+ _classifierModelController.text = settings.classifierModelFilename;
132
+ _soundFileController.text = settings.deterrentSoundFile;
133
+ _fileStabilizationController.text = settings.fileStabilizationExtraWait
134
+ .toString();
135
+ _playSound = settings.playSound;
136
+ _volume = settings.volume.toDouble();
137
+ _isLoading = false;
138
+ });
139
+ } catch (e) {
140
+ setState(() {
141
+ _error = e.toString();
142
+ _isLoading = false;
143
+ });
144
+ }
145
+ }
146
+
147
+ void _onTextFieldChanged() {
148
+ // Cancel any existing timer
149
+ _debounceTimer?.cancel();
150
+
151
+ // Create a new timer that will save after 1 second of no changes
152
+ _debounceTimer = Timer(const Duration(seconds: 1), () {
153
+ _saveSettings();
154
+ });
155
+ }
156
+
157
+ Future<void> _saveSettings() async {
158
+ if (_settings == null) return;
159
+
160
+ setState(() {
161
+ _isSaving = true;
162
+ _error = null;
163
+ });
164
+
165
+ try {
166
+ final updatedSettings = Settings(
167
+ yoloMinSize: double.tryParse(_yoloMinSizeController.text) ?? 0.01,
168
+ yoloConfThresh: double.tryParse(_yoloConfThreshController.text) ?? 0.25,
169
+ yoloMaxDets: int.tryParse(_yoloMaxDetsController.text) ?? 10,
170
+ yoloModelFilename: _yoloModelController.text,
171
+ classifierModelFilename: _classifierModelController.text,
172
+ deterrentSoundFile: _soundFileController.text,
173
+ fileStabilizationExtraWait:
174
+ double.tryParse(_fileStabilizationController.text) ?? 2.0,
175
+ playSound: _playSound,
176
+ volume: _volume.round(),
177
+ cameras: _settings?.cameras ?? [],
178
+ plugs: _settings?.plugs ?? [],
179
+ );
180
+
181
+ developer.log(
182
+ '[SettingsScreen._saveSettings] Settings JSON: ${updatedSettings.toJson()}',
183
+ );
184
+
185
+ final apiService = context.read<ApiService>();
186
+ await apiService.updateSettings(updatedSettings);
187
+
188
+ developer.log(
189
+ '[SettingsScreen._saveSettings] Settings saved successfully',
190
+ );
191
+
192
+ setState(() {
193
+ _settings = updatedSettings;
194
+ _isSaving = false;
195
+ });
196
+
197
+ if (mounted) {
198
+ ScaffoldMessenger.of(context).showSnackBar(
199
+ const SnackBar(
200
+ content: Text('Settings saved successfully'),
201
+ backgroundColor: Colors.green,
202
+ ),
203
+ );
204
+ }
205
+ } catch (e) {
206
+ setState(() {
207
+ _error = e.toString();
208
+ _isSaving = false;
209
+ });
210
+
211
+ if (mounted) {
212
+ ScaffoldMessenger.of(context).showSnackBar(
213
+ SnackBar(
214
+ content: Text('Failed to save settings: $e'),
215
+ backgroundColor: Colors.red,
216
+ ),
217
+ );
218
+ }
219
+ }
220
+ }
221
+
222
+ Future<void> _testSound() async {
223
+ setState(() {
224
+ _isTestingSound = true;
225
+ _error = null;
226
+ });
227
+
228
+ try {
229
+ final apiService = context.read<ApiService>();
230
+ await apiService.testSound();
231
+
232
+ if (mounted) {
233
+ ScaffoldMessenger.of(context).showSnackBar(
234
+ const SnackBar(
235
+ content: Text('Sound test started'),
236
+ backgroundColor: Colors.green,
237
+ duration: Duration(seconds: 2),
238
+ ),
239
+ );
240
+ }
241
+
242
+ // Poll sound status to detect when it finishes
243
+ while (mounted && _isTestingSound) {
244
+ await Future.delayed(const Duration(milliseconds: 500));
245
+ final isPlaying = await apiService.getSoundStatus();
246
+ if (!isPlaying && mounted) {
247
+ setState(() {
248
+ _isTestingSound = false;
249
+ });
250
+ break;
251
+ }
252
+ }
253
+ } catch (e) {
254
+ if (mounted) {
255
+ ScaffoldMessenger.of(context).showSnackBar(
256
+ SnackBar(
257
+ content: Text('Failed to test sound: $e'),
258
+ backgroundColor: Colors.red,
259
+ ),
260
+ );
261
+ setState(() {
262
+ _isTestingSound = false;
263
+ });
264
+ }
265
+ }
266
+ }
267
+
268
+ Future<void> _stopSound() async {
269
+ try {
270
+ final apiService = context.read<ApiService>();
271
+ await apiService.stopSound();
272
+
273
+ if (mounted) {
274
+ setState(() {
275
+ _isTestingSound = false;
276
+ });
277
+ ScaffoldMessenger.of(context).showSnackBar(
278
+ const SnackBar(
279
+ content: Text('Sound stopped'),
280
+ backgroundColor: Colors.orange,
281
+ duration: Duration(seconds: 2),
282
+ ),
283
+ );
284
+ }
285
+ } catch (e) {
286
+ if (mounted) {
287
+ ScaffoldMessenger.of(context).showSnackBar(
288
+ SnackBar(
289
+ content: Text('Failed to stop sound: $e'),
290
+ backgroundColor: Colors.red,
291
+ ),
292
+ );
293
+ }
294
+ }
295
+ }
296
+
297
+ Future<void> _uploadSoundFile() async {
298
+ try {
299
+ // Get API service before any async operations
300
+ final apiService = context.read<ApiService>();
301
+
302
+ // Pick a file
303
+ FilePickerResult? result = await FilePicker.platform.pickFiles(
304
+ type: FileType.custom,
305
+ allowedExtensions: ['mp3'],
306
+ withData: true,
307
+ );
308
+
309
+ if (result == null || result.files.isEmpty) {
310
+ // User canceled the picker
311
+ return;
312
+ }
313
+
314
+ final file = result.files.first;
315
+ if (file.bytes == null) {
316
+ if (mounted) {
317
+ ScaffoldMessenger.of(context).showSnackBar(
318
+ const SnackBar(
319
+ content: Text('Failed to read file'),
320
+ backgroundColor: Colors.red,
321
+ ),
322
+ );
323
+ }
324
+ return;
325
+ }
326
+
327
+ // Show loading indicator
328
+ if (mounted) {
329
+ setState(() {
330
+ _isLoading = true;
331
+ });
332
+ }
333
+
334
+ debugPrint(
335
+ '[SettingsScreen] Attempting to upload sound file: ${file.name}',
336
+ );
337
+ debugPrint('[SettingsScreen] File size: ${file.bytes!.length} bytes');
338
+
339
+ // Upload the file
340
+ // Note: file.path is not available on web, so we pass empty string
341
+ final response = await apiService.uploadSound(
342
+ '', // Path not available/needed on web
343
+ file.bytes!,
344
+ file.name,
345
+ );
346
+
347
+ debugPrint('[SettingsScreen] Upload response: $response');
348
+
349
+ if (mounted) {
350
+ setState(() {
351
+ _isLoading = false;
352
+ });
353
+
354
+ // Show success message
355
+ ScaffoldMessenger.of(context).showSnackBar(
356
+ SnackBar(
357
+ content: Text(
358
+ response['message'] ?? 'Sound file uploaded successfully',
359
+ ),
360
+ backgroundColor: Colors.green,
361
+ duration: const Duration(seconds: 3),
362
+ ),
363
+ );
364
+
365
+ // Set the uploaded file as the current sound
366
+ final uploadedFilename = response['filename'] as String?;
367
+ if (uploadedFilename != null) {
368
+ setState(() {
369
+ _soundFileController.text = uploadedFilename;
370
+ });
371
+ await _saveSettings();
372
+ }
373
+
374
+ // Refresh the sound picker dialog by calling it again
375
+ await _showSoundPicker();
376
+ }
377
+ } catch (e, stackTrace) {
378
+ developer.log('Error uploading sound', error: e, stackTrace: stackTrace);
379
+ debugPrint('[SettingsScreen] Upload error: $e');
380
+ debugPrint('[SettingsScreen] Stack trace: $stackTrace');
381
+ if (mounted) {
382
+ setState(() {
383
+ _isLoading = false;
384
+ });
385
+ ScaffoldMessenger.of(context).showSnackBar(
386
+ SnackBar(
387
+ content: Text('Failed to upload sound: $e'),
388
+ backgroundColor: Colors.red,
389
+ duration: const Duration(seconds: 5),
390
+ ),
391
+ );
392
+ }
393
+ }
394
+ }
395
+
396
+ Future<void> _showModelPicker({String modelType = 'classifier'}) async {
397
+ setState(() {
398
+ _isLoading = true;
399
+ });
400
+
401
+ try {
402
+ final apiService = context.read<ApiService>();
403
+ final models = await apiService.getAvailableModels(modelType: modelType);
404
+
405
+ setState(() {
406
+ _availableModels = models;
407
+ _isLoading = false;
408
+ });
409
+
410
+ if (!mounted) return;
411
+
412
+ final selectedModel = await showDialog<String>(
413
+ context: context,
414
+ builder: (BuildContext context) {
415
+ return AlertDialog(
416
+ title: Text(
417
+ modelType == 'yolo'
418
+ ? 'Select YOLO Model'
419
+ : 'Select Classifier Model',
420
+ ),
421
+ content: SizedBox(
422
+ width: double.maxFinite,
423
+ child: ListView.builder(
424
+ shrinkWrap: true,
425
+ itemCount: _availableModels.length,
426
+ itemBuilder: (context, index) {
427
+ final model = _availableModels[index];
428
+ final isCached = model['cached'] as bool;
429
+ final modelName = model['name'] as String;
430
+ final sizeMb = model['size_mb'] as double?;
431
+
432
+ return ListTile(
433
+ title: Text(modelName),
434
+ subtitle: Text(
435
+ isCached
436
+ ? 'Cached${sizeMb != null ? " (${sizeMb.toStringAsFixed(1)} MB)" : ""}'
437
+ : 'Requires download',
438
+ style: TextStyle(
439
+ color: isCached ? Colors.green : Colors.orange,
440
+ ),
441
+ ),
442
+ leading: Icon(
443
+ isCached ? Icons.check_circle : Icons.cloud_download,
444
+ color: isCached ? Colors.green : Colors.orange,
445
+ ),
446
+ trailing: modelName == _classifierModelController.text
447
+ ? const Icon(Icons.check, color: Colors.blue)
448
+ : null,
449
+ onTap: () {
450
+ Navigator.of(context).pop(modelName);
451
+ },
452
+ );
453
+ },
454
+ ),
455
+ ),
456
+ actions: [
457
+ TextButton(
458
+ onPressed: () => Navigator.of(context).pop(),
459
+ child: const Text('Cancel'),
460
+ ),
461
+ ],
462
+ );
463
+ },
464
+ );
465
+
466
+ if (selectedModel != null) {
467
+ setState(() {
468
+ if (modelType == 'yolo') {
469
+ _yoloModelController.text = selectedModel;
470
+ } else {
471
+ _classifierModelController.text = selectedModel;
472
+ }
473
+ });
474
+ // Persist the change immediately
475
+ await _saveSettings();
476
+ }
477
+ } catch (e) {
478
+ developer.log('Error loading models: $e');
479
+ setState(() {
480
+ _isLoading = false;
481
+ });
482
+ if (mounted) {
483
+ ScaffoldMessenger.of(context).showSnackBar(
484
+ SnackBar(
485
+ content: Text('Failed to load models: $e'),
486
+ backgroundColor: Colors.red,
487
+ ),
488
+ );
489
+ }
490
+ }
491
+ }
492
+
493
+ Future<void> _showSoundPicker() async {
494
+ setState(() {
495
+ _isLoading = true;
496
+ });
497
+
498
+ try {
499
+ final apiService = context.read<ApiService>();
500
+ final sounds = await apiService.getAvailableSounds();
501
+
502
+ setState(() {
503
+ _availableSounds = sounds;
504
+ _isLoading = false;
505
+ });
506
+
507
+ if (!mounted) return;
508
+
509
+ final selectedSound = await showDialog<String>(
510
+ context: context,
511
+ builder: (BuildContext context) {
512
+ return AlertDialog(
513
+ title: const Text('Select Sound File'),
514
+ content: SizedBox(
515
+ width: double.maxFinite,
516
+ child: Column(
517
+ mainAxisSize: MainAxisSize.min,
518
+ children: [
519
+ ElevatedButton.icon(
520
+ onPressed: () async {
521
+ Navigator.of(context).pop('__upload__');
522
+ },
523
+ icon: const Icon(Icons.upload_file),
524
+ label: const Text('Upload New Sound File'),
525
+ style: ElevatedButton.styleFrom(
526
+ minimumSize: const Size(double.infinity, 48),
527
+ ),
528
+ ),
529
+ const SizedBox(height: 16),
530
+ const Divider(),
531
+ const SizedBox(height: 8),
532
+ Flexible(
533
+ child: ListView.builder(
534
+ shrinkWrap: true,
535
+ itemCount: _availableSounds.length,
536
+ itemBuilder: (context, index) {
537
+ final sound = _availableSounds[index];
538
+ final soundName = sound['name'] as String;
539
+ final sizeMb = sound['size_mb'] as double?;
540
+
541
+ return ListTile(
542
+ title: Text(soundName),
543
+ subtitle: Text(
544
+ sizeMb != null
545
+ ? '${sizeMb.toStringAsFixed(2)} MB'
546
+ : '',
547
+ style: const TextStyle(color: Colors.blue),
548
+ ),
549
+ leading: const Icon(
550
+ Icons.music_note,
551
+ color: Colors.blue,
552
+ ),
553
+ trailing: soundName == _soundFileController.text
554
+ ? const Icon(Icons.check, color: Colors.blue)
555
+ : null,
556
+ onTap: () {
557
+ Navigator.of(context).pop(soundName);
558
+ },
559
+ );
560
+ },
561
+ ),
562
+ ),
563
+ ],
564
+ ),
565
+ ),
566
+ actions: [
567
+ TextButton(
568
+ onPressed: () => Navigator.of(context).pop(),
569
+ child: const Text('Cancel'),
570
+ ),
571
+ ],
572
+ );
573
+ },
574
+ );
575
+
576
+ if (selectedSound == '__upload__') {
577
+ // User selected upload option
578
+ await _uploadSoundFile();
579
+ } else if (selectedSound != null) {
580
+ setState(() {
581
+ _soundFileController.text = selectedSound;
582
+ });
583
+ // Persist the change immediately
584
+ await _saveSettings();
585
+ }
586
+ } catch (e) {
587
+ developer.log('Error loading sounds: $e');
588
+ setState(() {
589
+ _isLoading = false;
590
+ });
591
+ if (mounted) {
592
+ ScaffoldMessenger.of(context).showSnackBar(
593
+ SnackBar(
594
+ content: Text('Failed to load sounds: $e'),
595
+ backgroundColor: Colors.red,
596
+ ),
597
+ );
598
+ }
599
+ }
600
+ }
601
+
602
+ @override
603
+ Widget build(BuildContext context) {
604
+ return Scaffold(
605
+ appBar: AppBar(
606
+ title: const Text('Settings'),
607
+ actions: [
608
+ if (_isSaving)
609
+ const Padding(
610
+ padding: EdgeInsets.all(16.0),
611
+ child: SizedBox(
612
+ width: 20,
613
+ height: 20,
614
+ child: CircularProgressIndicator(strokeWidth: 2),
615
+ ),
616
+ ),
617
+ ],
618
+ ),
619
+ body: _buildBody(),
620
+ );
621
+ }
622
+
623
+ Widget _buildBody() {
624
+ if (_isLoading) {
625
+ return const Center(child: CircularProgressIndicator());
626
+ }
627
+
628
+ if (_error != null && _settings == null) {
629
+ return Center(
630
+ child: Column(
631
+ mainAxisAlignment: MainAxisAlignment.center,
632
+ children: [
633
+ Icon(
634
+ Icons.error_outline,
635
+ size: 64,
636
+ color: Theme.of(context).colorScheme.error,
637
+ ),
638
+ const SizedBox(height: 16),
639
+ Text(
640
+ 'Failed to Load Settings',
641
+ style: Theme.of(context).textTheme.headlineSmall,
642
+ ),
643
+ const SizedBox(height: 8),
644
+ Padding(
645
+ padding: const EdgeInsets.symmetric(horizontal: 32),
646
+ child: Text(
647
+ _error!,
648
+ textAlign: TextAlign.center,
649
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
650
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
651
+ ),
652
+ ),
653
+ ),
654
+ const SizedBox(height: 24),
655
+ ElevatedButton.icon(
656
+ onPressed: _loadSettings,
657
+ icon: const Icon(Icons.refresh),
658
+ label: const Text('Retry'),
659
+ ),
660
+ ],
661
+ ),
662
+ );
663
+ }
664
+
665
+ return SingleChildScrollView(
666
+ padding: const EdgeInsets.all(16),
667
+ child: Column(
668
+ crossAxisAlignment: CrossAxisAlignment.stretch,
669
+ children: [
670
+ _buildYoloSection(),
671
+ const SizedBox(height: 16),
672
+ _buildClassifierSection(),
673
+ const SizedBox(height: 16),
674
+ _buildSoundSection(),
675
+ const SizedBox(height: 16),
676
+ _buildSystemSection(),
677
+ const SizedBox(height: 24),
678
+ ElevatedButton.icon(
679
+ onPressed: _isSaving ? null : _saveSettings,
680
+ icon: _isSaving
681
+ ? const SizedBox(
682
+ width: 20,
683
+ height: 20,
684
+ child: CircularProgressIndicator(strokeWidth: 2),
685
+ )
686
+ : const Icon(Icons.save),
687
+ label: const Text('Save Settings'),
688
+ style: ElevatedButton.styleFrom(padding: const EdgeInsets.all(16)),
689
+ ),
690
+ ],
691
+ ),
692
+ );
693
+ }
694
+
695
+ Widget _buildYoloSection() {
696
+ return Card(
697
+ child: Padding(
698
+ padding: const EdgeInsets.all(16),
699
+ child: Column(
700
+ crossAxisAlignment: CrossAxisAlignment.start,
701
+ children: [
702
+ Row(
703
+ children: [
704
+ Icon(
705
+ Icons.track_changes,
706
+ color: Theme.of(context).colorScheme.primary,
707
+ ),
708
+ const SizedBox(width: 8),
709
+ Text(
710
+ 'YOLO Detection Settings',
711
+ style: Theme.of(context).textTheme.titleLarge,
712
+ ),
713
+ ],
714
+ ),
715
+ const SizedBox(height: 8),
716
+ Text(
717
+ 'Configure object detection parameters',
718
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
719
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
720
+ ),
721
+ ),
722
+ const SizedBox(height: 16),
723
+ TextField(
724
+ controller: _yoloMinSizeController,
725
+ onChanged: (_) => _onTextFieldChanged(),
726
+ decoration: const InputDecoration(
727
+ labelText: 'Minimum Size',
728
+ hintText: '0.01',
729
+ helperText: 'Minimum object size as fraction of image',
730
+ border: OutlineInputBorder(),
731
+ ),
732
+ keyboardType: TextInputType.number,
733
+ ),
734
+ const SizedBox(height: 16),
735
+ TextField(
736
+ controller: _yoloConfThreshController,
737
+ onChanged: (_) => _onTextFieldChanged(),
738
+ decoration: const InputDecoration(
739
+ labelText: 'Confidence Threshold',
740
+ hintText: '0.25',
741
+ helperText: 'Detection confidence threshold (0.0 - 1.0)',
742
+ border: OutlineInputBorder(),
743
+ ),
744
+ keyboardType: TextInputType.number,
745
+ ),
746
+ const SizedBox(height: 16),
747
+ TextField(
748
+ controller: _yoloMaxDetsController,
749
+ onChanged: (_) => _onTextFieldChanged(),
750
+ decoration: const InputDecoration(
751
+ labelText: 'Maximum Detections',
752
+ hintText: '10',
753
+ helperText: 'Maximum number of objects to detect',
754
+ border: OutlineInputBorder(),
755
+ ),
756
+ keyboardType: TextInputType.number,
757
+ ),
758
+ const SizedBox(height: 16),
759
+ TextField(
760
+ controller: _yoloModelController,
761
+ decoration: InputDecoration(
762
+ labelText: 'YOLO Model Filename',
763
+ hintText: 'yolo_model.pt',
764
+ helperText: 'Path to YOLO model file',
765
+ border: const OutlineInputBorder(),
766
+ suffixIcon: IconButton(
767
+ icon: const Icon(Icons.folder_open),
768
+ onPressed: () => _showModelPicker(modelType: 'yolo'),
769
+ tooltip: 'Browse available models',
770
+ ),
771
+ ),
772
+ readOnly: false,
773
+ ),
774
+ const SizedBox(height: 8),
775
+ OutlinedButton.icon(
776
+ onPressed: () => _showModelPicker(modelType: 'yolo'),
777
+ icon: const Icon(Icons.list),
778
+ label: const Text('Choose from Available Models'),
779
+ style: OutlinedButton.styleFrom(
780
+ padding: const EdgeInsets.all(12),
781
+ ),
782
+ ),
783
+ ],
784
+ ),
785
+ ),
786
+ );
787
+ }
788
+
789
+ Widget _buildClassifierSection() {
790
+ return Card(
791
+ child: Padding(
792
+ padding: const EdgeInsets.all(16),
793
+ child: Column(
794
+ crossAxisAlignment: CrossAxisAlignment.start,
795
+ children: [
796
+ Row(
797
+ children: [
798
+ Icon(
799
+ Icons.category,
800
+ color: Theme.of(context).colorScheme.primary,
801
+ ),
802
+ const SizedBox(width: 8),
803
+ Text(
804
+ 'Classifier Settings',
805
+ style: Theme.of(context).textTheme.titleLarge,
806
+ ),
807
+ ],
808
+ ),
809
+ const SizedBox(height: 8),
810
+ Text(
811
+ 'Configure EfficientNet classifier',
812
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
813
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
814
+ ),
815
+ ),
816
+ const SizedBox(height: 16),
817
+ TextField(
818
+ controller: _classifierModelController,
819
+ decoration: InputDecoration(
820
+ labelText: 'Classifier Model Filename',
821
+ hintText: 'classifier_model.h5',
822
+ helperText: 'Path to classifier model file',
823
+ border: const OutlineInputBorder(),
824
+ suffixIcon: IconButton(
825
+ icon: const Icon(Icons.folder_open),
826
+ onPressed: _showModelPicker,
827
+ tooltip: 'Browse available models',
828
+ ),
829
+ ),
830
+ readOnly: false,
831
+ ),
832
+ const SizedBox(height: 8),
833
+ OutlinedButton.icon(
834
+ onPressed: _showModelPicker,
835
+ icon: const Icon(Icons.list),
836
+ label: const Text('Choose from Available Models'),
837
+ style: OutlinedButton.styleFrom(
838
+ padding: const EdgeInsets.all(12),
839
+ ),
840
+ ),
841
+ ],
842
+ ),
843
+ ),
844
+ );
845
+ }
846
+
847
+ Widget _buildSoundSection() {
848
+ return Card(
849
+ child: Padding(
850
+ padding: const EdgeInsets.all(16),
851
+ child: Column(
852
+ crossAxisAlignment: CrossAxisAlignment.start,
853
+ children: [
854
+ Row(
855
+ children: [
856
+ Icon(
857
+ Icons.volume_up,
858
+ color: Theme.of(context).colorScheme.primary,
859
+ ),
860
+ const SizedBox(width: 8),
861
+ Text(
862
+ 'Sound Settings',
863
+ style: Theme.of(context).textTheme.titleLarge,
864
+ ),
865
+ ],
866
+ ),
867
+ const SizedBox(height: 8),
868
+ Text(
869
+ 'Configure deterrent sound playback',
870
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
871
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
872
+ ),
873
+ ),
874
+ const SizedBox(height: 16),
875
+ TextField(
876
+ controller: _soundFileController,
877
+ decoration: InputDecoration(
878
+ labelText: 'Deterrent Sound File',
879
+ hintText: 'deterrent.wav',
880
+ helperText: 'Path to sound file to play when puma detected',
881
+ border: const OutlineInputBorder(),
882
+ suffixIcon: IconButton(
883
+ icon: const Icon(Icons.folder_open),
884
+ onPressed: _showSoundPicker,
885
+ tooltip: 'Browse available sounds',
886
+ ),
887
+ ),
888
+ readOnly: false,
889
+ ),
890
+ const SizedBox(height: 8),
891
+ Row(
892
+ children: [
893
+ Expanded(
894
+ child: OutlinedButton.icon(
895
+ onPressed: _showSoundPicker,
896
+ icon: const Icon(Icons.list),
897
+ label: const Text('Choose from Available Sounds'),
898
+ style: OutlinedButton.styleFrom(
899
+ padding: const EdgeInsets.all(12),
900
+ ),
901
+ ),
902
+ ),
903
+ const SizedBox(width: 8),
904
+ Expanded(
905
+ child: ElevatedButton.icon(
906
+ onPressed: _isLoading ? null : _uploadSoundFile,
907
+ icon: const Icon(Icons.upload_file),
908
+ label: const Text('Upload New Sound File'),
909
+ style: ElevatedButton.styleFrom(
910
+ padding: const EdgeInsets.all(12),
911
+ ),
912
+ ),
913
+ ),
914
+ ],
915
+ ),
916
+ const SizedBox(height: 16),
917
+ SwitchListTile(
918
+ title: const Text('Play Sound'),
919
+ subtitle: const Text('Enable deterrent sound playback'),
920
+ value: _playSound,
921
+ onChanged: (value) {
922
+ setState(() {
923
+ _playSound = value;
924
+ });
925
+ // Persist the change immediately
926
+ _saveSettings();
927
+ },
928
+ ),
929
+ const SizedBox(height: 16),
930
+ Row(
931
+ children: [
932
+ Icon(
933
+ Icons.volume_down,
934
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
935
+ ),
936
+ Expanded(
937
+ child: Slider(
938
+ value: _volume,
939
+ min: 0,
940
+ max: 100,
941
+ divisions: 20,
942
+ label: _volume.round().toString(),
943
+ onChanged: (value) {
944
+ setState(() {
945
+ _volume = value;
946
+ });
947
+ },
948
+ onChangeEnd: (value) {
949
+ // Save when user releases the slider
950
+ _saveSettings();
951
+ },
952
+ ),
953
+ ),
954
+ Icon(
955
+ Icons.volume_up,
956
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
957
+ ),
958
+ const SizedBox(width: 8),
959
+ SizedBox(
960
+ width: 45,
961
+ child: Text(
962
+ '${_volume.round()}%',
963
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
964
+ fontFamily: 'RobotoMono',
965
+ fontWeight: FontWeight.bold,
966
+ ),
967
+ ),
968
+ ),
969
+ ],
970
+ ),
971
+ const SizedBox(height: 16),
972
+ Row(
973
+ children: [
974
+ Expanded(
975
+ child: OutlinedButton.icon(
976
+ onPressed: _isTestingSound ? null : _testSound,
977
+ icon: _isTestingSound
978
+ ? const SizedBox(
979
+ width: 20,
980
+ height: 20,
981
+ child: CircularProgressIndicator(strokeWidth: 2),
982
+ )
983
+ : const Icon(Icons.play_arrow),
984
+ label: const Text('Test Sound'),
985
+ style: OutlinedButton.styleFrom(
986
+ padding: const EdgeInsets.all(12),
987
+ ),
988
+ ),
989
+ ),
990
+ const SizedBox(width: 8),
991
+ Expanded(
992
+ child: OutlinedButton.icon(
993
+ onPressed: _isTestingSound ? _stopSound : null,
994
+ icon: const Icon(Icons.stop),
995
+ label: const Text('Stop Sound'),
996
+ style: OutlinedButton.styleFrom(
997
+ padding: const EdgeInsets.all(12),
998
+ foregroundColor: Colors.orange,
999
+ ),
1000
+ ),
1001
+ ),
1002
+ ],
1003
+ ),
1004
+ ],
1005
+ ),
1006
+ ),
1007
+ );
1008
+ }
1009
+
1010
+ Widget _buildSystemSection() {
1011
+ return Card(
1012
+ child: Padding(
1013
+ padding: const EdgeInsets.all(16),
1014
+ child: Column(
1015
+ crossAxisAlignment: CrossAxisAlignment.start,
1016
+ children: [
1017
+ Row(
1018
+ children: [
1019
+ Icon(
1020
+ Icons.settings_system_daydream,
1021
+ color: Theme.of(context).colorScheme.primary,
1022
+ ),
1023
+ const SizedBox(width: 8),
1024
+ Text(
1025
+ 'System Settings',
1026
+ style: Theme.of(context).textTheme.titleLarge,
1027
+ ),
1028
+ ],
1029
+ ),
1030
+ const SizedBox(height: 8),
1031
+ Text(
1032
+ 'Configure system behavior',
1033
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
1034
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
1035
+ ),
1036
+ ),
1037
+ const SizedBox(height: 16),
1038
+ TextField(
1039
+ controller: _fileStabilizationController,
1040
+ onChanged: (_) => _onTextFieldChanged(),
1041
+ decoration: const InputDecoration(
1042
+ labelText: 'File Stabilization Wait (seconds)',
1043
+ hintText: '2.0',
1044
+ helperText: 'Extra wait time for file operations to complete',
1045
+ border: OutlineInputBorder(),
1046
+ ),
1047
+ keyboardType: TextInputType.number,
1048
+ ),
1049
+ const SizedBox(height: 24),
1050
+ // WiFi Settings Button
1051
+ FilledButton.icon(
1052
+ onPressed: () {
1053
+ Navigator.of(context).push(
1054
+ MaterialPageRoute(
1055
+ builder: (context) => WifiSettingsScreen(
1056
+ apiService: context.read<ApiService>(),
1057
+ ),
1058
+ ),
1059
+ );
1060
+ },
1061
+ icon: const Icon(Icons.wifi),
1062
+ label: const Text('WiFi Settings'),
1063
+ style: FilledButton.styleFrom(
1064
+ minimumSize: const Size(double.infinity, 48),
1065
+ ),
1066
+ ),
1067
+ const SizedBox(height: 24),
1068
+ // Detected Cameras Section
1069
+ if (_settings != null && _settings!.cameras.isNotEmpty) ...[
1070
+ Text(
1071
+ 'Detected Cameras',
1072
+ style: Theme.of(context).textTheme.titleLarge,
1073
+ ),
1074
+ const SizedBox(height: 8),
1075
+ Text(
1076
+ 'Cameras detected via DHCP',
1077
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
1078
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
1079
+ ),
1080
+ ),
1081
+ const SizedBox(height: 16),
1082
+ ..._settings!.cameras.map((camera) {
1083
+ return Card(
1084
+ child: ListTile(
1085
+ leading: Icon(
1086
+ Icons.videocam,
1087
+ color: camera.isConnected ? Colors.green : Colors.grey,
1088
+ ),
1089
+ title: Text(camera.displayName),
1090
+ subtitle: Column(
1091
+ crossAxisAlignment: CrossAxisAlignment.start,
1092
+ children: [
1093
+ Text('IP: ${camera.ipAddress}'),
1094
+ Text(
1095
+ 'Status: ${camera.status}',
1096
+ style: TextStyle(
1097
+ color: camera.isConnected
1098
+ ? Colors.green
1099
+ : Colors.grey,
1100
+ ),
1101
+ ),
1102
+ ],
1103
+ ),
1104
+ trailing: Icon(
1105
+ camera.isConnected ? Icons.check_circle : Icons.cancel,
1106
+ color: camera.isConnected ? Colors.green : Colors.grey,
1107
+ ),
1108
+ ),
1109
+ );
1110
+ }),
1111
+ const SizedBox(height: 24),
1112
+ ],
1113
+ // Detected Plugs Section
1114
+ if (_settings != null && _settings!.plugs.isNotEmpty) ...[
1115
+ Text(
1116
+ 'Detected Plugs',
1117
+ style: Theme.of(context).textTheme.titleLarge,
1118
+ ),
1119
+ const SizedBox(height: 8),
1120
+ Text(
1121
+ 'Smart plugs detected via DHCP (for deterrents)',
1122
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
1123
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
1124
+ ),
1125
+ ),
1126
+ const SizedBox(height: 16),
1127
+ ..._settings!.plugs.map((plug) {
1128
+ return Card(
1129
+ child: ListTile(
1130
+ leading: Icon(
1131
+ Icons.power,
1132
+ color: plug.isConnected ? Colors.green : Colors.grey,
1133
+ ),
1134
+ title: Text(plug.displayName),
1135
+ subtitle: Column(
1136
+ crossAxisAlignment: CrossAxisAlignment.start,
1137
+ children: [
1138
+ Text('IP: ${plug.ipAddress}'),
1139
+ Text(
1140
+ 'Status: ${plug.status}',
1141
+ style: TextStyle(
1142
+ color: plug.isConnected
1143
+ ? Colors.green
1144
+ : Colors.grey,
1145
+ ),
1146
+ ),
1147
+ ],
1148
+ ),
1149
+ trailing: Icon(
1150
+ plug.isConnected ? Icons.check_circle : Icons.cancel,
1151
+ color: plug.isConnected ? Colors.green : Colors.grey,
1152
+ ),
1153
+ ),
1154
+ );
1155
+ }),
1156
+ ],
1157
+ ],
1158
+ ),
1159
+ ),
1160
+ );
1161
+ }
1162
+ }