maps/mobile/lib/features/offline/data/offline_repository.dart
2026-03-30 09:22:16 +02:00

168 lines
5.4 KiB
Dart

import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import '../../../core/api/api_client.dart';
import '../../../core/database/app_database.dart';
/// Region metadata from the server.
class OfflineRegionInfo {
final String id;
final String name;
final String description;
final List<double> bbox; // [minLon, minLat, maxLon, maxLat]
final int sizeMb;
final String lastUpdated;
final Map<String, int> components;
OfflineRegionInfo({
required this.id,
required this.name,
required this.description,
required this.bbox,
required this.sizeMb,
required this.lastUpdated,
required this.components,
});
factory OfflineRegionInfo.fromJson(Map<String, dynamic> json) {
final comps = json['components'] as Map<String, dynamic>;
return OfflineRegionInfo(
id: json['id'] as String,
name: json['name'] as String,
description: json['description'] as String? ?? '',
bbox: (json['bbox'] as List).map((e) => (e as num).toDouble()).toList(),
sizeMb: json['size_mb'] as int,
lastUpdated: json['last_updated'] as String,
components: {
'tiles_mb': comps['tiles_mb'] as int? ?? 0,
'routing_driving_mb': comps['routing_driving_mb'] as int? ?? 0,
'routing_walking_mb': comps['routing_walking_mb'] as int? ?? 0,
'routing_cycling_mb': comps['routing_cycling_mb'] as int? ?? 0,
'pois_mb': comps['pois_mb'] as int? ?? 0,
},
);
}
}
class OfflineRepository {
final ApiClient _apiClient;
final AppDatabase _db;
OfflineRepository(this._apiClient, this._db);
/// Fetch available regions from GET /api/offline/regions.
Future<List<OfflineRegionInfo>> getAvailableRegions() async {
final data = await _apiClient.get('/api/offline/regions');
final regions = (data['regions'] as List)
.map((r) => OfflineRegionInfo.fromJson(r as Map<String, dynamic>))
.toList();
return regions;
}
/// Download a region component with progress tracking.
Future<void> downloadComponent({
required String regionId,
required String component,
required void Function(int received, int total) onProgress,
}) async {
final dir = await _getOfflineDir();
final filePath = p.join(dir.path, '$regionId-$component');
await _apiClient.getRaw(
'/api/offline/regions/$regionId/$component',
responseType: ResponseType.bytes,
options: Options(
responseType: ResponseType.bytes,
receiveTimeout: const Duration(minutes: 30),
),
onReceiveProgress: onProgress,
).then((response) async {
final file = File(filePath);
await file.writeAsBytes(response.data as List<int>);
});
}
/// Download all components for a region.
Future<void> downloadRegion({
required OfflineRegionInfo region,
required void Function(String component, int received, int total)
onProgress,
}) async {
final components = ['tiles', 'routing-driving', 'routing-walking',
'routing-cycling', 'pois'];
for (final component in components) {
await downloadComponent(
regionId: region.id,
component: component,
onProgress: (received, total) =>
onProgress(component, received, total),
);
}
// Save to DB after successful download.
await _db.upsertOfflineRegion(OfflineRegionsCompanion.insert(
id: region.id,
name: region.name,
minLat: region.bbox[1],
minLon: region.bbox[0],
maxLat: region.bbox[3],
maxLon: region.bbox[2],
tilesSizeBytes: (region.components['tiles_mb'] ?? 0) * 1024 * 1024,
routingSizeBytes:
((region.components['routing_driving_mb'] ?? 0) +
(region.components['routing_walking_mb'] ?? 0) +
(region.components['routing_cycling_mb'] ?? 0)) *
1024 *
1024,
poisSizeBytes: (region.components['pois_mb'] ?? 0) * 1024 * 1024,
downloadedAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
lastUpdated: DateTime.parse(region.lastUpdated).millisecondsSinceEpoch ~/
1000,
));
}
/// Delete a downloaded region (DB record + files).
Future<void> deleteRegion(String regionId) async {
await _db.deleteOfflineRegion(regionId);
final dir = await _getOfflineDir();
final components = ['tiles', 'routing-driving', 'routing-walking',
'routing-cycling', 'pois'];
for (final component in components) {
final file = File(p.join(dir.path, '$regionId-$component'));
if (await file.exists()) {
await file.delete();
}
}
}
/// Watch downloaded regions.
Stream<List<OfflineRegion>> watchDownloadedRegions() {
return _db.watchOfflineRegions();
}
/// Check if a region is downloaded.
Future<bool> isRegionDownloaded(String regionId) async {
final region = await _db.getOfflineRegionById(regionId);
return region != null;
}
Future<Directory> _getOfflineDir() async {
final appDir = await getApplicationDocumentsDirectory();
final offlineDir = Directory(p.join(appDir.path, 'offline'));
if (!await offlineDir.exists()) {
await offlineDir.create(recursive: true);
}
return offlineDir;
}
}
final offlineRepositoryProvider = Provider<OfflineRepository>((ref) {
return OfflineRepository(
ref.watch(apiClientProvider),
ref.watch(appDatabaseProvider),
);
});