From ffec43495bc016ca57130a01193d7521e537a110 Mon Sep 17 00:00:00 2001 From: stopflock Date: Sun, 7 Dec 2025 11:00:42 -0600 Subject: [PATCH] Better suspected locations download indicator --- lib/app_state.dart | 1 + .../sections/suspected_locations_section.dart | 46 ++++++++++++- lib/services/suspected_location_service.dart | 65 ++++++++++++++----- lib/state/suspected_location_state.dart | 18 ++++- 4 files changed, 107 insertions(+), 23 deletions(-) diff --git a/lib/app_state.dart b/lib/app_state.dart index ddbbba0..1d31147 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -175,6 +175,7 @@ class AppState extends ChangeNotifier { SuspectedLocation? get selectedSuspectedLocation => _suspectedLocationState.selectedLocation; bool get suspectedLocationsEnabled => _suspectedLocationState.isEnabled; bool get suspectedLocationsLoading => _suspectedLocationState.isLoading; + double? get suspectedLocationsDownloadProgress => _suspectedLocationState.downloadProgress; Future get suspectedLocationsLastFetch => _suspectedLocationState.lastFetchTime; void _onStateChanged() { diff --git a/lib/screens/settings/sections/suspected_locations_section.dart b/lib/screens/settings/sections/suspected_locations_section.dart index 539ac90..f1d8b3b 100644 --- a/lib/screens/settings/sections/suspected_locations_section.dart +++ b/lib/screens/settings/sections/suspected_locations_section.dart @@ -12,6 +12,7 @@ class SuspectedLocationsSection extends StatefulWidget { class _SuspectedLocationsSectionState extends State { DateTime? _lastFetch; + bool _wasLoading = false; @override void initState() { @@ -38,8 +39,26 @@ class _SuspectedLocationsSectionState extends State { final appState = context.watch(); final isEnabled = appState.suspectedLocationsEnabled; final isLoading = appState.suspectedLocationsLoading; + final downloadProgress = appState.suspectedLocationsDownloadProgress; + + // Check if loading just finished and reload last fetch time + if (_wasLoading && !isLoading) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadLastFetch(); + }); + } + _wasLoading = isLoading; String getLastFetchText() { + // Show status during loading + if (isLoading) { + if (downloadProgress != null) { + return 'Downloading data... (this may take a few minutes)'; + } else { + return 'Processing data...'; + } + } + if (_lastFetch == null) { return locService.t('suspectedLocations.neverFetched'); } else { @@ -112,10 +131,31 @@ class _SuspectedLocationsSectionState extends State { title: Text(locService.t('suspectedLocations.lastUpdated')), subtitle: Text(getLastFetchText()), trailing: isLoading - ? const SizedBox( - width: 24, + ? SizedBox( + width: 80, height: 24, - child: CircularProgressIndicator(strokeWidth: 2), + child: downloadProgress != null + ? Column( + mainAxisSize: MainAxisSize.min, + children: [ + LinearProgressIndicator( + value: downloadProgress, + backgroundColor: Colors.grey[300], + ), + const SizedBox(height: 2), + Text( + '${(downloadProgress * 100).toInt()}%', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ) + : const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), ) : IconButton( icon: const Icon(Icons.refresh), diff --git a/lib/services/suspected_location_service.dart b/lib/services/suspected_location_service.dart index ff33d2f..4f82c96 100644 --- a/lib/services/suspected_location_service.dart +++ b/lib/services/suspected_location_service.dart @@ -66,17 +66,17 @@ class SuspectedLocationService { Future get lastFetch => _cache.lastFetchTime; /// Fetch data if needed (for enabling suspected locations when no data exists) - Future fetchDataIfNeeded() async { + Future fetchDataIfNeeded({void Function(double)? onProgress}) async { if (!(await _shouldRefresh())) { debugPrint('[SuspectedLocationService] Data is fresh, skipping fetch'); return true; // Already have fresh data } - return await _fetchData(); + return await _fetchData(onProgress: onProgress); } /// Force refresh the data (for manual refresh button) - Future forceRefresh() async { - return await _fetchData(); + Future forceRefresh({void Function(double)? onProgress}) async { + return await _fetchData(onProgress: onProgress); } /// Check if data should be refreshed @@ -102,7 +102,7 @@ class SuspectedLocationService { } /// Fetch data from the CSV URL - Future _fetchData() async { + Future _fetchData({void Function(double)? onProgress}) async { const maxRetries = 3; for (int attempt = 1; attempt <= maxRetries; attempt++) { @@ -112,23 +112,52 @@ class SuspectedLocationService { debugPrint('[SuspectedLocationService] This may take up to ${_timeout.inMinutes} minutes for large datasets...'); } - final response = await http.get( - Uri.parse(kSuspectedLocationsCsvUrl), - headers: { - 'User-Agent': 'DeFlock/1.0 (OSM surveillance mapping app)', - }, - ).timeout(_timeout); - - if (response.statusCode != 200) { - debugPrint('[SuspectedLocationService] HTTP error ${response.statusCode}'); - throw Exception('HTTP ${response.statusCode}'); + // Use streaming download for progress tracking + final request = http.Request('GET', Uri.parse(kSuspectedLocationsCsvUrl)); + request.headers['User-Agent'] = 'DeFlock/1.0 (OSM surveillance mapping app)'; + + final client = http.Client(); + final streamedResponse = await client.send(request).timeout(_timeout); + + if (streamedResponse.statusCode != 200) { + debugPrint('[SuspectedLocationService] HTTP error ${streamedResponse.statusCode}'); + client.close(); + throw Exception('HTTP ${streamedResponse.statusCode}'); } - final responseSize = response.contentLength ?? response.bodyBytes.length; - debugPrint('[SuspectedLocationService] Downloaded ${responseSize} bytes, parsing CSV...'); + final contentLength = streamedResponse.contentLength; + debugPrint('[SuspectedLocationService] Starting download of ${contentLength != null ? '$contentLength bytes' : 'unknown size'}...'); + + // Download with progress tracking + final chunks = >[]; + int downloadedBytes = 0; + + await for (final chunk in streamedResponse.stream) { + chunks.add(chunk); + downloadedBytes += chunk.length; + + // Report progress if we know the total size + if (contentLength != null && onProgress != null) { + try { + final progress = downloadedBytes / contentLength; + onProgress(progress.clamp(0.0, 1.0)); + } catch (e) { + // Don't let progress callback errors break the download + debugPrint('[SuspectedLocationService] Progress callback error: $e'); + } + } + } + + client.close(); + + // Combine chunks into single response body + final bodyBytes = chunks.expand((chunk) => chunk).toList(); + final responseBody = String.fromCharCodes(bodyBytes); + + debugPrint('[SuspectedLocationService] Downloaded $downloadedBytes bytes, parsing CSV...'); // Parse CSV with proper field separator and quote handling - final csvData = await compute(_parseCSV, response.body); + final csvData = await compute(_parseCSV, responseBody); debugPrint('[SuspectedLocationService] Parsed ${csvData.length} rows from CSV'); if (csvData.isEmpty) { diff --git a/lib/state/suspected_location_state.dart b/lib/state/suspected_location_state.dart index 122b413..5bdaa7b 100644 --- a/lib/state/suspected_location_state.dart +++ b/lib/state/suspected_location_state.dart @@ -8,6 +8,7 @@ class SuspectedLocationState extends ChangeNotifier { SuspectedLocation? _selectedLocation; bool _isLoading = false; + double? _downloadProgress; // 0.0 to 1.0, null when not downloading /// Currently selected suspected location (for detail view) SuspectedLocation? get selectedLocation => _selectedLocation; @@ -47,6 +48,9 @@ class SuspectedLocationState extends ChangeNotifier { /// Whether currently loading data bool get isLoading => _isLoading; + + /// Download progress (0.0 to 1.0), null when not downloading + double? get downloadProgress => _downloadProgress; /// Last time data was fetched Future get lastFetchTime => _service.lastFetchTime; @@ -72,13 +76,15 @@ class SuspectedLocationState extends ChangeNotifier { /// Manually refresh the data (force refresh) Future refreshData() async { _isLoading = true; + _downloadProgress = null; notifyListeners(); try { - final success = await _service.forceRefresh(); + final success = await _service.forceRefresh(onProgress: _updateDownloadProgress); return success; } finally { _isLoading = false; + _downloadProgress = null; notifyListeners(); } } @@ -86,16 +92,24 @@ class SuspectedLocationState extends ChangeNotifier { /// Internal method to fetch data if needed with loading state management Future _fetchData() async { _isLoading = true; + _downloadProgress = null; notifyListeners(); try { - final success = await _service.fetchDataIfNeeded(); + final success = await _service.fetchDataIfNeeded(onProgress: _updateDownloadProgress); return success; } finally { _isLoading = false; + _downloadProgress = null; notifyListeners(); } } + + /// Update download progress + void _updateDownloadProgress(double progress) { + _downloadProgress = progress; + notifyListeners(); + } /// Select a suspected location for detail view void selectLocation(SuspectedLocation location) {