import 'dart:async'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_archive/flutter_archive.dart'; import 'package:hadi_hoda_flutter/core/constants/my_api.dart'; import 'package:hadi_hoda_flutter/core/constants/my_constants.dart'; import 'package:hadi_hoda_flutter/core/error_handler/my_exception.dart'; import 'package:hadi_hoda_flutter/core/network/http_request.dart'; import 'package:hadi_hoda_flutter/core/response/base_response.dart'; import 'package:hadi_hoda_flutter/core/utils/local_storage.dart'; import 'package:hadi_hoda_flutter/core/utils/storage_path.dart'; import 'package:hadi_hoda_flutter/features/download/domain/entities/download_entity.dart'; import 'package:hadi_hoda_flutter/features/level/data/model/node_model.dart'; import 'package:hadi_hoda_flutter/features/level/domain/entity/node_entity.dart'; import 'package:hadi_hoda_flutter/features/level/domain/entity/total_data_entity.dart'; import 'package:hive/hive.dart'; abstract class IDownloadDatasource { Future saveLevels(); Future batchDownload(int toLevel); Future getLastDownloadedLevel(); Stream loadingStream(); void cancelDownload(); } class DownloadDatasourceImpl implements IDownloadDatasource { final IHttpRequest httpRequest; final StreamController streamController = StreamController.broadcast(); CancelToken? _audioCancelToken; CancelToken? _imageCancelToken; bool _isBatchDownloading = false; DownloadDatasourceImpl(this.httpRequest); @override void cancelDownload() { _isBatchDownloading = false; _cancelAudioDownload(); _cancelImageDownload(); } void _cancelAudioDownload() { _audioCancelToken?.cancel('Download cancelled by user.'); _audioCancelToken = null; } void _cancelImageDownload() { _imageCancelToken?.cancel('Download cancelled by user.'); _imageCancelToken = null; } void _createAudioNewCancelToken() { _audioCancelToken = CancelToken(); } void _createImageNewCancelToken() { _imageCancelToken = CancelToken(); } int _imageCompleted = 0; int _audioCompleted = 0; int _targetLevel = 0; void _emitProgress() { final int totalTasks = _targetLevel * 2; final int completedTasks = _imageCompleted + _audioCompleted; final double percent = totalTasks == 0 ? 0 : (completedTasks / totalTasks) * 100; streamController.add( DownloadEntity( downloadedLevels: completedTasks ~/ 2, // optional percent: percent, ), ); } Future _isLevelImagesDownloaded(int level) async { final String levelPath = '${StoragePath.documentDir.path}/$level/'; final Directory directory = Directory(levelPath); if (!await directory.exists()) { return false; } final List files = directory.listSync(recursive: false); if (files.isEmpty) { return false; } return true; } Future _getLevelsImages(int toLevel) async { for (int level = 1; level <= toLevel; level++) { if (!_isBatchDownloading) return; final bool isDownloaded = await _isLevelImagesDownloaded(level); if (isDownloaded) { _imageCompleted++; _emitProgress(); continue; } await _getLevelImage(level); } } Future _getLevelImage(int level) async { if (!_isBatchDownloading) return; debugPrint("Started _getLevelImage($level)"); final String filePath = '${StoragePath.documentDir.path}/images.zip'; _createImageNewCancelToken(); try { final response = await httpRequest.download( urlPath: MyApi.images, savePath: filePath, queryParameters: {'batch_start': level, 'batch_size': 1}, cancelToken: _imageCancelToken, onReceive: (count, total) { debugPrint("Started _getLevelImage($level) : ${(count / total) * 100}"); }, ); if (response is Response && response.statusCode == 204) { debugPrint("No image content for level $level (204)"); _imageCompleted++; _emitProgress(); return; } final File file = File(filePath); if (!await file.exists()) { _imageCompleted++; _emitProgress(); return; } final Directory directory = Directory( '${StoragePath.documentDir.path}/$level/', ); await ZipFile.extractToDirectory( zipFile: file, destinationDir: directory, onExtracting: (zipEntry, progress) { return ZipFileOperation.includeItem; }, ); if (await file.exists()) { await file.delete(recursive: true); } } catch (e) { if (e is DioException && CancelToken.isCancel(e)) { return; } throw MyException(errorMessage: '$e'); } _imageCompleted++; _emitProgress(); } Future _isLevelAudiosDownloaded(int level) async { final lang = LocalStorage.readData(key: MyConstants.selectLanguage) ?? MyConstants.defaultLanguage; final String levelPath = '${StoragePath.documentDir.path}/$lang/$level/'; final Directory directory = Directory(levelPath); if (!await directory.exists()) { return false; } final List files = directory.listSync(recursive: false); if (files.isEmpty) { return false; } return true; } Future _getLevelsAudios(int toLevel) async { for (int level = 1; level <= toLevel; level++) { if (!_isBatchDownloading) return; final bool isDownloaded = await _isLevelAudiosDownloaded(level); if (isDownloaded) { _audioCompleted++; _emitProgress(); continue; } await _getLevelAudio(level); } } Future _getLevelAudio(int level) async { if (!_isBatchDownloading) return; debugPrint("Started _getLevelAudio($level)"); final String filePath = '${StoragePath.documentDir.path}/audios.zip'; _createAudioNewCancelToken(); final lang = LocalStorage.readData(key: MyConstants.selectLanguage) ?? MyConstants.defaultLanguage; try { final response = await httpRequest.download( urlPath: MyApi.audios, savePath: filePath, queryParameters: { 'batch_start': level, 'batch_size': 1, 'lang': lang, }, cancelToken: _audioCancelToken, onReceive: (count, total) { debugPrint("Started _getLevelAudio($level) : ${(count / total) * 100}"); }, ); if (response is Response && response.statusCode == 204) { debugPrint("No audio content for level $level (204)"); _audioCompleted++; _emitProgress(); return; } final File file = File(filePath); if (!await file.exists()) { _audioCompleted++; _emitProgress(); return; } final Directory directory = Directory( '${StoragePath.documentDir.path}/$lang/$level/', ); await ZipFile.extractToDirectory( zipFile: file, destinationDir: directory, onExtracting: (zipEntry, progress) { return ZipFileOperation.includeItem; }, ); if (await file.exists()) { await file.delete(recursive: true); } } catch (e) { if (e is DioException && CancelToken.isCancel(e)) { return; } throw MyException(errorMessage: '$e'); } _audioCompleted++; _emitProgress(); } @override Future getLastDownloadedLevel() async { int lastCompleteLevel = 0; for (int level = 1; level <= 50; level++) { final bool imageExists = await _isLevelImagesDownloaded(level); final bool audioExists = await _isLevelAudiosDownloaded(level); if (imageExists && audioExists) { lastCompleteLevel = level; } else { break; // stop at first incomplete level } } return lastCompleteLevel; } @override Future saveLevels() async { final String selectedLanguage = LocalStorage.readData(key: MyConstants.selectLanguage) ?? MyConstants.defaultLanguage; final Box data = Hive.box(MyConstants.levelBox); // Check if data already exists for this language bool dataExists = false; try { data.values.firstWhere((e) => e.code == selectedLanguage); dataExists = true; } catch (_) { dataExists = false; } if (!dataExists) { final response = await httpRequest.get( path: MyApi.levels, queryParameters: {'lang': selectedLanguage}, ); final List levels = BaseResponse.getDataList( response?['path'], (json) => NodeModel.fromJson(json), ); LocalStorage.saveData(key: MyConstants.maxLevelCount, value: '${levels.length}'); await data.add(TotalDataEntity(code: selectedLanguage, nodes: levels)); } } @override Future batchDownload(int toLevel) async { debugPrint("Started batchDownload($toLevel)"); // Cancel any existing batch download before starting a new one cancelDownload(); _isBatchDownloading = true; _targetLevel = toLevel; _imageCompleted = 0; _audioCompleted = 0; try { await Future.wait([_getLevelsImages(toLevel), _getLevelsAudios(toLevel)]); } finally { _isBatchDownloading = false; } } @override Stream loadingStream() => streamController.stream; }