diff --git a/AutoCat.xcodeproj/project.pbxproj b/AutoCat.xcodeproj/project.pbxproj index 5555cb9..2e6b11a 100644 --- a/AutoCat.xcodeproj/project.pbxproj +++ b/AutoCat.xcodeproj/project.pbxproj @@ -134,6 +134,12 @@ 7A8AB76525A0DB8F00ECF2C1 /* BundleVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8AB76425A0DB8F00ECF2C1 /* BundleVersion.swift */; }; 7A912F372D381B7400002938 /* LicensePlateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A912F362D381B7400002938 /* LicensePlateView.swift */; }; 7A91894F29A2BD8700519C74 /* GestureRecognizers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A91894E29A2BD8700519C74 /* GestureRecognizers.swift */; }; + 7A9519792D80B3E800E69883 /* AudioRecordService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9519782D80B3E800E69883 /* AudioRecordService.swift */; }; + 7A95197B2D80B41600E69883 /* AudioRecordServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A95197A2D80B41600E69883 /* AudioRecordServiceProtocol.swift */; }; + 7A95197D2D80B43D00E69883 /* AudioRecordError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A95197C2D80B43D00E69883 /* AudioRecordError.swift */; }; + 7A9519802D80B6C100E69883 /* RecordsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A95197F2D80B6C100E69883 /* RecordsScreen.swift */; }; + 7A9519822D80B6E500E69883 /* RecordsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9519812D80B6E500E69883 /* RecordsViewModel.swift */; }; + 7A9519842D80B72B00E69883 /* RecordsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9519832D80B72B00E69883 /* RecordsCoordinator.swift */; }; 7A961C6C2C4C3C8600CE2211 /* TextRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A961C6B2C4C3C8600CE2211 /* TextRowView.swift */; }; 7A961C6E2C4C3C9E00CE2211 /* LinkRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A961C6D2C4C3C9E00CE2211 /* LinkRowView.swift */; }; 7A96AE2D246B2B7400297C33 /* GoogleSignInController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A96AE2C246B2B7400297C33 /* GoogleSignInController.swift */; }; @@ -419,6 +425,12 @@ 7A912F362D381B7400002938 /* LicensePlateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LicensePlateView.swift; sourceTree = ""; }; 7A91894E29A2BD8700519C74 /* GestureRecognizers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureRecognizers.swift; sourceTree = ""; }; 7A92D0AB240425B100EF3B77 /* ATGMediaBrowser.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ATGMediaBrowser.framework; path = Carthage/Build/iOS/ATGMediaBrowser.framework; sourceTree = ""; }; + 7A9519782D80B3E800E69883 /* AudioRecordService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecordService.swift; sourceTree = ""; }; + 7A95197A2D80B41600E69883 /* AudioRecordServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecordServiceProtocol.swift; sourceTree = ""; }; + 7A95197C2D80B43D00E69883 /* AudioRecordError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecordError.swift; sourceTree = ""; }; + 7A95197F2D80B6C100E69883 /* RecordsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordsScreen.swift; sourceTree = ""; }; + 7A9519812D80B6E500E69883 /* RecordsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordsViewModel.swift; sourceTree = ""; }; + 7A9519832D80B72B00E69883 /* RecordsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordsCoordinator.swift; sourceTree = ""; }; 7A961C6B2C4C3C8600CE2211 /* TextRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRowView.swift; sourceTree = ""; }; 7A961C6D2C4C3C9E00CE2211 /* LinkRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkRowView.swift; sourceTree = ""; }; 7A96AE2C246B2B7400297C33 /* GoogleSignInController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleSignInController.swift; sourceTree = ""; }; @@ -726,6 +738,7 @@ 7A1441632C297E9800E79018 /* Screens */ = { isa = PBXGroup; children = ( + 7A95197E2D80B69800E69883 /* RecordsScreen */, 7A5911EC2D63225500EC51BA /* SearchScreen */, 7A131FD12D37B74100DC7755 /* HistoryScreen */, 7AB9FE202D08C28E005DE374 /* EventsScreen */, @@ -802,6 +815,7 @@ 7A45FB362C2706D000618694 /* Services */ = { isa = PBXGroup; children = ( + 7A9519772D80B3B200E69883 /* AudioRecordService */, 7AB4E4392D3D3F390006D052 /* VehicleService */, 7A06E0B12C707DD7005731AC /* SettingsService */, 7A60D24B2C5A9D2700D13F7B /* LocationService */, @@ -990,6 +1004,26 @@ path = Location; sourceTree = ""; }; + 7A9519772D80B3B200E69883 /* AudioRecordService */ = { + isa = PBXGroup; + children = ( + 7A9519782D80B3E800E69883 /* AudioRecordService.swift */, + 7A95197A2D80B41600E69883 /* AudioRecordServiceProtocol.swift */, + 7A95197C2D80B43D00E69883 /* AudioRecordError.swift */, + ); + path = AudioRecordService; + sourceTree = ""; + }; + 7A95197E2D80B69800E69883 /* RecordsScreen */ = { + isa = PBXGroup; + children = ( + 7A95197F2D80B6C100E69883 /* RecordsScreen.swift */, + 7A9519812D80B6E500E69883 /* RecordsViewModel.swift */, + 7A9519832D80B72B00E69883 /* RecordsCoordinator.swift */, + ); + path = RecordsScreen; + sourceTree = ""; + }; 7AAAFAD12C4D0FB00050410D /* ACImageSlider */ = { isa = PBXGroup; children = ( @@ -1398,6 +1432,7 @@ 7A813DC32508EE4F00CC93B9 /* EventCell.swift in Sources */, 7A1441682C297EFD00E79018 /* NotesViewModel.swift in Sources */, 7AFBE8C02C3024E5003C491D /* ACHud.swift in Sources */, + 7A9519842D80B72B00E69883 /* RecordsCoordinator.swift in Sources */, 7AABDE26253350C30041AFC6 /* RxSectionedDataSource.swift in Sources */, 7AAAFADA2C4D1AFE0050410D /* Zoomable.swift in Sources */, 7AC8B2762D6A01C700190706 /* UISearchTextField+Dumb.swift in Sources */, @@ -1468,6 +1503,7 @@ 7AAAFADE2C4D23620050410D /* ACImageSliderModel.swift in Sources */, 7A8AB76525A0DB8F00ECF2C1 /* BundleVersion.swift in Sources */, 7AC3555229696E3F00889457 /* UIView+layout.swift in Sources */, + 7A9519822D80B6E500E69883 /* RecordsViewModel.swift in Sources */, 7AC355592969746600889457 /* UIControl.swift in Sources */, 7AB67E8E2435D1A000258F61 /* CustomButton.swift in Sources */, 7AC35554296973E100889457 /* ACButton.swift in Sources */, @@ -1500,6 +1536,7 @@ 7A1022702C551EFD00B84627 /* LocationEditCoordinator.swift in Sources */, 7A7158042C43EAA200852088 /* OwnersCoordinator.swift in Sources */, 7A17CE4C2A2E850200626A6E /* UISegmentedControl.swift in Sources */, + 7A9519802D80B6C100E69883 /* RecordsScreen.swift in Sources */, 7A131FD52D37B76A00DC7755 /* HistoryViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1544,6 +1581,7 @@ 7A64A21E2C19E8D500284124 /* VehicleAdDto.swift in Sources */, 7AF6D2202677C1680086EA64 /* Filter.swift in Sources */, 7A761C042677F18E0005F28F /* ApiService.swift in Sources */, + 7A95197B2D80B41600E69883 /* AudioRecordServiceProtocol.swift in Sources */, 7AF6D21C2677C1680086EA64 /* DebugInfo.swift in Sources */, 7AF6D2122677C12E0086EA64 /* Location.swift in Sources */, 7AF6D2142677C1680086EA64 /* VehicleEvent.swift in Sources */, @@ -1562,6 +1600,7 @@ 7A761C07267E8E7F0005F28F /* AnyEncodable.swift in Sources */, 7A64A2032C19DA1000284124 /* VehicleDto.swift in Sources */, 7AB4E4332D3C21C00006D052 /* FileManagerExt.swift in Sources */, + 7A9519792D80B3E800E69883 /* AudioRecordService.swift in Sources */, 7AB587322C42D38E00FA7B66 /* StorageServiceProtocol.swift in Sources */, 7A3E12D72C7B42B700EE710D /* UserDefaults+Settings.swift in Sources */, 7AB4E43B2D3D3F4F0006D052 /* VehicleServiceProtocol.swift in Sources */, @@ -1587,6 +1626,7 @@ 7A599C362C18AC7F00D47C18 /* ApiError.swift in Sources */, 7A45FB382C27073700618694 /* StorageService.swift in Sources */, 7A64A20A2C19E07100284124 /* VehicleNameDto.swift in Sources */, + 7A95197D2D80B43D00E69883 /* AudioRecordError.swift in Sources */, 7AF6D22A2677C3AD0086EA64 /* Exportable.swift in Sources */, 7AF6D2212677C1680086EA64 /* PagedResponse.swift in Sources */, 7AF6D2192677C1680086EA64 /* DateSection.swift in Sources */, diff --git a/AutoCat/Controllers/MainTabController.swift b/AutoCat/Controllers/MainTabController.swift index 239d184..5ff695d 100644 --- a/AutoCat/Controllers/MainTabController.swift +++ b/AutoCat/Controllers/MainTabController.swift @@ -18,6 +18,7 @@ class MainTabController: UITabBarController, UITabBarControllerDelegate { } addHistoryTab() + //addRecordsTab() #if !targetEnvironment(macCatalyst) addDummyTab() @@ -36,11 +37,20 @@ class MainTabController: UITabBarController, UITabBarControllerDelegate { historyViewModel = viewModel } + func addRecordsTab() { + + let coordinator = RecordsCoordinator() + let controller = coordinator.start() + controller.tabBarItem = UITabBarItem(title: NSLocalizedString("Records", comment: ""), + image: UIImage(named: "record"), tag: 0) + viewControllers?[1] = controller + } + func addDummyTab() { let controller = DummyNewController() controller.tabBarItem = UITabBarItem(title: "", image: UIImage(systemName: "plus"), tag: 0) - viewControllers?.insert(controller, at: 2) + viewControllers?.append(controller) } func addSearchTab() { diff --git a/AutoCat/SceneDelegate.swift b/AutoCat/SceneDelegate.swift index 8603345..6856158 100644 --- a/AutoCat/SceneDelegate.swift +++ b/AutoCat/SceneDelegate.swift @@ -60,6 +60,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { storageService: storageService, locationService: locationService) container.register(VehicleServiceProtocol.self, instance: vehicleService) + + container.register(AudioRecordServiceProtocol.self, instance: AudioRecordService()) } func setupRootController(scene: UIScene, openReport number: String?) { diff --git a/AutoCat/Screens/RecordsScreen/RecordsCoordinator.swift b/AutoCat/Screens/RecordsScreen/RecordsCoordinator.swift new file mode 100644 index 0000000..6a1ef19 --- /dev/null +++ b/AutoCat/Screens/RecordsScreen/RecordsCoordinator.swift @@ -0,0 +1,34 @@ +// +// RecordsCoordinator.swift +// AutoCat +// +// Created by Selim Mustafaev on 11.03.2025. +// Copyright © 2025 Selim Mustafaev. All rights reserved. +// + +import UIKit +import SwiftUI +import AutoCatCore + +@MainActor +final class RecordsCoordinator { + + var navController = UINavigationController() + + func start() -> UIViewController { + + let resolver = ServiceContainer.shared + let viewModel = RecordsViewModel( + recordService: resolver.resolve(AudioRecordServiceProtocol.self), + storageService: resolver.resolve(StorageServiceProtocol.self), + locationService: resolver.resolve(LocationServiceProtocol.self) + ) + + let view = RecordsScreen(viewModel: viewModel) + let controller = UIHostingController(rootView: view) + + let navController = UINavigationController(rootViewController: controller) + self.navController = navController + return navController + } +} diff --git a/AutoCat/Screens/RecordsScreen/RecordsScreen.swift b/AutoCat/Screens/RecordsScreen/RecordsScreen.swift new file mode 100644 index 0000000..21ca4ae --- /dev/null +++ b/AutoCat/Screens/RecordsScreen/RecordsScreen.swift @@ -0,0 +1,18 @@ +// +// RecordsScreen.swift +// AutoCat +// +// Created by Selim Mustafaev on 11.03.2025. +// Copyright © 2025 Selim Mustafaev. All rights reserved. +// + +import SwiftUI + +struct RecordsScreen: View { + + @State var viewModel: RecordsViewModel + + var body: some View { + Text("Hello, World!") + } +} diff --git a/AutoCat/Screens/RecordsScreen/RecordsViewModel.swift b/AutoCat/Screens/RecordsScreen/RecordsViewModel.swift new file mode 100644 index 0000000..ccdc6fe --- /dev/null +++ b/AutoCat/Screens/RecordsScreen/RecordsViewModel.swift @@ -0,0 +1,28 @@ +// +// RecordsViewModel.swift +// AutoCat +// +// Created by Selim Mustafaev on 11.03.2025. +// Copyright © 2025 Selim Mustafaev. All rights reserved. +// + +import SwiftUI +import AutoCatCore + +@MainActor +@Observable +final class RecordsViewModel { + + let recordService: AudioRecordServiceProtocol + let storageService: StorageServiceProtocol + let locationService: LocationServiceProtocol + + init(recordService: AudioRecordServiceProtocol, + storageService: StorageServiceProtocol, + locationService: LocationServiceProtocol) { + + self.recordService = recordService + self.storageService = storageService + self.locationService = locationService + } +} diff --git a/AutoCatCore/Services/AudioRecordService/AudioRecordError.swift b/AutoCatCore/Services/AudioRecordService/AudioRecordError.swift new file mode 100644 index 0000000..d1aa43f --- /dev/null +++ b/AutoCatCore/Services/AudioRecordService/AudioRecordError.swift @@ -0,0 +1,22 @@ +// +// AudioRecordError.swift +// AutoCatCore +// +// Created by Selim Mustafaev on 11.03.2025. +// Copyright © 2025 Selim Mustafaev. All rights reserved. +// + +import Foundation + +public enum AudioRecordError: LocalizedError { + + case permissionDenied + case unknown + + public var errorDescription: String? { + switch self { + case .permissionDenied: "Permission denied to record audio" + case .unknown: "Unknown error" + } + } +} diff --git a/AutoCatCore/Services/AudioRecordService/AudioRecordService.swift b/AutoCatCore/Services/AudioRecordService/AudioRecordService.swift new file mode 100644 index 0000000..d9b9570 --- /dev/null +++ b/AutoCatCore/Services/AudioRecordService/AudioRecordService.swift @@ -0,0 +1,97 @@ +// +// AudioRecordService.swift +// AutoCatCore +// +// Created by Selim Mustafaev on 11.03.2025. +// Copyright © 2025 Selim Mustafaev. All rights reserved. +// + +import AVFoundation +import Speech + +public final class AudioRecordService { + + let audioFileSettings: [String : Any] = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVSampleRateKey: 44100, + AVNumberOfChannelsKey: 2, + ] + + var recorder: AVAudioRecorder? + + public init() { + + } + + @discardableResult + func requestRecognitionAuthorization() async -> SFSpeechRecognizerAuthorizationStatus { + + let status = SFSpeechRecognizer.authorizationStatus() + guard status == .notDetermined else { + return status + } + + return await withCheckedContinuation { continuation in + SFSpeechRecognizer.requestAuthorization { status in + continuation.resume(returning: status) + } + } + } +} + +extension AudioRecordService: AudioRecordServiceProtocol { + + public func requestPermissions() async -> Bool { + + await requestRecognitionAuthorization() + return await AVAudioApplication.requestRecordPermission() + } + + public func startRecording(to url: URL) async throws { + guard AVAudioApplication.shared.recordPermission != .denied else { + throw AudioRecordError.permissionDenied + } + + switch AVAudioApplication.shared.recordPermission { + case .denied: + throw AudioRecordError.permissionDenied + case .undetermined: + if await AVAudioApplication.requestRecordPermission() { + break + } else { + return + } + case .granted: + break + @unknown default: + throw AudioRecordError.unknown + } + + try AVAudioSession.sharedInstance().setCategory(.playAndRecord) + try AVAudioSession.sharedInstance().setActive(true) + + recorder = try AVAudioRecorder(url: url, settings: audioFileSettings) + recorder?.record() + } + + public func stopRecording() { + + recorder?.stop() + recorder = nil + } + + public func recognizeText(from url: URL) async -> String? { + + guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "ru-RU")), recognizer.isAvailable else { + return nil + } + + let request = SFSpeechURLRecognitionRequest(url: url) + + return await withCheckedContinuation { continuation in + recognizer.recognitionTask(with: request) { result, error in + continuation.resume(returning: result?.bestTranscription.formattedString) + } + } + } +} diff --git a/AutoCatCore/Services/AudioRecordService/AudioRecordServiceProtocol.swift b/AutoCatCore/Services/AudioRecordService/AudioRecordServiceProtocol.swift new file mode 100644 index 0000000..edbe451 --- /dev/null +++ b/AutoCatCore/Services/AudioRecordService/AudioRecordServiceProtocol.swift @@ -0,0 +1,17 @@ +// +// AudioRecordServiceProtocol.swift +// AutoCatCore +// +// Created by Selim Mustafaev on 11.03.2025. +// Copyright © 2025 Selim Mustafaev. All rights reserved. +// + +import Foundation + +public protocol AudioRecordServiceProtocol { + + func requestPermissions() async -> Bool + func startRecording(to url: URL) async throws + func stopRecording() + func recognizeText(from url: URL) async -> String? +}