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,555 @@
1
+ import 'package:flutter_test/flutter_test.dart';
2
+ import 'package:intl/intl.dart';
3
+
4
+ // Test helper to simulate the grouping logic
5
+ enum ImageGrouping { none, day, week }
6
+
7
+ List<Map<String, dynamic>> groupImages(
8
+ List<Map<String, dynamic>> images,
9
+ ImageGrouping grouping,
10
+ ) {
11
+ if (grouping == ImageGrouping.none) {
12
+ return images;
13
+ }
14
+
15
+ // Group images by date
16
+ final Map<String, List<Map<String, dynamic>>> grouped = {};
17
+
18
+ for (final image in images) {
19
+ final timestamp = image['modified'];
20
+ if (timestamp == null) continue;
21
+
22
+ // Handle both int and double (st_mtime is a float)
23
+ final timestampInt = (timestamp is int)
24
+ ? timestamp
25
+ : (timestamp as num).round();
26
+ final date = DateTime.fromMillisecondsSinceEpoch(timestampInt * 1000);
27
+ String groupKey;
28
+
29
+ if (grouping == ImageGrouping.day) {
30
+ // Group by day
31
+ groupKey = DateFormat('yyyy-MM-dd EEEE').format(date);
32
+ } else {
33
+ // Group by week
34
+ final weekStart = date.subtract(Duration(days: date.weekday - 1));
35
+ final weekEnd = weekStart.add(const Duration(days: 6));
36
+ groupKey =
37
+ '${DateFormat('MMM d').format(weekStart)} - ${DateFormat('MMM d, yyyy').format(weekEnd)}';
38
+ }
39
+
40
+ if (!grouped.containsKey(groupKey)) {
41
+ grouped[groupKey] = [];
42
+ }
43
+ grouped[groupKey]!.add(image);
44
+ }
45
+
46
+ // Sort groups by date (most recent first)
47
+ final sortedKeys = grouped.keys.toList()
48
+ ..sort((a, b) {
49
+ // Get the first image from each group to compare dates
50
+ final aTimestamp = grouped[a]!.first['modified'];
51
+ final aTimestampInt = (aTimestamp is int)
52
+ ? aTimestamp
53
+ : (aTimestamp as num).round();
54
+ final aDate = DateTime.fromMillisecondsSinceEpoch(aTimestampInt * 1000);
55
+
56
+ final bTimestamp = grouped[b]!.first['modified'];
57
+ final bTimestampInt = (bTimestamp is int)
58
+ ? bTimestamp
59
+ : (bTimestamp as num).round();
60
+ final bDate = DateTime.fromMillisecondsSinceEpoch(bTimestampInt * 1000);
61
+
62
+ return bDate.compareTo(aDate); // Descending order
63
+ });
64
+
65
+ // Flatten the grouped images back into a list with headers
66
+ final List<Map<String, dynamic>> result = [];
67
+ for (final key in sortedKeys) {
68
+ // Add a header item with reference to group images
69
+ result.add({
70
+ 'is_header': true,
71
+ 'header_text': key,
72
+ 'image_count': grouped[key]!.length,
73
+ 'group_images': grouped[key]!, // Include images for group operations
74
+ });
75
+ // Add all images in this group
76
+ result.addAll(grouped[key]!);
77
+ }
78
+
79
+ return result;
80
+ }
81
+
82
+ void main() {
83
+ group('Image Grouping Tests', () {
84
+ late List<Map<String, dynamic>> sampleImages;
85
+
86
+ setUp(() {
87
+ // Create sample images with timestamps
88
+ // Using specific dates for predictable testing
89
+ sampleImages = [
90
+ {
91
+ 'path': 'img1.jpg',
92
+ 'filename': 'img1.jpg',
93
+ 'modified':
94
+ DateTime(2024, 1, 15, 10, 30).millisecondsSinceEpoch ~/
95
+ 1000, // Monday
96
+ 'size': 1024,
97
+ },
98
+ {
99
+ 'path': 'img2.jpg',
100
+ 'filename': 'img2.jpg',
101
+ 'modified':
102
+ DateTime(2024, 1, 15, 14, 20).millisecondsSinceEpoch ~/
103
+ 1000, // Same day
104
+ 'size': 2048,
105
+ },
106
+ {
107
+ 'path': 'img3.jpg',
108
+ 'filename': 'img3.jpg',
109
+ 'modified':
110
+ DateTime(2024, 1, 16, 9, 15).millisecondsSinceEpoch ~/
111
+ 1000, // Tuesday
112
+ 'size': 1536,
113
+ },
114
+ {
115
+ 'path': 'img4.jpg',
116
+ 'filename': 'img4.jpg',
117
+ 'modified':
118
+ DateTime(2024, 1, 22, 11, 45).millisecondsSinceEpoch ~/
119
+ 1000, // Next week Monday
120
+ 'size': 3072,
121
+ },
122
+ {
123
+ 'path': 'img5.jpg',
124
+ 'filename': 'img5.jpg',
125
+ 'modified':
126
+ DateTime(2024, 1, 23, 16, 0).millisecondsSinceEpoch ~/
127
+ 1000, // Next week Tuesday
128
+ 'size': 2560,
129
+ },
130
+ ];
131
+ });
132
+
133
+ test('No grouping returns images as-is', () {
134
+ final result = groupImages(sampleImages, ImageGrouping.none);
135
+
136
+ expect(result.length, equals(5));
137
+ expect(result, equals(sampleImages));
138
+ expect(result.where((img) => img['is_header'] == true).isEmpty, isTrue);
139
+ });
140
+
141
+ test('Group by day creates correct day headers', () {
142
+ final result = groupImages(sampleImages, ImageGrouping.day);
143
+
144
+ // Should have 4 groups (Jan 15, Jan 16, Jan 22, Jan 23) = 4 headers + 5 images = 9 items
145
+ expect(result.length, equals(9));
146
+
147
+ // Count headers
148
+ final headers = result
149
+ .where((item) => item['is_header'] == true)
150
+ .toList();
151
+ expect(headers.length, equals(4));
152
+
153
+ // Verify first header (most recent date - Jan 23)
154
+ expect(headers[0]['header_text'], contains('2024-01-23'));
155
+ expect(headers[0]['image_count'], equals(1));
156
+
157
+ // Verify second header (Jan 22)
158
+ expect(headers[1]['header_text'], contains('2024-01-22'));
159
+ expect(headers[1]['image_count'], equals(1));
160
+
161
+ // Verify third header (Jan 16)
162
+ expect(headers[2]['header_text'], contains('2024-01-16'));
163
+ expect(headers[2]['image_count'], equals(1));
164
+
165
+ // Verify fourth header (Jan 15 - has 2 images)
166
+ expect(headers[3]['header_text'], contains('2024-01-15'));
167
+ expect(headers[3]['image_count'], equals(2));
168
+ });
169
+
170
+ test('Group by day sorts most recent first', () {
171
+ final result = groupImages(sampleImages, ImageGrouping.day);
172
+
173
+ // Find all headers
174
+ final headerIndices = <int>[];
175
+ for (int i = 0; i < result.length; i++) {
176
+ if (result[i]['is_header'] == true) {
177
+ headerIndices.add(i);
178
+ }
179
+ }
180
+
181
+ // Headers should be in descending order by date
182
+ expect(headerIndices.length, equals(4));
183
+
184
+ // Most recent should be first
185
+ expect(result[headerIndices[0]]['header_text'], contains('2024-01-23'));
186
+ expect(result[headerIndices[1]]['header_text'], contains('2024-01-22'));
187
+ expect(result[headerIndices[2]]['header_text'], contains('2024-01-16'));
188
+ expect(result[headerIndices[3]]['header_text'], contains('2024-01-15'));
189
+ });
190
+
191
+ test('Group by week creates correct week headers', () {
192
+ final result = groupImages(sampleImages, ImageGrouping.week);
193
+
194
+ // Should have 2 weeks = 2 headers + 5 images = 7 items
195
+ expect(result.length, equals(7));
196
+
197
+ // Count headers
198
+ final headers = result
199
+ .where((item) => item['is_header'] == true)
200
+ .toList();
201
+ expect(headers.length, equals(2));
202
+
203
+ // Week 1: Jan 15-21 (Monday to Sunday) - contains Jan 15 and Jan 16
204
+ // Week 2: Jan 22-28 (Monday to Sunday) - contains Jan 22 and Jan 23
205
+
206
+ // First header should be the most recent week
207
+ expect(headers[0]['header_text'], contains('Jan 22'));
208
+ expect(headers[0]['header_text'], contains('Jan 28'));
209
+ expect(headers[0]['image_count'], equals(2));
210
+
211
+ // Second header should be the earlier week
212
+ expect(headers[1]['header_text'], contains('Jan 15'));
213
+ expect(headers[1]['header_text'], contains('Jan 21'));
214
+ expect(headers[1]['image_count'], equals(3));
215
+ });
216
+
217
+ test('Group by week handles images in same week correctly', () {
218
+ // Create images all in the same week
219
+ final sameWeekImages = [
220
+ {
221
+ 'path': 'mon.jpg',
222
+ 'filename': 'mon.jpg',
223
+ 'modified':
224
+ DateTime(2024, 1, 15).millisecondsSinceEpoch ~/ 1000, // Monday
225
+ 'size': 1024,
226
+ },
227
+ {
228
+ 'path': 'wed.jpg',
229
+ 'filename': 'wed.jpg',
230
+ 'modified':
231
+ DateTime(2024, 1, 17).millisecondsSinceEpoch ~/ 1000, // Wednesday
232
+ 'size': 1024,
233
+ },
234
+ {
235
+ 'path': 'sun.jpg',
236
+ 'filename': 'sun.jpg',
237
+ 'modified':
238
+ DateTime(2024, 1, 21).millisecondsSinceEpoch ~/ 1000, // Sunday
239
+ 'size': 1024,
240
+ },
241
+ ];
242
+
243
+ final result = groupImages(sameWeekImages, ImageGrouping.week);
244
+
245
+ // Should have 1 header + 3 images = 4 items
246
+ expect(result.length, equals(4));
247
+
248
+ final headers = result
249
+ .where((item) => item['is_header'] == true)
250
+ .toList();
251
+ expect(headers.length, equals(1));
252
+ expect(headers[0]['image_count'], equals(3));
253
+ });
254
+
255
+ test('Images within group maintain original order', () {
256
+ final result = groupImages(sampleImages, ImageGrouping.day);
257
+
258
+ // Find the Jan 15 group (should have 2 images)
259
+ int headerIndex = -1;
260
+ for (int i = 0; i < result.length; i++) {
261
+ if (result[i]['is_header'] == true &&
262
+ result[i]['header_text'].toString().contains('2024-01-15')) {
263
+ headerIndex = i;
264
+ break;
265
+ }
266
+ }
267
+
268
+ expect(headerIndex, greaterThanOrEqualTo(0));
269
+
270
+ // The two images after this header should be img1 and img2
271
+ final firstImage = result[headerIndex + 1];
272
+ final secondImage = result[headerIndex + 2];
273
+
274
+ expect(firstImage['filename'], equals('img1.jpg'));
275
+ expect(secondImage['filename'], equals('img2.jpg'));
276
+ });
277
+
278
+ test('Handles images without timestamps gracefully', () {
279
+ final imagesWithNull = [
280
+ {
281
+ 'path': 'img1.jpg',
282
+ 'filename': 'img1.jpg',
283
+ 'modified': DateTime(2024, 1, 15).millisecondsSinceEpoch ~/ 1000,
284
+ 'size': 1024,
285
+ },
286
+ {
287
+ 'path': 'img2.jpg',
288
+ 'filename': 'img2.jpg',
289
+ 'modified': null, // No timestamp
290
+ 'size': 2048,
291
+ },
292
+ {
293
+ 'path': 'img3.jpg',
294
+ 'filename': 'img3.jpg',
295
+ 'modified': DateTime(2024, 1, 16).millisecondsSinceEpoch ~/ 1000,
296
+ 'size': 1536,
297
+ },
298
+ ];
299
+
300
+ final result = groupImages(imagesWithNull, ImageGrouping.day);
301
+
302
+ // Should only group the images with valid timestamps (2 headers + 2 images)
303
+ expect(result.length, equals(4));
304
+
305
+ final headers = result
306
+ .where((item) => item['is_header'] == true)
307
+ .toList();
308
+ expect(headers.length, equals(2));
309
+
310
+ // Verify null timestamp image is not included
311
+ final allImagePaths = result
312
+ .where((item) => item['is_header'] != true)
313
+ .map((item) => item['path'])
314
+ .toList();
315
+ expect(allImagePaths.contains('img2.jpg'), isFalse);
316
+ });
317
+
318
+ test('Empty image list returns empty result', () {
319
+ final result = groupImages([], ImageGrouping.day);
320
+ expect(result.isEmpty, isTrue);
321
+
322
+ final resultWeek = groupImages([], ImageGrouping.week);
323
+ expect(resultWeek.isEmpty, isTrue);
324
+ });
325
+
326
+ test('Single image creates single group', () {
327
+ final singleImage = [
328
+ {
329
+ 'path': 'single.jpg',
330
+ 'filename': 'single.jpg',
331
+ 'modified': DateTime(2024, 1, 15).millisecondsSinceEpoch ~/ 1000,
332
+ 'size': 1024,
333
+ },
334
+ ];
335
+
336
+ final result = groupImages(singleImage, ImageGrouping.day);
337
+ expect(result.length, equals(2)); // 1 header + 1 image
338
+
339
+ final headers = result
340
+ .where((item) => item['is_header'] == true)
341
+ .toList();
342
+ expect(headers.length, equals(1));
343
+ expect(headers[0]['image_count'], equals(1));
344
+ });
345
+
346
+ test('Day grouping includes day of week in header', () {
347
+ final result = groupImages(sampleImages, ImageGrouping.day);
348
+
349
+ final headers = result
350
+ .where((item) => item['is_header'] == true)
351
+ .toList();
352
+
353
+ // Jan 15, 2024 was a Monday
354
+ final jan15Header = headers.firstWhere(
355
+ (h) => h['header_text'].toString().contains('2024-01-15'),
356
+ );
357
+ expect(jan15Header['header_text'], contains('Monday'));
358
+
359
+ // Jan 16, 2024 was a Tuesday
360
+ final jan16Header = headers.firstWhere(
361
+ (h) => h['header_text'].toString().contains('2024-01-16'),
362
+ );
363
+ expect(jan16Header['header_text'], contains('Tuesday'));
364
+ });
365
+
366
+ test('Week grouping handles year boundary correctly', () {
367
+ final yearBoundaryImages = [
368
+ {
369
+ 'path': 'dec31.jpg',
370
+ 'filename': 'dec31.jpg',
371
+ 'modified':
372
+ DateTime(2023, 12, 31).millisecondsSinceEpoch ~/ 1000, // Sunday
373
+ 'size': 1024,
374
+ },
375
+ {
376
+ 'path': 'jan1.jpg',
377
+ 'filename': 'jan1.jpg',
378
+ 'modified':
379
+ DateTime(2024, 1, 1).millisecondsSinceEpoch ~/ 1000, // Monday
380
+ 'size': 1024,
381
+ },
382
+ ];
383
+
384
+ final result = groupImages(yearBoundaryImages, ImageGrouping.week);
385
+
386
+ // These should be in different weeks
387
+ final headers = result
388
+ .where((item) => item['is_header'] == true)
389
+ .toList();
390
+ expect(headers.length, equals(2));
391
+ });
392
+
393
+ test('Groups are sorted with most recent first for both day and week', () {
394
+ // Test with deliberately unsorted input
395
+ final unsortedImages = [
396
+ {
397
+ 'path': 'old.jpg',
398
+ 'filename': 'old.jpg',
399
+ 'modified': DateTime(2024, 1, 1).millisecondsSinceEpoch ~/ 1000,
400
+ 'size': 1024,
401
+ },
402
+ {
403
+ 'path': 'new.jpg',
404
+ 'filename': 'new.jpg',
405
+ 'modified': DateTime(2024, 1, 31).millisecondsSinceEpoch ~/ 1000,
406
+ 'size': 1024,
407
+ },
408
+ {
409
+ 'path': 'middle.jpg',
410
+ 'filename': 'middle.jpg',
411
+ 'modified': DateTime(2024, 1, 15).millisecondsSinceEpoch ~/ 1000,
412
+ 'size': 1024,
413
+ },
414
+ ];
415
+
416
+ // Test day grouping
417
+ final dayResult = groupImages(unsortedImages, ImageGrouping.day);
418
+ final dayHeaders = dayResult
419
+ .where((item) => item['is_header'] == true)
420
+ .toList();
421
+
422
+ expect(
423
+ dayHeaders[0]['header_text'],
424
+ contains('2024-01-31'),
425
+ ); // Most recent
426
+ expect(dayHeaders[1]['header_text'], contains('2024-01-15')); // Middle
427
+ expect(dayHeaders[2]['header_text'], contains('2024-01-01')); // Oldest
428
+
429
+ // Test week grouping
430
+ final weekResult = groupImages(unsortedImages, ImageGrouping.week);
431
+ final weekHeaders = weekResult
432
+ .where((item) => item['is_header'] == true)
433
+ .toList();
434
+
435
+ // Should still be ordered most recent first
436
+ expect(weekHeaders.length, greaterThan(0));
437
+
438
+ // First header should contain the most recent date range
439
+ final firstHeaderText = weekHeaders[0]['header_text'] as String;
440
+ expect(firstHeaderText, contains('Jan'));
441
+ });
442
+
443
+ test('Handles float timestamps from backend (st_mtime)', () {
444
+ // Backend returns st_mtime as float, not int
445
+ final imagesWithFloatTimestamps = [
446
+ {
447
+ 'path': 'img1.jpg',
448
+ 'filename': 'img1.jpg',
449
+ 'modified': 1705320600.5, // Float with fractional seconds
450
+ 'size': 1024,
451
+ },
452
+ {
453
+ 'path': 'img2.jpg',
454
+ 'filename': 'img2.jpg',
455
+ 'modified': 1705320650.123, // Float with more precision
456
+ 'size': 2048,
457
+ },
458
+ {
459
+ 'path': 'img3.jpg',
460
+ 'filename': 'img3.jpg',
461
+ 'modified': 1705407000.0, // Float that's a whole number
462
+ 'size': 1536,
463
+ },
464
+ ];
465
+
466
+ // Should group successfully without errors
467
+ final resultDay = groupImages(
468
+ imagesWithFloatTimestamps,
469
+ ImageGrouping.day,
470
+ );
471
+ expect(resultDay.length, greaterThan(0));
472
+
473
+ final headersDay = resultDay
474
+ .where((item) => item['is_header'] == true)
475
+ .toList();
476
+ expect(headersDay.length, greaterThan(0));
477
+
478
+ // Should also work with week grouping
479
+ final resultWeek = groupImages(
480
+ imagesWithFloatTimestamps,
481
+ ImageGrouping.week,
482
+ );
483
+ expect(resultWeek.length, greaterThan(0));
484
+
485
+ final headersWeek = resultWeek
486
+ .where((item) => item['is_header'] == true)
487
+ .toList();
488
+ expect(headersWeek.length, greaterThan(0));
489
+ });
490
+
491
+ test('Handles mixed int and float timestamps', () {
492
+ final mixedImages = [
493
+ {
494
+ 'path': 'int_timestamp.jpg',
495
+ 'filename': 'int_timestamp.jpg',
496
+ 'modified': 1705320600, // Integer timestamp
497
+ 'size': 1024,
498
+ },
499
+ {
500
+ 'path': 'float_timestamp.jpg',
501
+ 'filename': 'float_timestamp.jpg',
502
+ 'modified': 1705320650.5, // Float timestamp
503
+ 'size': 2048,
504
+ },
505
+ ];
506
+
507
+ // Should handle both types without errors
508
+ final result = groupImages(mixedImages, ImageGrouping.day);
509
+ expect(result.length, greaterThan(0));
510
+
511
+ // Both images should be included
512
+ final images = result.where((item) => item['is_header'] != true).toList();
513
+ expect(images.length, equals(2));
514
+ });
515
+
516
+ test('Group headers include group_images field', () {
517
+ final result = groupImages(sampleImages, ImageGrouping.day);
518
+
519
+ // Find all headers
520
+ final headers = result
521
+ .where((item) => item['is_header'] == true)
522
+ .toList();
523
+
524
+ // Each header should have group_images field
525
+ for (final header in headers) {
526
+ expect(header.containsKey('group_images'), isTrue);
527
+ expect(header['group_images'], isNotNull);
528
+ expect(header['group_images'], isA<List<Map<String, dynamic>>>());
529
+
530
+ // Verify group_images contains the right number of images
531
+ final groupImages =
532
+ header['group_images'] as List<Map<String, dynamic>>;
533
+ expect(groupImages.length, equals(header['image_count']));
534
+ }
535
+ });
536
+
537
+ test('Group images can be used for selection operations', () {
538
+ final result = groupImages(sampleImages, ImageGrouping.day);
539
+
540
+ // Get first group header
541
+ final firstHeader = result.firstWhere(
542
+ (item) => item['is_header'] == true,
543
+ );
544
+ final imagesInGroup =
545
+ firstHeader['group_images'] as List<Map<String, dynamic>>;
546
+
547
+ // Verify all images in group_images are actual image objects (not headers)
548
+ for (final image in imagesInGroup) {
549
+ expect(image['is_header'], isNot(true));
550
+ expect(image.containsKey('filename'), isTrue);
551
+ expect(image.containsKey('path'), isTrue);
552
+ }
553
+ });
554
+ });
555
+ }