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 bbox; // [minLon, minLat, maxLon, maxLat] final int sizeMb; final String lastUpdated; final Map 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 json) { final comps = json['components'] as Map; 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> getAvailableRegions() async { final data = await _apiClient.get('/api/offline/regions'); final regions = (data['regions'] as List) .map((r) => OfflineRegionInfo.fromJson(r as Map)) .toList(); return regions; } /// Download a region component with progress tracking. Future 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); }); } /// Download all components for a region. Future 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 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> watchDownloadedRegions() { return _db.watchOfflineRegions(); } /// Check if a region is downloaded. Future isRegionDownloaded(String regionId) async { final region = await _db.getOfflineRegionById(regionId); return region != null; } Future _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((ref) { return OfflineRepository( ref.watch(apiClientProvider), ref.watch(appDatabaseProvider), ); });