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.
- pumaguard/presets.py +1 -0
- pumaguard/pumaguard-ui/.last_build_id +1 -1
- pumaguard/pumaguard-ui/assets/NOTICES +621 -71
- pumaguard/pumaguard-ui/assets/fonts/MaterialIcons-Regular.otf +0 -0
- pumaguard/pumaguard-ui/flutter_bootstrap.js +1 -1
- pumaguard/pumaguard-ui/main.dart.js +28869 -28787
- pumaguard/web_routes/dhcp.py +311 -54
- pumaguard/web_routes/diagnostics.py +6 -0
- pumaguard/web_routes/settings.py +13 -0
- pumaguard/web_ui.py +29 -0
- {pumaguard-21.post29.dist-info → pumaguard-21.post83.dist-info}/METADATA +1 -1
- pumaguard-21.post83.dist-info/RECORD +254 -0
- pumaguard-ui/.gitignore +48 -0
- pumaguard-ui/.metadata +45 -0
- pumaguard-ui/API_REFERENCE.md +717 -0
- pumaguard-ui/LICENSE +201 -0
- pumaguard-ui/Makefile +36 -0
- pumaguard-ui/README.md +371 -0
- pumaguard-ui/UI_DEVELOPMENT_CONTEXT.md +427 -0
- pumaguard-ui/analysis_options.yaml +28 -0
- pumaguard-ui/android/.gitignore +14 -0
- pumaguard-ui/android/app/build.gradle.kts +44 -0
- pumaguard-ui/android/app/src/debug/AndroidManifest.xml +7 -0
- pumaguard-ui/android/app/src/main/AndroidManifest.xml +45 -0
- pumaguard-ui/android/app/src/main/kotlin/com/example/pumaguard_ui/MainActivity.kt +5 -0
- pumaguard-ui/android/app/src/main/res/drawable/launch_background.xml +12 -0
- pumaguard-ui/android/app/src/main/res/drawable-v21/launch_background.xml +12 -0
- pumaguard-ui/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
- pumaguard-ui/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
- pumaguard-ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
- pumaguard-ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- pumaguard-ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- pumaguard-ui/android/app/src/main/res/values/styles.xml +18 -0
- pumaguard-ui/android/app/src/main/res/values-night/styles.xml +18 -0
- pumaguard-ui/android/app/src/profile/AndroidManifest.xml +7 -0
- pumaguard-ui/android/build.gradle.kts +24 -0
- pumaguard-ui/android/gradle/wrapper/gradle-wrapper.properties +5 -0
- pumaguard-ui/android/gradle.properties +2 -0
- pumaguard-ui/android/settings.gradle.kts +26 -0
- pumaguard-ui/fonts/README.md +38 -0
- pumaguard-ui/fonts/Roboto-Bold.ttf +0 -0
- pumaguard-ui/fonts/Roboto-Light.ttf +0 -0
- pumaguard-ui/fonts/Roboto-Medium.ttf +0 -0
- pumaguard-ui/fonts/Roboto-Regular.ttf +0 -0
- pumaguard-ui/fonts/RobotoMono-Bold.ttf +0 -0
- pumaguard-ui/fonts/RobotoMono-Medium.ttf +0 -0
- pumaguard-ui/fonts/RobotoMono-Regular.ttf +0 -0
- pumaguard-ui/fonts/download_fonts.sh +76 -0
- pumaguard-ui/ios/.gitignore +34 -0
- pumaguard-ui/ios/Flutter/AppFrameworkInfo.plist +26 -0
- pumaguard-ui/ios/Flutter/Debug.xcconfig +1 -0
- pumaguard-ui/ios/Flutter/Release.xcconfig +1 -0
- pumaguard-ui/ios/Runner/AppDelegate.swift +13 -0
- pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +122 -0
- pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png +0 -0
- pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png +0 -0
- pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png +0 -0
- pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png +0 -0
- pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png +0 -0
- pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png +0 -0
- pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png +0 -0
- pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png +0 -0
- pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png +0 -0
- pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png +0 -0
- pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png +0 -0
- pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png +0 -0
- pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png +0 -0
- pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png +0 -0
- pumaguard-ui/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png +0 -0
- pumaguard-ui/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +23 -0
- pumaguard-ui/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png +0 -0
- pumaguard-ui/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png +0 -0
- pumaguard-ui/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png +0 -0
- pumaguard-ui/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +5 -0
- pumaguard-ui/ios/Runner/Base.lproj/LaunchScreen.storyboard +37 -0
- pumaguard-ui/ios/Runner/Base.lproj/Main.storyboard +26 -0
- pumaguard-ui/ios/Runner/Info.plist +49 -0
- pumaguard-ui/ios/Runner/Runner-Bridging-Header.h +1 -0
- pumaguard-ui/ios/Runner.xcodeproj/project.pbxproj +616 -0
- pumaguard-ui/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- pumaguard-ui/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
- pumaguard-ui/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +8 -0
- pumaguard-ui/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +101 -0
- pumaguard-ui/ios/Runner.xcworkspace/contents.xcworkspacedata +7 -0
- pumaguard-ui/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
- pumaguard-ui/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +8 -0
- pumaguard-ui/ios/RunnerTests/RunnerTests.swift +12 -0
- pumaguard-ui/lib/main.dart +56 -0
- pumaguard-ui/lib/models/camera.dart +45 -0
- pumaguard-ui/lib/models/plug.dart +45 -0
- pumaguard-ui/lib/models/settings.dart +112 -0
- pumaguard-ui/lib/models/status.dart +58 -0
- pumaguard-ui/lib/screens/directories_screen.dart +319 -0
- pumaguard-ui/lib/screens/home_screen.dart +545 -0
- pumaguard-ui/lib/screens/image_browser_screen.dart +1248 -0
- pumaguard-ui/lib/screens/server_discovery_screen.dart +390 -0
- pumaguard-ui/lib/screens/settings_screen.dart +1162 -0
- pumaguard-ui/lib/screens/wifi_settings_screen.dart +671 -0
- pumaguard-ui/lib/services/api_service.dart +717 -0
- pumaguard-ui/lib/services/camera_events_service.dart +195 -0
- pumaguard-ui/lib/services/mdns_service.dart +4 -0
- pumaguard-ui/lib/services/mdns_service_impl.dart +282 -0
- pumaguard-ui/lib/services/mdns_service_io.dart +1 -0
- pumaguard-ui/lib/services/mdns_service_web.dart +106 -0
- pumaguard-ui/lib/utils/download_helper.dart +2 -0
- pumaguard-ui/lib/utils/download_helper_stub.dart +6 -0
- pumaguard-ui/lib/utils/download_helper_web.dart +14 -0
- pumaguard-ui/lib/utils/platform_url.dart +10 -0
- pumaguard-ui/lib/utils/platform_url_stub.dart +11 -0
- pumaguard-ui/lib/utils/platform_url_web.dart +16 -0
- pumaguard-ui/linux/.gitignore +1 -0
- pumaguard-ui/linux/CMakeLists.txt +128 -0
- pumaguard-ui/linux/flutter/CMakeLists.txt +88 -0
- pumaguard-ui/linux/flutter/generated_plugin_registrant.cc +15 -0
- pumaguard-ui/linux/flutter/generated_plugin_registrant.h +15 -0
- pumaguard-ui/linux/flutter/generated_plugins.cmake +24 -0
- pumaguard-ui/linux/runner/CMakeLists.txt +26 -0
- pumaguard-ui/linux/runner/main.cc +6 -0
- pumaguard-ui/linux/runner/my_application.cc +148 -0
- pumaguard-ui/linux/runner/my_application.h +21 -0
- pumaguard-ui/macos/.gitignore +7 -0
- pumaguard-ui/macos/Flutter/Flutter-Debug.xcconfig +1 -0
- pumaguard-ui/macos/Flutter/Flutter-Release.xcconfig +1 -0
- pumaguard-ui/macos/Flutter/GeneratedPluginRegistrant.swift +16 -0
- pumaguard-ui/macos/Runner/AppDelegate.swift +13 -0
- pumaguard-ui/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +68 -0
- pumaguard-ui/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
- pumaguard-ui/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
- pumaguard-ui/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
- pumaguard-ui/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
- pumaguard-ui/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
- pumaguard-ui/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
- pumaguard-ui/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
- pumaguard-ui/macos/Runner/Base.lproj/MainMenu.xib +343 -0
- pumaguard-ui/macos/Runner/Configs/AppInfo.xcconfig +14 -0
- pumaguard-ui/macos/Runner/Configs/Debug.xcconfig +2 -0
- pumaguard-ui/macos/Runner/Configs/Release.xcconfig +2 -0
- pumaguard-ui/macos/Runner/Configs/Warnings.xcconfig +13 -0
- pumaguard-ui/macos/Runner/DebugProfile.entitlements +12 -0
- pumaguard-ui/macos/Runner/Info.plist +32 -0
- pumaguard-ui/macos/Runner/MainFlutterWindow.swift +15 -0
- pumaguard-ui/macos/Runner/Release.entitlements +8 -0
- pumaguard-ui/macos/Runner.xcodeproj/project.pbxproj +705 -0
- pumaguard-ui/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
- pumaguard-ui/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +99 -0
- pumaguard-ui/macos/Runner.xcworkspace/contents.xcworkspacedata +7 -0
- pumaguard-ui/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
- pumaguard-ui/macos/RunnerTests/RunnerTests.swift +12 -0
- pumaguard-ui/pubspec.lock +882 -0
- pumaguard-ui/pubspec.yaml +125 -0
- pumaguard-ui/test/models/camera_test.dart +515 -0
- pumaguard-ui/test/models/plug_test.dart +499 -0
- pumaguard-ui/test/models/settings_test.dart +903 -0
- pumaguard-ui/test/models/status_test.dart +707 -0
- pumaguard-ui/test/screens/image_browser_grouping_test.dart +555 -0
- pumaguard-ui/test/services/api_service_cameras_test.dart +580 -0
- pumaguard-ui/test/services/api_service_image_browser_test.dart +512 -0
- pumaguard-ui/test/widget_test.dart.skip +38 -0
- pumaguard-ui/web/favicon.png +0 -0
- pumaguard-ui/web/icons/Icon-192.png +0 -0
- pumaguard-ui/web/icons/Icon-512.png +0 -0
- pumaguard-ui/web/icons/Icon-maskable-192.png +0 -0
- pumaguard-ui/web/icons/Icon-maskable-512.png +0 -0
- pumaguard-ui/web/index.html +38 -0
- pumaguard-ui/web/manifest.json +35 -0
- pumaguard-ui/windows/.gitignore +17 -0
- pumaguard-ui/windows/CMakeLists.txt +108 -0
- pumaguard-ui/windows/flutter/CMakeLists.txt +109 -0
- pumaguard-ui/windows/flutter/generated_plugin_registrant.cc +14 -0
- pumaguard-ui/windows/flutter/generated_plugin_registrant.h +15 -0
- pumaguard-ui/windows/flutter/generated_plugins.cmake +24 -0
- pumaguard-ui/windows/runner/CMakeLists.txt +40 -0
- pumaguard-ui/windows/runner/Runner.rc +121 -0
- pumaguard-ui/windows/runner/flutter_window.cpp +71 -0
- pumaguard-ui/windows/runner/flutter_window.h +33 -0
- pumaguard-ui/windows/runner/main.cpp +43 -0
- pumaguard-ui/windows/runner/resource.h +16 -0
- pumaguard-ui/windows/runner/resources/app_icon.ico +0 -0
- pumaguard-ui/windows/runner/runner.exe.manifest +14 -0
- pumaguard-ui/windows/runner/utils.cpp +65 -0
- pumaguard-ui/windows/runner/utils.h +19 -0
- pumaguard-ui/windows/runner/win32_window.cpp +288 -0
- pumaguard-ui/windows/runner/win32_window.h +102 -0
- pumaguard-21.post29.dist-info/RECORD +0 -83
- {pumaguard-21.post29.dist-info → pumaguard-21.post83.dist-info}/WHEEL +0 -0
- {pumaguard-21.post29.dist-info → pumaguard-21.post83.dist-info}/entry_points.txt +0 -0
- {pumaguard-21.post29.dist-info → pumaguard-21.post83.dist-info}/licenses/LICENSE +0 -0
- {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
|
+
}
|