168 lines
5.4 KiB
Dart
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),
|
|
);
|
|
});
|