Better suspected locations download indicator

This commit is contained in:
stopflock
2025-12-07 11:00:42 -06:00
parent 16b8acad3a
commit ffec43495b
4 changed files with 107 additions and 23 deletions
+1
View File
@@ -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<DateTime?> get suspectedLocationsLastFetch => _suspectedLocationState.lastFetchTime;
void _onStateChanged() {
@@ -12,6 +12,7 @@ class SuspectedLocationsSection extends StatefulWidget {
class _SuspectedLocationsSectionState extends State<SuspectedLocationsSection> {
DateTime? _lastFetch;
bool _wasLoading = false;
@override
void initState() {
@@ -38,8 +39,26 @@ class _SuspectedLocationsSectionState extends State<SuspectedLocationsSection> {
final appState = context.watch<AppState>();
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<SuspectedLocationsSection> {
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),
+47 -18
View File
@@ -66,17 +66,17 @@ class SuspectedLocationService {
Future<DateTime?> get lastFetch => _cache.lastFetchTime;
/// Fetch data if needed (for enabling suspected locations when no data exists)
Future<bool> fetchDataIfNeeded() async {
Future<bool> 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<bool> forceRefresh() async {
return await _fetchData();
Future<bool> 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<bool> _fetchData() async {
Future<bool> _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 = <List<int>>[];
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) {
+16 -2
View File
@@ -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<DateTime?> get lastFetchTime => _service.lastFetchTime;
@@ -72,13 +76,15 @@ class SuspectedLocationState extends ChangeNotifier {
/// Manually refresh the data (force refresh)
Future<bool> 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<bool> _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) {