pumaguard 21.post29__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.post29.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.post29.dist-info/RECORD +0 -83
  185. {pumaguard-21.post29.dist-info → pumaguard-21.post83.dist-info}/WHEEL +0 -0
  186. {pumaguard-21.post29.dist-info → pumaguard-21.post83.dist-info}/entry_points.txt +0 -0
  187. {pumaguard-21.post29.dist-info → pumaguard-21.post83.dist-info}/licenses/LICENSE +0 -0
  188. {pumaguard-21.post29.dist-info → pumaguard-21.post83.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1248 @@
1
+ import 'package:flutter/material.dart';
2
+ import 'package:provider/provider.dart';
3
+ import 'dart:developer' as developer;
4
+ import '../services/api_service.dart';
5
+ import 'package:flutter/foundation.dart' show kIsWeb;
6
+ import 'package:file_picker/file_picker.dart';
7
+ import '../utils/download_helper.dart';
8
+ import 'package:intl/intl.dart';
9
+ import 'package:shared_preferences/shared_preferences.dart';
10
+
11
+ enum ImageGrouping { none, day, week }
12
+
13
+ enum ImageSize { small, large, full }
14
+
15
+ class ImageBrowserScreen extends StatefulWidget {
16
+ const ImageBrowserScreen({super.key});
17
+
18
+ @override
19
+ State<ImageBrowserScreen> createState() => _ImageBrowserScreenState();
20
+ }
21
+
22
+ class _ImageBrowserScreenState extends State<ImageBrowserScreen> {
23
+ List<Map<String, dynamic>> _folders = [];
24
+ String? _selectedFolder;
25
+ List<Map<String, dynamic>> _images = [];
26
+ Set<String> _selectedImages = {};
27
+ bool _isLoading = false;
28
+ bool _isDownloading = false;
29
+ String? _error;
30
+ bool _selectAll = false;
31
+ ImageGrouping _grouping = ImageGrouping.none;
32
+ ImageSize _imageSize = ImageSize.large;
33
+
34
+ @override
35
+ void initState() {
36
+ super.initState();
37
+ _loadGroupingPreference();
38
+ _loadFolders();
39
+ }
40
+
41
+ Future<void> _loadGroupingPreference() async {
42
+ final prefs = await SharedPreferences.getInstance();
43
+ final groupingString = prefs.getString('image_grouping') ?? 'none';
44
+ final sizeString = prefs.getString('image_size') ?? 'large';
45
+ setState(() {
46
+ _grouping = ImageGrouping.values.firstWhere(
47
+ (e) => e.name == groupingString,
48
+ orElse: () => ImageGrouping.none,
49
+ );
50
+ _imageSize = ImageSize.values.firstWhere(
51
+ (e) => e.name == sizeString,
52
+ orElse: () => ImageSize.large,
53
+ );
54
+ });
55
+ }
56
+
57
+ Future<void> _saveGroupingPreference(ImageGrouping grouping) async {
58
+ final prefs = await SharedPreferences.getInstance();
59
+ await prefs.setString('image_grouping', grouping.name);
60
+ }
61
+
62
+ Future<void> _saveSizePreference(ImageSize size) async {
63
+ final prefs = await SharedPreferences.getInstance();
64
+ await prefs.setString('image_size', size.name);
65
+ }
66
+
67
+ List<Map<String, dynamic>> _groupImages(List<Map<String, dynamic>> images) {
68
+ if (_grouping == ImageGrouping.none) {
69
+ return images;
70
+ }
71
+
72
+ // Group images by date
73
+ final Map<String, List<Map<String, dynamic>>> grouped = {};
74
+
75
+ for (final image in images) {
76
+ final timestamp = image['modified'];
77
+ if (timestamp == null) continue;
78
+
79
+ // Handle both int and double (st_mtime is a float)
80
+ final timestampInt = (timestamp is int)
81
+ ? timestamp
82
+ : (timestamp as num).round();
83
+ final date = DateTime.fromMillisecondsSinceEpoch(timestampInt * 1000);
84
+ String groupKey;
85
+
86
+ if (_grouping == ImageGrouping.day) {
87
+ // Group by day
88
+ groupKey = DateFormat('yyyy-MM-dd EEEE').format(date);
89
+ } else {
90
+ // Group by week
91
+ final weekStart = date.subtract(Duration(days: date.weekday - 1));
92
+ final weekEnd = weekStart.add(const Duration(days: 6));
93
+ groupKey =
94
+ '${DateFormat('MMM d').format(weekStart)} - ${DateFormat('MMM d, yyyy').format(weekEnd)}';
95
+ }
96
+
97
+ if (!grouped.containsKey(groupKey)) {
98
+ grouped[groupKey] = [];
99
+ }
100
+ grouped[groupKey]!.add(image);
101
+ }
102
+
103
+ // Sort groups by date (most recent first)
104
+ final sortedKeys = grouped.keys.toList()
105
+ ..sort((a, b) {
106
+ // Get the first image from each group to compare dates
107
+ final aTimestamp = grouped[a]!.first['modified'];
108
+ final aTimestampInt = (aTimestamp is int)
109
+ ? aTimestamp
110
+ : (aTimestamp as num).round();
111
+ final aDate = DateTime.fromMillisecondsSinceEpoch(aTimestampInt * 1000);
112
+
113
+ final bTimestamp = grouped[b]!.first['modified'];
114
+ final bTimestampInt = (bTimestamp is int)
115
+ ? bTimestamp
116
+ : (bTimestamp as num).round();
117
+ final bDate = DateTime.fromMillisecondsSinceEpoch(bTimestampInt * 1000);
118
+
119
+ return bDate.compareTo(aDate); // Descending order
120
+ });
121
+
122
+ // Flatten the grouped images back into a list with headers
123
+ final List<Map<String, dynamic>> result = [];
124
+ for (final key in sortedKeys) {
125
+ // Add a header item with reference to group images
126
+ result.add({
127
+ 'is_header': true,
128
+ 'header_text': key,
129
+ 'image_count': grouped[key]!.length,
130
+ 'group_images': grouped[key]!, // Include images for group operations
131
+ });
132
+ // Add all images in this group
133
+ result.addAll(grouped[key]!);
134
+ }
135
+
136
+ return result;
137
+ }
138
+
139
+ Future<void> _loadFolders() async {
140
+ setState(() {
141
+ _isLoading = true;
142
+ _error = null;
143
+ });
144
+
145
+ try {
146
+ final apiService = Provider.of<ApiService>(context, listen: false);
147
+ final folders = await apiService.getFolders();
148
+ setState(() {
149
+ _folders = folders;
150
+ _isLoading = false;
151
+ });
152
+
153
+ // Reload images for the currently selected folder if there is one
154
+ if (_selectedFolder != null) {
155
+ await _loadFolderImages(_selectedFolder!);
156
+ }
157
+ } catch (e) {
158
+ setState(() {
159
+ _error = e.toString();
160
+ _isLoading = false;
161
+ });
162
+ }
163
+ }
164
+
165
+ Future<void> _loadFolderImages(String folderPath) async {
166
+ setState(() {
167
+ _isLoading = true;
168
+ _error = null;
169
+ _selectedFolder = folderPath;
170
+ _images = [];
171
+ _selectedImages.clear();
172
+ _selectAll = false;
173
+ });
174
+
175
+ try {
176
+ final apiService = Provider.of<ApiService>(context, listen: false);
177
+ final result = await apiService.getFolderImages(folderPath);
178
+ final images = result['images'] as List<dynamic>;
179
+
180
+ // The backend returns 'path' as relative to the base directory
181
+ // This is exactly what the /api/photos endpoint expects
182
+ final imagesWithFullPaths = images.map((img) {
183
+ final imageMap = img as Map<String, dynamic>;
184
+ final relativePath = imageMap['path'] as String;
185
+ return {
186
+ ...imageMap,
187
+ 'full_path':
188
+ relativePath, // Use the relative path as-is for API calls
189
+ };
190
+ }).toList();
191
+
192
+ setState(() {
193
+ _images = imagesWithFullPaths.cast<Map<String, dynamic>>();
194
+ _isLoading = false;
195
+ });
196
+ } catch (e) {
197
+ setState(() {
198
+ _error = e.toString();
199
+ _isLoading = false;
200
+ });
201
+ }
202
+ }
203
+
204
+ void _toggleImageSelection(String imagePath, String fullPath) {
205
+ setState(() {
206
+ if (_selectedImages.contains(fullPath)) {
207
+ _selectedImages.remove(fullPath);
208
+ _selectAll = false;
209
+ } else {
210
+ _selectedImages.add(fullPath);
211
+ if (_selectedImages.length == _images.length) {
212
+ _selectAll = true;
213
+ }
214
+ }
215
+ });
216
+ }
217
+
218
+ void _toggleSelectAll() {
219
+ setState(() {
220
+ _selectAll = !_selectAll;
221
+ if (_selectAll) {
222
+ _selectedImages = _images
223
+ .map((img) => img['full_path'] as String)
224
+ .toSet();
225
+ } else {
226
+ _selectedImages.clear();
227
+ }
228
+ });
229
+ }
230
+
231
+ void _selectAllInGroup(List<Map<String, dynamic>> groupImages) {
232
+ setState(() {
233
+ // Add all images in this group to selected images
234
+ for (final image in groupImages) {
235
+ if (image['is_header'] != true) {
236
+ final fullPath = image['full_path'] as String;
237
+ _selectedImages.add(fullPath);
238
+ }
239
+ }
240
+ // Update select all checkbox state
241
+ if (_selectedImages.length == _images.length) {
242
+ _selectAll = true;
243
+ }
244
+ });
245
+ }
246
+
247
+ void _deselectAllInGroup(List<Map<String, dynamic>> groupImages) {
248
+ setState(() {
249
+ // Remove all images in this group from selected images
250
+ for (final image in groupImages) {
251
+ if (image['is_header'] != true) {
252
+ final fullPath = image['full_path'] as String;
253
+ _selectedImages.remove(fullPath);
254
+ }
255
+ }
256
+ _selectAll = false;
257
+ });
258
+ }
259
+
260
+ bool _areAllImagesInGroupSelected(List<Map<String, dynamic>> groupImages) {
261
+ for (final image in groupImages) {
262
+ if (image['is_header'] != true) {
263
+ final fullPath = image['full_path'] as String;
264
+ if (!_selectedImages.contains(fullPath)) {
265
+ return false;
266
+ }
267
+ }
268
+ }
269
+ return true;
270
+ }
271
+
272
+ String _formatFolderName(String folderName) {
273
+ // Replace "intermediate" with "AI" for display
274
+ if (folderName.toLowerCase() == 'intermediate') {
275
+ return 'AI';
276
+ }
277
+ return folderName;
278
+ }
279
+
280
+ Future<void> _downloadSelectedImages() async {
281
+ if (_selectedImages.isEmpty) {
282
+ if (mounted) {
283
+ ScaffoldMessenger.of(
284
+ context,
285
+ ).showSnackBar(const SnackBar(content: Text('No images selected')));
286
+ }
287
+ return;
288
+ }
289
+
290
+ setState(() {
291
+ _isDownloading = true;
292
+ _error = null;
293
+ });
294
+
295
+ try {
296
+ final apiService = Provider.of<ApiService>(context, listen: false);
297
+
298
+ // For web, we'll download directly without checksum comparison
299
+ // For native apps, we could implement local checksum comparison
300
+ if (kIsWeb) {
301
+ await _downloadFilesWeb(apiService);
302
+ } else {
303
+ await _downloadFilesNative(apiService);
304
+ }
305
+
306
+ if (mounted) {
307
+ setState(() {
308
+ _isDownloading = false;
309
+ });
310
+
311
+ ScaffoldMessenger.of(context).showSnackBar(
312
+ SnackBar(
313
+ content: Text('Downloaded ${_selectedImages.length} image(s)'),
314
+ ),
315
+ );
316
+ }
317
+ } catch (e) {
318
+ if (mounted) {
319
+ setState(() {
320
+ _error = e.toString();
321
+ _isDownloading = false;
322
+ });
323
+ ScaffoldMessenger.of(context).showSnackBar(
324
+ SnackBar(
325
+ content: Text('Download failed: $e'),
326
+ backgroundColor: Colors.red,
327
+ ),
328
+ );
329
+ }
330
+ }
331
+ }
332
+
333
+ Future<void> _downloadFilesWeb(ApiService apiService) async {
334
+ // For web, download files directly
335
+ final fileBytes = await apiService.downloadFiles(_selectedImages.toList());
336
+
337
+ // Use web download helper
338
+ final filename = _selectedImages.length == 1
339
+ ? _selectedImages.first.split('/').last
340
+ : 'pumaguard_images.zip';
341
+ downloadFilesWeb(fileBytes, filename);
342
+ }
343
+
344
+ Future<void> _downloadFilesNative(ApiService apiService) async {
345
+ // For native apps, allow user to select destination folder
346
+ String? selectedDirectory = await FilePicker.platform.getDirectoryPath();
347
+
348
+ if (selectedDirectory == null) {
349
+ // User cancelled
350
+ return;
351
+ }
352
+
353
+ // TODO: Implement local file checking and checksum comparison
354
+ // For now, just download all files
355
+ await apiService.downloadFiles(_selectedImages.toList());
356
+
357
+ // Save to selected directory
358
+ // Note: This is simplified - in production you'd want to:
359
+ // 1. Check local files
360
+ // 2. Calculate checksums
361
+ // 3. Only download changed files
362
+ // 4. Extract ZIP if multiple files
363
+
364
+ if (mounted) {
365
+ ScaffoldMessenger.of(context).showSnackBar(
366
+ SnackBar(content: Text('Files saved to: $selectedDirectory')),
367
+ );
368
+ }
369
+ }
370
+
371
+ Future<void> _deleteSelectedImages() async {
372
+ if (_selectedImages.isEmpty) {
373
+ if (mounted) {
374
+ ScaffoldMessenger.of(
375
+ context,
376
+ ).showSnackBar(const SnackBar(content: Text('No images selected')));
377
+ }
378
+ return;
379
+ }
380
+
381
+ // Get apiService before async gap
382
+ final apiService = Provider.of<ApiService>(context, listen: false);
383
+
384
+ // Show confirmation dialog
385
+ final confirm = await showDialog<bool>(
386
+ context: context,
387
+ builder: (BuildContext context) {
388
+ return AlertDialog(
389
+ title: const Text('Delete Images'),
390
+ content: Text(
391
+ 'Are you sure you want to delete ${_selectedImages.length} image(s)? This action cannot be undone.',
392
+ ),
393
+ actions: [
394
+ TextButton(
395
+ onPressed: () => Navigator.of(context).pop(false),
396
+ child: const Text('Cancel'),
397
+ ),
398
+ TextButton(
399
+ onPressed: () => Navigator.of(context).pop(true),
400
+ style: TextButton.styleFrom(foregroundColor: Colors.red),
401
+ child: const Text('Delete'),
402
+ ),
403
+ ],
404
+ );
405
+ },
406
+ );
407
+
408
+ if (confirm != true) {
409
+ return;
410
+ }
411
+
412
+ setState(() {
413
+ _isLoading = true;
414
+ _error = null;
415
+ });
416
+
417
+ try {
418
+ final imagesToDelete = _selectedImages.toList();
419
+ int successCount = 0;
420
+ int failCount = 0;
421
+
422
+ for (final imagePath in imagesToDelete) {
423
+ try {
424
+ await apiService.deletePhoto(imagePath);
425
+ successCount++;
426
+
427
+ // Remove from local state
428
+ setState(() {
429
+ _images.removeWhere((img) => img['full_path'] == imagePath);
430
+ _selectedImages.remove(imagePath);
431
+ });
432
+ } catch (e) {
433
+ failCount++;
434
+ developer.log(
435
+ 'Failed to delete $imagePath: $e',
436
+ name: 'ImageBrowser',
437
+ );
438
+ }
439
+ }
440
+
441
+ if (mounted) {
442
+ setState(() {
443
+ _isLoading = false;
444
+ _selectAll = false;
445
+ });
446
+
447
+ if (failCount == 0) {
448
+ ScaffoldMessenger.of(context).showSnackBar(
449
+ SnackBar(
450
+ content: Text('Successfully deleted $successCount image(s)'),
451
+ backgroundColor: Colors.green,
452
+ ),
453
+ );
454
+ } else {
455
+ ScaffoldMessenger.of(context).showSnackBar(
456
+ SnackBar(
457
+ content: Text(
458
+ 'Deleted $successCount image(s), failed to delete $failCount',
459
+ ),
460
+ backgroundColor: Colors.orange,
461
+ ),
462
+ );
463
+ }
464
+
465
+ // Reload folder list to update counts
466
+ await _loadFolders();
467
+ }
468
+ } catch (e) {
469
+ if (mounted) {
470
+ setState(() {
471
+ _error = e.toString();
472
+ _isLoading = false;
473
+ });
474
+ ScaffoldMessenger.of(context).showSnackBar(
475
+ SnackBar(
476
+ content: Text('Delete failed: $e'),
477
+ backgroundColor: Colors.red,
478
+ ),
479
+ );
480
+ }
481
+ }
482
+ }
483
+
484
+ String _formatFileSize(int bytes) {
485
+ if (bytes < 1024) return '$bytes B';
486
+ if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
487
+ if (bytes < 1024 * 1024 * 1024) {
488
+ return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
489
+ }
490
+ return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
491
+ }
492
+
493
+ Widget _buildImageItem(
494
+ BuildContext context,
495
+ ApiService apiService,
496
+ Map<String, dynamic> image,
497
+ ) {
498
+ final imagePath = image['path'] as String;
499
+ final fullPath = image['full_path'] as String;
500
+ final isSelected = _selectedImages.contains(fullPath);
501
+
502
+ // Determine thumbnail parameters based on selected size
503
+ final bool useThumbnail;
504
+ final int? maxWidth;
505
+ final int? maxHeight;
506
+ final double cardMaxHeight;
507
+ final double cardMinHeight;
508
+
509
+ switch (_imageSize) {
510
+ case ImageSize.small:
511
+ useThumbnail = true;
512
+ maxWidth = 200;
513
+ maxHeight = 200;
514
+ cardMaxHeight = 150;
515
+ cardMinHeight = 100;
516
+ break;
517
+ case ImageSize.large:
518
+ useThumbnail = true;
519
+ maxWidth = 400;
520
+ maxHeight = 400;
521
+ cardMaxHeight = 300;
522
+ cardMinHeight = 150;
523
+ break;
524
+ case ImageSize.full:
525
+ useThumbnail = false;
526
+ maxWidth = null;
527
+ maxHeight = null;
528
+ cardMaxHeight = 600;
529
+ cardMinHeight = 300;
530
+ break;
531
+ }
532
+
533
+ // Debug: log constructed photo URL
534
+ final photoUrl = apiService.getPhotoUrl(
535
+ fullPath,
536
+ thumbnail: useThumbnail,
537
+ maxWidth: maxWidth,
538
+ maxHeight: maxHeight,
539
+ );
540
+ developer.log(
541
+ 'ImageBrowser: base=$_selectedFolder path=$imagePath full=$fullPath url=$photoUrl',
542
+ name: 'ImageBrowser',
543
+ );
544
+
545
+ return GestureDetector(
546
+ onTap: () => _toggleImageSelection(imagePath, fullPath),
547
+ child: Card(
548
+ elevation: isSelected ? 8 : 2,
549
+ color: isSelected
550
+ ? Theme.of(context).colorScheme.primaryContainer
551
+ : null,
552
+ child: Column(
553
+ crossAxisAlignment: CrossAxisAlignment.stretch,
554
+ children: [
555
+ ConstrainedBox(
556
+ constraints: BoxConstraints(
557
+ maxHeight: cardMaxHeight,
558
+ minHeight: cardMinHeight,
559
+ ),
560
+ child: Stack(
561
+ children: [
562
+ Center(
563
+ child: ClipRRect(
564
+ borderRadius: const BorderRadius.vertical(
565
+ top: Radius.circular(12),
566
+ ),
567
+ child: _RetryableImage(
568
+ photoUrl: photoUrl,
569
+ fit: BoxFit.contain,
570
+ ),
571
+ ),
572
+ ),
573
+ Positioned(
574
+ top: 8,
575
+ right: 8,
576
+ child: Container(
577
+ decoration: BoxDecoration(
578
+ color: isSelected
579
+ ? Theme.of(context).colorScheme.primary
580
+ : Colors.white,
581
+ shape: BoxShape.circle,
582
+ ),
583
+ child: Padding(
584
+ padding: const EdgeInsets.all(4),
585
+ child: Icon(
586
+ isSelected
587
+ ? Icons.check_circle
588
+ : Icons.circle_outlined,
589
+ color: isSelected ? Colors.white : Colors.grey,
590
+ size: 24,
591
+ ),
592
+ ),
593
+ ),
594
+ ),
595
+ ],
596
+ ),
597
+ ),
598
+ Padding(
599
+ padding: const EdgeInsets.all(8),
600
+ child: _imageSize == ImageSize.small
601
+ ? Column(
602
+ crossAxisAlignment: CrossAxisAlignment.start,
603
+ children: [
604
+ Text(
605
+ image['filename'] as String,
606
+ style: Theme.of(context).textTheme.bodySmall,
607
+ maxLines: 1,
608
+ overflow: TextOverflow.ellipsis,
609
+ ),
610
+ ],
611
+ )
612
+ : Column(
613
+ crossAxisAlignment: CrossAxisAlignment.start,
614
+ children: [
615
+ Text(
616
+ image['filename'] as String,
617
+ style: Theme.of(context).textTheme.bodySmall,
618
+ maxLines: 1,
619
+ overflow: TextOverflow.ellipsis,
620
+ ),
621
+ // Debug: show relative path used for API
622
+ const SizedBox(height: 4),
623
+ Text(
624
+ 'rel: $fullPath',
625
+ style: Theme.of(context).textTheme.bodySmall
626
+ ?.copyWith(color: Colors.grey[600]),
627
+ maxLines: 1,
628
+ overflow: TextOverflow.ellipsis,
629
+ ),
630
+ const SizedBox(height: 4),
631
+ Text(
632
+ _formatFileSize(image['size'] as int),
633
+ style: Theme.of(context).textTheme.bodySmall
634
+ ?.copyWith(color: Colors.grey[600]),
635
+ ),
636
+ ],
637
+ ),
638
+ ),
639
+ ],
640
+ ),
641
+ ),
642
+ );
643
+ }
644
+
645
+ @override
646
+ Widget build(BuildContext context) {
647
+ final apiService = Provider.of<ApiService>(context, listen: false);
648
+ final displayImages = _groupImages(_images);
649
+
650
+ return Scaffold(
651
+ appBar: AppBar(
652
+ title: const Text('Image Browser'),
653
+ actions: [
654
+ if (_selectedImages.isNotEmpty)
655
+ Padding(
656
+ padding: const EdgeInsets.all(8.0),
657
+ child: Center(
658
+ child: Text(
659
+ '${_selectedImages.length} selected',
660
+ style: Theme.of(context).textTheme.titleMedium,
661
+ ),
662
+ ),
663
+ ),
664
+ if (_selectedImages.isNotEmpty)
665
+ IconButton(
666
+ icon: const Icon(Icons.download),
667
+ onPressed: _isDownloading ? null : _downloadSelectedImages,
668
+ tooltip: 'Download selected images',
669
+ ),
670
+ if (_selectedImages.isNotEmpty)
671
+ IconButton(
672
+ icon: const Icon(Icons.delete),
673
+ onPressed: _isLoading ? null : _deleteSelectedImages,
674
+ tooltip: 'Delete selected images',
675
+ ),
676
+ IconButton(
677
+ icon: const Icon(Icons.refresh),
678
+ onPressed: _isLoading ? null : _loadFolders,
679
+ tooltip: 'Refresh',
680
+ ),
681
+ ],
682
+ ),
683
+ body: _isLoading && _folders.isEmpty
684
+ ? const Center(child: CircularProgressIndicator())
685
+ : _error != null
686
+ ? Center(
687
+ child: Column(
688
+ mainAxisAlignment: MainAxisAlignment.center,
689
+ children: [
690
+ const Icon(Icons.error_outline, size: 48, color: Colors.red),
691
+ const SizedBox(height: 16),
692
+ Text('Error: $_error'),
693
+ const SizedBox(height: 16),
694
+ ElevatedButton(
695
+ onPressed: _loadFolders,
696
+ child: const Text('Retry'),
697
+ ),
698
+ ],
699
+ ),
700
+ )
701
+ : Row(
702
+ children: [
703
+ // Folder list sidebar
704
+ SizedBox(
705
+ width: 300,
706
+ child: Card(
707
+ margin: const EdgeInsets.all(8),
708
+ child: Column(
709
+ children: [
710
+ Padding(
711
+ padding: const EdgeInsets.all(16),
712
+ child: Text(
713
+ 'Watched Folders',
714
+ style: Theme.of(context).textTheme.titleLarge,
715
+ ),
716
+ ),
717
+ // Debug overlay shows selected folder
718
+ if (_selectedFolder != null)
719
+ Padding(
720
+ padding: const EdgeInsets.symmetric(horizontal: 16),
721
+ child: Align(
722
+ alignment: Alignment.centerLeft,
723
+ child: Text(
724
+ 'Debug: Selected base = $_selectedFolder',
725
+ style: Theme.of(context).textTheme.bodySmall
726
+ ?.copyWith(color: Colors.grey[600]),
727
+ maxLines: 2,
728
+ overflow: TextOverflow.ellipsis,
729
+ ),
730
+ ),
731
+ ),
732
+ const Divider(),
733
+ Expanded(
734
+ child: _folders.isEmpty
735
+ ? const Center(child: Text('No watched folders'))
736
+ : ListView.builder(
737
+ itemCount: _folders.length,
738
+ itemBuilder: (context, index) {
739
+ final folder = _folders[index];
740
+ final isSelected =
741
+ _selectedFolder == folder['path'];
742
+ return ListTile(
743
+ selected: isSelected,
744
+ leading: const Icon(Icons.folder),
745
+ title: Text(
746
+ _formatFolderName(
747
+ folder['name'] as String,
748
+ ),
749
+ ),
750
+ subtitle: Text(
751
+ '${folder['image_count']} images',
752
+ ),
753
+ onTap: () => _loadFolderImages(
754
+ folder['path'] as String,
755
+ ),
756
+ );
757
+ },
758
+ ),
759
+ ),
760
+ ],
761
+ ),
762
+ ),
763
+ ),
764
+ // Image grid
765
+ Expanded(
766
+ child: _selectedFolder == null
767
+ ? Center(
768
+ child: Column(
769
+ mainAxisAlignment: MainAxisAlignment.center,
770
+ children: [
771
+ Icon(
772
+ Icons.photo_library_outlined,
773
+ size: 64,
774
+ color: Colors.grey[400],
775
+ ),
776
+ const SizedBox(height: 16),
777
+ Text(
778
+ 'Select a folder to view images',
779
+ style: Theme.of(context).textTheme.titleMedium,
780
+ ),
781
+ ],
782
+ ),
783
+ )
784
+ : Column(
785
+ children: [
786
+ // Toolbar
787
+ Container(
788
+ padding: const EdgeInsets.symmetric(
789
+ horizontal: 16,
790
+ vertical: 8,
791
+ ),
792
+ color: Theme.of(
793
+ context,
794
+ ).colorScheme.surfaceContainerHighest,
795
+ child: Row(
796
+ children: [
797
+ Checkbox(
798
+ value: _selectAll,
799
+ onChanged: (value) => _toggleSelectAll(),
800
+ ),
801
+ const SizedBox(width: 8),
802
+ Text(
803
+ _selectAll ? 'Deselect All' : 'Select All',
804
+ ),
805
+ const Spacer(),
806
+ const Text('Size:'),
807
+ const SizedBox(width: 8),
808
+ DropdownButton<ImageSize>(
809
+ value: _imageSize,
810
+ underline: Container(),
811
+ items: const [
812
+ DropdownMenuItem(
813
+ value: ImageSize.small,
814
+ child: Text('Small'),
815
+ ),
816
+ DropdownMenuItem(
817
+ value: ImageSize.large,
818
+ child: Text('Large'),
819
+ ),
820
+ DropdownMenuItem(
821
+ value: ImageSize.full,
822
+ child: Text('Full'),
823
+ ),
824
+ ],
825
+ onChanged: (value) {
826
+ if (value != null) {
827
+ setState(() {
828
+ _imageSize = value;
829
+ });
830
+ _saveSizePreference(value);
831
+ }
832
+ },
833
+ ),
834
+ const SizedBox(width: 16),
835
+ const Text('Group by:'),
836
+ const SizedBox(width: 8),
837
+ DropdownButton<ImageGrouping>(
838
+ value: _grouping,
839
+ underline: Container(),
840
+ items: const [
841
+ DropdownMenuItem(
842
+ value: ImageGrouping.none,
843
+ child: Text('None'),
844
+ ),
845
+ DropdownMenuItem(
846
+ value: ImageGrouping.day,
847
+ child: Text('Day'),
848
+ ),
849
+ DropdownMenuItem(
850
+ value: ImageGrouping.week,
851
+ child: Text('Week'),
852
+ ),
853
+ ],
854
+ onChanged: (value) {
855
+ if (value != null) {
856
+ setState(() {
857
+ _grouping = value;
858
+ });
859
+ _saveGroupingPreference(value);
860
+ }
861
+ },
862
+ ),
863
+ const SizedBox(width: 16),
864
+ Text('${_images.length} images'),
865
+ ],
866
+ ),
867
+ ),
868
+ // Image grid with grouping
869
+ Expanded(
870
+ child: _isLoading
871
+ ? const Center(
872
+ child: CircularProgressIndicator(),
873
+ )
874
+ : _images.isEmpty
875
+ ? const Center(
876
+ child: Text('No images in this folder'),
877
+ )
878
+ : CustomScrollView(
879
+ slivers: [
880
+ SliverPadding(
881
+ padding: const EdgeInsets.all(16),
882
+ sliver: _imageSize == ImageSize.small
883
+ ? SliverGrid(
884
+ gridDelegate:
885
+ const SliverGridDelegateWithMaxCrossAxisExtent(
886
+ maxCrossAxisExtent: 250,
887
+ childAspectRatio: 0.8,
888
+ crossAxisSpacing: 16,
889
+ mainAxisSpacing: 16,
890
+ ),
891
+ delegate: SliverChildBuilderDelegate(
892
+ (context, index) {
893
+ final item =
894
+ displayImages[index];
895
+
896
+ // Skip headers in grid view
897
+ if (item['is_header'] ==
898
+ true) {
899
+ return const SizedBox.shrink();
900
+ }
901
+
902
+ return _buildImageItem(
903
+ context,
904
+ apiService,
905
+ item,
906
+ );
907
+ },
908
+ childCount:
909
+ displayImages.length,
910
+ ),
911
+ )
912
+ : SliverList(
913
+ delegate: SliverChildBuilderDelegate(
914
+ (context, index) {
915
+ final item =
916
+ displayImages[index];
917
+
918
+ // Check if this is a header
919
+ if (item['is_header'] ==
920
+ true) {
921
+ final groupImages =
922
+ item['group_images']
923
+ as List<
924
+ Map<
925
+ String,
926
+ dynamic
927
+ >
928
+ >;
929
+ final allSelected =
930
+ _areAllImagesInGroupSelected(
931
+ groupImages,
932
+ );
933
+
934
+ return Padding(
935
+ padding:
936
+ const EdgeInsets.only(
937
+ top: 16,
938
+ bottom: 8,
939
+ ),
940
+ child: Row(
941
+ children: [
942
+ Expanded(
943
+ child: Text(
944
+ item['header_text']
945
+ as String,
946
+ style: Theme.of(context)
947
+ .textTheme
948
+ .titleMedium
949
+ ?.copyWith(
950
+ fontWeight:
951
+ FontWeight.bold,
952
+ ),
953
+ ),
954
+ ),
955
+ Container(
956
+ padding:
957
+ const EdgeInsets.symmetric(
958
+ horizontal:
959
+ 12,
960
+ vertical:
961
+ 4,
962
+ ),
963
+ decoration: BoxDecoration(
964
+ color: Theme.of(
965
+ context,
966
+ ).colorScheme.primaryContainer,
967
+ borderRadius:
968
+ BorderRadius.circular(
969
+ 12,
970
+ ),
971
+ ),
972
+ child: Text(
973
+ '${item['image_count']} images',
974
+ style: Theme.of(context)
975
+ .textTheme
976
+ .bodySmall
977
+ ?.copyWith(
978
+ color: Theme.of(
979
+ context,
980
+ ).colorScheme.onPrimaryContainer,
981
+ ),
982
+ ),
983
+ ),
984
+ const SizedBox(
985
+ width: 8,
986
+ ),
987
+ OutlinedButton.icon(
988
+ onPressed: () {
989
+ if (allSelected) {
990
+ _deselectAllInGroup(
991
+ groupImages,
992
+ );
993
+ } else {
994
+ _selectAllInGroup(
995
+ groupImages,
996
+ );
997
+ }
998
+ },
999
+ icon: Icon(
1000
+ allSelected
1001
+ ? Icons
1002
+ .check_box
1003
+ : Icons
1004
+ .check_box_outline_blank,
1005
+ size: 18,
1006
+ ),
1007
+ label: Text(
1008
+ allSelected
1009
+ ? 'Deselect All'
1010
+ : 'Select All',
1011
+ ),
1012
+ style: OutlinedButton.styleFrom(
1013
+ padding:
1014
+ const EdgeInsets.symmetric(
1015
+ horizontal:
1016
+ 12,
1017
+ vertical:
1018
+ 8,
1019
+ ),
1020
+ visualDensity:
1021
+ VisualDensity
1022
+ .compact,
1023
+ ),
1024
+ ),
1025
+ ],
1026
+ ),
1027
+ );
1028
+ }
1029
+
1030
+ // Regular image item
1031
+ return Padding(
1032
+ padding:
1033
+ const EdgeInsets.only(
1034
+ bottom: 16,
1035
+ ),
1036
+ child: _buildImageItem(
1037
+ context,
1038
+ apiService,
1039
+ item,
1040
+ ),
1041
+ );
1042
+ },
1043
+ childCount:
1044
+ displayImages.length,
1045
+ ),
1046
+ ),
1047
+ ),
1048
+ ],
1049
+ ),
1050
+ ),
1051
+ ],
1052
+ ),
1053
+ ),
1054
+ ],
1055
+ ),
1056
+ floatingActionButton: _isDownloading
1057
+ ? const FloatingActionButton(
1058
+ onPressed: null,
1059
+ child: CircularProgressIndicator(),
1060
+ )
1061
+ : null,
1062
+ );
1063
+ }
1064
+ }
1065
+
1066
+ /// A widget that displays an image with automatic retry capability on error
1067
+ class _RetryableImage extends StatefulWidget {
1068
+ final String photoUrl;
1069
+ final BoxFit fit;
1070
+
1071
+ const _RetryableImage({required this.photoUrl, required this.fit});
1072
+
1073
+ @override
1074
+ State<_RetryableImage> createState() => _RetryableImageState();
1075
+ }
1076
+
1077
+ class _RetryableImageState extends State<_RetryableImage> {
1078
+ int _retryCount = 0;
1079
+ static const int _maxRetries = 3;
1080
+ static const List<int> _retryDelays = [500, 1000, 2000]; // milliseconds
1081
+ bool _hasScheduledRetry = false;
1082
+ int _cacheBuster = DateTime.now().millisecondsSinceEpoch;
1083
+
1084
+ @override
1085
+ void initState() {
1086
+ super.initState();
1087
+ developer.log(
1088
+ 'Image widget created: ${widget.photoUrl}',
1089
+ name: 'RetryableImage.initState',
1090
+ );
1091
+ }
1092
+
1093
+ @override
1094
+ void dispose() {
1095
+ developer.log(
1096
+ 'Image widget disposed (retries: $_retryCount): ${widget.photoUrl}',
1097
+ name: 'RetryableImage.dispose',
1098
+ );
1099
+ super.dispose();
1100
+ }
1101
+
1102
+ void _scheduleRetry() {
1103
+ if (_retryCount < _maxRetries && !_hasScheduledRetry) {
1104
+ _hasScheduledRetry = true;
1105
+ final delay = _retryDelays[_retryCount];
1106
+
1107
+ developer.log(
1108
+ 'Scheduling retry #${_retryCount + 1} in ${delay}ms: ${widget.photoUrl}',
1109
+ name: 'RetryableImage.scheduleRetry',
1110
+ );
1111
+
1112
+ Future.delayed(Duration(milliseconds: delay), () {
1113
+ if (mounted) {
1114
+ developer.log(
1115
+ 'Executing retry #${_retryCount + 1}: ${widget.photoUrl}',
1116
+ name: 'RetryableImage.scheduleRetry',
1117
+ );
1118
+ setState(() {
1119
+ _retryCount++;
1120
+ _hasScheduledRetry = false;
1121
+ _cacheBuster = DateTime.now().millisecondsSinceEpoch;
1122
+ });
1123
+ } else {
1124
+ developer.log(
1125
+ 'Widget unmounted, skipping retry: ${widget.photoUrl}',
1126
+ name: 'RetryableImage.scheduleRetry',
1127
+ );
1128
+ }
1129
+ });
1130
+ } else {
1131
+ developer.log(
1132
+ 'Retry not scheduled (count: $_retryCount, hasScheduled: $_hasScheduledRetry): ${widget.photoUrl}',
1133
+ name: 'RetryableImage.scheduleRetry',
1134
+ );
1135
+ }
1136
+ }
1137
+
1138
+ void _manualRetry() {
1139
+ developer.log(
1140
+ 'Manual retry triggered: ${widget.photoUrl}',
1141
+ name: 'RetryableImage.manualRetry',
1142
+ );
1143
+ setState(() {
1144
+ _retryCount = 0;
1145
+ _hasScheduledRetry = false;
1146
+ _cacheBuster = DateTime.now().millisecondsSinceEpoch;
1147
+ });
1148
+ }
1149
+
1150
+ @override
1151
+ Widget build(BuildContext context) {
1152
+ // Use cache buster as query parameter to force reload
1153
+ final separator = widget.photoUrl.contains('?') ? '&' : '?';
1154
+ final imageUrl = '${widget.photoUrl}${separator}_cb=$_cacheBuster';
1155
+
1156
+ developer.log(
1157
+ 'Building image (retry: $_retryCount, cb: $_cacheBuster): $imageUrl',
1158
+ name: 'RetryableImage.build',
1159
+ );
1160
+
1161
+ return Image.network(
1162
+ imageUrl,
1163
+ fit: widget.fit,
1164
+ loadingBuilder: (context, child, loadingProgress) {
1165
+ if (loadingProgress == null) {
1166
+ developer.log(
1167
+ 'Image loaded successfully (after $_retryCount retries): ${widget.photoUrl}',
1168
+ name: 'RetryableImage.loadingBuilder',
1169
+ );
1170
+ return child;
1171
+ }
1172
+ developer.log(
1173
+ 'Image loading (${loadingProgress.cumulativeBytesLoaded}/${loadingProgress.expectedTotalBytes ?? '?'}): ${widget.photoUrl}',
1174
+ name: 'RetryableImage.loadingBuilder',
1175
+ );
1176
+ return Center(
1177
+ child: CircularProgressIndicator(
1178
+ value: loadingProgress.expectedTotalBytes != null
1179
+ ? loadingProgress.cumulativeBytesLoaded /
1180
+ loadingProgress.expectedTotalBytes!
1181
+ : null,
1182
+ ),
1183
+ );
1184
+ },
1185
+ errorBuilder: (context, error, stackTrace) {
1186
+ // Log the error for debugging
1187
+ developer.log(
1188
+ 'Image load FAILED (attempt ${_retryCount + 1}/$_maxRetries): ${widget.photoUrl}',
1189
+ name: 'RetryableImage.errorBuilder',
1190
+ error: error,
1191
+ stackTrace: stackTrace,
1192
+ );
1193
+
1194
+ // Schedule automatic retry only if we haven't exhausted retries
1195
+ if (_retryCount < _maxRetries) {
1196
+ developer.log(
1197
+ 'Will schedule retry (hasScheduled: $_hasScheduledRetry): ${widget.photoUrl}',
1198
+ name: 'RetryableImage.errorBuilder',
1199
+ );
1200
+ WidgetsBinding.instance.addPostFrameCallback((_) {
1201
+ _scheduleRetry();
1202
+ });
1203
+ } else {
1204
+ developer.log(
1205
+ 'Max retries reached, showing manual retry button: ${widget.photoUrl}',
1206
+ name: 'RetryableImage.errorBuilder',
1207
+ );
1208
+ }
1209
+
1210
+ return Center(
1211
+ child: Column(
1212
+ mainAxisAlignment: MainAxisAlignment.center,
1213
+ children: [
1214
+ const Icon(Icons.broken_image, size: 48, color: Colors.grey),
1215
+ const SizedBox(height: 8),
1216
+ Text(
1217
+ _retryCount < _maxRetries
1218
+ ? 'Retrying... (${_retryCount + 1}/$_maxRetries)'
1219
+ : 'Failed to load',
1220
+ style: Theme.of(context).textTheme.bodySmall,
1221
+ ),
1222
+ const SizedBox(height: 8),
1223
+ if (_retryCount < _maxRetries)
1224
+ const SizedBox(
1225
+ width: 16,
1226
+ height: 16,
1227
+ child: CircularProgressIndicator(strokeWidth: 2),
1228
+ )
1229
+ else
1230
+ TextButton.icon(
1231
+ onPressed: _manualRetry,
1232
+ icon: const Icon(Icons.refresh, size: 16),
1233
+ label: const Text('Retry'),
1234
+ style: TextButton.styleFrom(
1235
+ padding: const EdgeInsets.symmetric(
1236
+ horizontal: 8,
1237
+ vertical: 4,
1238
+ ),
1239
+ visualDensity: VisualDensity.compact,
1240
+ ),
1241
+ ),
1242
+ ],
1243
+ ),
1244
+ );
1245
+ },
1246
+ );
1247
+ }
1248
+ }