From c01948a21a5cd53ead1d9c305d3dc6db0074b905 Mon Sep 17 00:00:00 2001 From: Thales Matheus <133025183+ThalesMMS@users.noreply.github.com> Date: Mon, 22 Sep 2025 14:32:45 -0300 Subject: [PATCH] Inject export dependencies into AnalysisDetailView --- .../Core/AnalysisDetailViewModel.swift | 6 +- Skinia/Views/Screens/AnalysisDetailView.swift | 160 +++++++++++++++--- Skinia/Views/Screens/AnalysisListView.swift | 4 +- .../AnalysisDetailViewModelTests.swift | 44 +++++ 4 files changed, 192 insertions(+), 22 deletions(-) diff --git a/Skinia/ViewModels/Core/AnalysisDetailViewModel.swift b/Skinia/ViewModels/Core/AnalysisDetailViewModel.swift index 965c082..486f03d 100644 --- a/Skinia/ViewModels/Core/AnalysisDetailViewModel.swift +++ b/Skinia/ViewModels/Core/AnalysisDetailViewModel.swift @@ -74,5 +74,9 @@ final class AnalysisDetailViewModel { func deletePhoto() async throws { try photoRepository.delete(photo) } - + + func exportReport(using service: any AnalysisExportServiceProtocol) throws -> URL { + try service.exportPhotos([photo], format: .pdf) + } + } \ No newline at end of file diff --git a/Skinia/Views/Screens/AnalysisDetailView.swift b/Skinia/Views/Screens/AnalysisDetailView.swift index 83973c8..15fc77e 100644 --- a/Skinia/Views/Screens/AnalysisDetailView.swift +++ b/Skinia/Views/Screens/AnalysisDetailView.swift @@ -1,18 +1,25 @@ +import Photos import SwiftUI +import UIKit struct AnalysisDetailView: View { @State private var viewModel: AnalysisDetailViewModel @Environment(\.notificationManager) private var notificationManager - @State private var showingShareSheet = false + private let analysisExportService: any AnalysisExportServiceProtocol + private let shareSheetPresenter: ShareSheetPresenter @State private var showingFullScreenImage = false @State private var showingPatientInfo = false init( photo: SkinLesionPhoto, photoRepository: any PhotoRepositoryProtocol, - analysisService: any AnalysisServiceProtocol + analysisService: any AnalysisServiceProtocol, + analysisExportService: any AnalysisExportServiceProtocol, + shareSheetPresenter: ShareSheetPresenter ) { + self.analysisExportService = analysisExportService + self.shareSheetPresenter = shareSheetPresenter _viewModel = State( wrappedValue: AnalysisDetailViewModel( photo: photo, @@ -91,16 +98,11 @@ struct AnalysisDetailView: View { } Button("Compartilhar") { - showingShareSheet = true + exportReport() } .disabled(viewModel.photo.analysisResult == nil) } } - .sheet(isPresented: $showingShareSheet) { - if let result = viewModel.photo.analysisResult { - ShareSheet(photo: viewModel.photo, result: result) - } - } .sheet(isPresented: $showingPatientInfo) { PatientInfoSheet(photo: viewModel.photo) } @@ -180,21 +182,137 @@ struct AnalysisDetailView: View { } private func exportReport() { - notificationManager.show( - title: "Exportando Relatório", - message: "Gerando relatório em PDF...", - type: .info - ) + do { + let fileURL = try viewModel.exportReport(using: analysisExportService) + shareSheetPresenter.present(items: [fileURL]) + showNotification( + title: "Relatório Gerado", + message: "Selecione onde compartilhar o PDF.", + type: .success + ) + } catch { + let message: String + if let localizedError = error as? LocalizedError, + let description = localizedError.errorDescription { + message = description + } else { + message = error.localizedDescription + } + + showNotification( + title: "Erro ao Exportar", + message: message, + type: .error + ) + } } private func saveToPhotos() { - guard viewModel.photo.fullImage != nil else { return } + guard let image = viewModel.photo.fullImage else { + showNotification( + title: "Erro ao Salvar", + message: "Não foi possível acessar a imagem para salvar.", + type: .error + ) + return + } - notificationManager.show( - title: "Imagem Salva", - message: "Imagem salva na galeria com sucesso", - type: .success - ) + let status = currentAuthorizationStatus() + + switch status { + case .authorized, .limited: + saveImageToPhotoLibrary(image) + case .notDetermined: + requestPhotoLibraryAccess { authorization in + switch authorization { + case .authorized, .limited: + saveImageToPhotoLibrary(image) + case .denied, .restricted: + showNotification( + title: "Permissão Necessária", + message: "Autorize o acesso às fotos nas configurações para salvar a imagem.", + type: .error + ) + case .notDetermined: + break + @unknown default: + showNotification( + title: "Erro ao Salvar", + message: "Não foi possível acessar a biblioteca de fotos.", + type: .error + ) + } + } + case .denied, .restricted: + showNotification( + title: "Permissão Necessária", + message: "Autorize o acesso às fotos nas configurações para salvar a imagem.", + type: .error + ) + @unknown default: + showNotification( + title: "Erro ao Salvar", + message: "Não foi possível acessar a biblioteca de fotos.", + type: .error + ) + } + } + + private func currentAuthorizationStatus() -> PHAuthorizationStatus { + if #available(iOS 14, *) { + return PHPhotoLibrary.authorizationStatus(for: .addOnly) + } else { + return PHPhotoLibrary.authorizationStatus() + } + } + + private func requestPhotoLibraryAccess(_ completion: @escaping (PHAuthorizationStatus) -> Void) { + if #available(iOS 14, *) { + PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in + DispatchQueue.main.async { + completion(status) + } + } + } else { + PHPhotoLibrary.requestAuthorization { status in + DispatchQueue.main.async { + completion(status) + } + } + } + } + + private func saveImageToPhotoLibrary(_ image: UIImage) { + PHPhotoLibrary.shared().performChanges({ + PHAssetChangeRequest.creationRequestForAsset(from: image) + }) { success, error in + DispatchQueue.main.async { + if success { + showNotification( + title: "Imagem Salva", + message: "Imagem salva na galeria com sucesso.", + type: .success + ) + } else { + let message = error?.localizedDescription ?? "Não foi possível salvar a imagem." + showNotification( + title: "Erro ao Salvar", + message: message, + type: .error + ) + } + } + } + } + + private func showNotification( + title: String, + message: String?, + type: StatusNotification.NotificationType + ) { + Task { @MainActor in + notificationManager.show(title: title, message: message, type: type) + } } } @@ -294,7 +412,9 @@ private struct AnalysisErrorView: View { AnalysisDetailView( photo: mockPhoto, photoRepository: container.photoRepository, - analysisService: container.analysisService + analysisService: container.analysisService, + analysisExportService: container.analysisExportService, + shareSheetPresenter: container.shareSheetPresenter ) } } diff --git a/Skinia/Views/Screens/AnalysisListView.swift b/Skinia/Views/Screens/AnalysisListView.swift index 1d4c9d5..781273f 100644 --- a/Skinia/Views/Screens/AnalysisListView.swift +++ b/Skinia/Views/Screens/AnalysisListView.swift @@ -188,7 +188,9 @@ struct AnalysisListView: View { AnalysisDetailView( photo: photoToShow, photoRepository: coordinator.dependencyContainer.photoRepository, - analysisService: coordinator.dependencyContainer.analysisService + analysisService: coordinator.dependencyContainer.analysisService, + analysisExportService: exportService, + shareSheetPresenter: shareSheetPresenter ) .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/SkiniaTests/AnalysisDetailViewModelTests.swift b/SkiniaTests/AnalysisDetailViewModelTests.swift index 618e69b..4804930 100644 --- a/SkiniaTests/AnalysisDetailViewModelTests.swift +++ b/SkiniaTests/AnalysisDetailViewModelTests.swift @@ -84,6 +84,27 @@ struct AnalysisDetailViewModelTests { } } + @MainActor + private final class MockAnalysisExportService: AnalysisExportServiceProtocol { + enum MockError: Error { + case exportFailed + } + + var exportedPhotos: [[SkinLesionPhoto]] = [] + var shouldThrow = false + var urlToReturn = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("mock.pdf") + + func exportPhotos(_ photos: [SkinLesionPhoto], format: AnalysisExportFormat) throws -> URL { + exportedPhotos.append(photos) + + if shouldThrow { + throw MockError.exportFailed + } + + return urlToReturn + } + } + private func makeViewModel(status: AnalysisStatus = .failed) -> (AnalysisDetailViewModel, MockPhotoRepository, MockAnalysisService, SkinLesionPhoto) { let repository = MockPhotoRepository() let photo = SkinLesionPhoto( @@ -123,4 +144,27 @@ struct AnalysisDetailViewModelTests { #expect(viewModel.errorMessage != nil) #expect(!viewModel.isRetrying) } + + @Test + func exportReportSuccessReturnsURL() throws { + let (viewModel, _, _, photo) = makeViewModel(status: .completed) + let exportService = MockAnalysisExportService() + + let url = try viewModel.exportReport(using: exportService) + + #expect(url == exportService.urlToReturn) + #expect(exportService.exportedPhotos.count == 1) + #expect(exportService.exportedPhotos.first?.first === photo) + } + + @Test + func exportReportFailurePropagatesError() { + let (viewModel, _, _, _) = makeViewModel(status: .completed) + let exportService = MockAnalysisExportService() + exportService.shouldThrow = true + + #expect(throws: MockAnalysisExportService.MockError.exportFailed) { + _ = try viewModel.exportReport(using: exportService) + } + } }