diff --git a/AutoCat.xcodeproj/project.pbxproj b/AutoCat.xcodeproj/project.pbxproj index 2e6b11a..85c9232 100644 --- a/AutoCat.xcodeproj/project.pbxproj +++ b/AutoCat.xcodeproj/project.pbxproj @@ -68,7 +68,6 @@ 7A5911EE2D63226F00EC51BA /* SearchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5911ED2D63226F00EC51BA /* SearchScreen.swift */; }; 7A5911F02D63266B00EC51BA /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5911EF2D63266B00EC51BA /* SearchViewModel.swift */; }; 7A5911F22D63268400EC51BA /* SearchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5911F12D63268400EC51BA /* SearchCoordinator.swift */; }; - 7A5912052D648A6000EC51BA /* AutoCancellable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5912042D648A6000EC51BA /* AutoCancellable.swift */; }; 7A599C362C18AC7F00D47C18 /* ApiError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A599C352C18AC7F00D47C18 /* ApiError.swift */; }; 7A599C392C18B22900D47C18 /* FbRefreshTokenModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A599C382C18B22900D47C18 /* FbRefreshTokenModel.swift */; }; 7A599C3B2C18B36A00D47C18 /* FbVerifyTokenModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A599C3A2C18B36A00D47C18 /* FbVerifyTokenModel.swift */; }; @@ -104,7 +103,6 @@ 7A64AE742469DFB600ABE48E /* MediaContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A64AE702469DFB600ABE48E /* MediaContentView.swift */; }; 7A64AE752469DFB600ABE48E /* MediaBrowserViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A64AE712469DFB600ABE48E /* MediaBrowserViewController.swift */; }; 7A64AE762469DFB600ABE48E /* ContentTransformers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A64AE722469DFB600ABE48E /* ContentTransformers.swift */; }; - 7A659B5B24A3768A0043A0F2 /* Substrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A659B5A24A3768A0043A0F2 /* Substrings.swift */; }; 7A6B65B32CFB0DB500AABA6B /* NullifyDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6B65B22CFB0DB500AABA6B /* NullifyDate.swift */; }; 7A6C4D9E2C56BCA600982597 /* SwiftLocation in Frameworks */ = {isa = PBXBuildFile; productRef = 7A6C4D9D2C56BCA600982597 /* SwiftLocation */; }; 7A6DD903242BF4A5009DE740 /* PlateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6DD902242BF4A5009DE740 /* PlateView.swift */; }; @@ -178,6 +176,12 @@ 7ABD1B472D044A3200B43213 /* GalleryScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABD1B462D044A3200B43213 /* GalleryScreen.swift */; }; 7ABD1B492D044A4700B43213 /* GalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABD1B482D044A4700B43213 /* GalleryViewModel.swift */; }; 7ABD1B4B2D044A7D00B43213 /* GalleryCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABD1B4A2D044A7D00B43213 /* GalleryCoordinator.swift */; }; + 7ABDA8032D8704F70083C715 /* VehicleRecordService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABDA8022D8704F70083C715 /* VehicleRecordService.swift */; }; + 7ABDA8052D8705210083C715 /* VehicleRecordServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABDA8042D8705210083C715 /* VehicleRecordServiceProtocol.swift */; }; + 7ABDA8092D8710F80083C715 /* AutoCancellable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABDA8082D8710F80083C715 /* AutoCancellable.swift */; }; + 7ABDA80B2D8715DC0083C715 /* VehicleRecordError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABDA80A2D8715DC0083C715 /* VehicleRecordError.swift */; }; + 7ABDA80D2D8721B10083C715 /* Substrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABDA80C2D8721B10083C715 /* Substrings.swift */; }; + 7ABDA80F2D8723F90083C715 /* StorageService+AudioRecords.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABDA80E2D8723F90083C715 /* StorageService+AudioRecords.swift */; }; 7AC3554A2969652F00889457 /* SwiftEntryKit in Frameworks */ = {isa = PBXBuildFile; productRef = 7AC355492969652F00889457 /* SwiftEntryKit */; }; 7AC3554C29696A1C00889457 /* MainTabController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AC3554B29696A1C00889457 /* MainTabController.swift */; }; 7AC3554E29696C4500889457 /* DummyNewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AC3554D29696C4500889457 /* DummyNewController.swift */; }; @@ -354,7 +358,6 @@ 7A5911ED2D63226F00EC51BA /* SearchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreen.swift; sourceTree = ""; }; 7A5911EF2D63266B00EC51BA /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = ""; }; 7A5911F12D63268400EC51BA /* SearchCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchCoordinator.swift; sourceTree = ""; }; - 7A5912042D648A6000EC51BA /* AutoCancellable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCancellable.swift; sourceTree = ""; }; 7A599C352C18AC7F00D47C18 /* ApiError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiError.swift; sourceTree = ""; }; 7A599C382C18B22900D47C18 /* FbRefreshTokenModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FbRefreshTokenModel.swift; sourceTree = ""; }; 7A599C3A2C18B36A00D47C18 /* FbVerifyTokenModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FbVerifyTokenModel.swift; sourceTree = ""; }; @@ -397,7 +400,6 @@ 7A64AE712469DFB600ABE48E /* MediaBrowserViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaBrowserViewController.swift; sourceTree = ""; }; 7A64AE722469DFB600ABE48E /* ContentTransformers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentTransformers.swift; sourceTree = ""; }; 7A659B5824A2B1BA0043A0F2 /* AudioRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecord.swift; sourceTree = ""; }; - 7A659B5A24A3768A0043A0F2 /* Substrings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Substrings.swift; sourceTree = ""; }; 7A6B65B22CFB0DB500AABA6B /* NullifyDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NullifyDate.swift; sourceTree = ""; }; 7A6DD902242BF4A5009DE740 /* PlateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlateView.swift; sourceTree = ""; }; 7A6DD90724329144009DE740 /* CenterTextLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CenterTextLayer.swift; sourceTree = ""; }; @@ -470,6 +472,12 @@ 7ABD1B462D044A3200B43213 /* GalleryScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryScreen.swift; sourceTree = ""; }; 7ABD1B482D044A4700B43213 /* GalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryViewModel.swift; sourceTree = ""; }; 7ABD1B4A2D044A7D00B43213 /* GalleryCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryCoordinator.swift; sourceTree = ""; }; + 7ABDA8022D8704F70083C715 /* VehicleRecordService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VehicleRecordService.swift; sourceTree = ""; }; + 7ABDA8042D8705210083C715 /* VehicleRecordServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VehicleRecordServiceProtocol.swift; sourceTree = ""; }; + 7ABDA8082D8710F80083C715 /* AutoCancellable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCancellable.swift; sourceTree = ""; }; + 7ABDA80A2D8715DC0083C715 /* VehicleRecordError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VehicleRecordError.swift; sourceTree = ""; }; + 7ABDA80C2D8721B10083C715 /* Substrings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Substrings.swift; sourceTree = ""; }; + 7ABDA80E2D8723F90083C715 /* StorageService+AudioRecords.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StorageService+AudioRecords.swift"; sourceTree = ""; }; 7AC3554B29696A1C00889457 /* MainTabController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabController.swift; sourceTree = ""; }; 7AC3554D29696C4500889457 /* DummyNewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DummyNewController.swift; sourceTree = ""; }; 7AC3554F29696D5A00889457 /* NewNumberController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewNumberController.swift; sourceTree = ""; }; @@ -794,7 +802,6 @@ 7ADF6CA02512244400F237B2 /* MapExt.swift */, 7ADF6C92250B954900F237B2 /* Navigation.swift */, 7A8A2208248D10EC0073DFD9 /* ResizeImage.swift */, - 7A659B5A24A3768A0043A0F2 /* Substrings.swift */, 7AE26A3224EEF9EC00625033 /* UIViewControllerExt.swift */, 7A761C0A267E8FF90005F28F /* Error.swift */, 7AC76D7A270083AE0084DB27 /* TextView.swift */, @@ -815,6 +822,7 @@ 7A45FB362C2706D000618694 /* Services */ = { isa = PBXGroup; children = ( + 7ABDA8012D8704C90083C715 /* VehicleRecordService */, 7A9519772D80B3B200E69883 /* AudioRecordService */, 7AB4E4392D3D3F390006D052 /* VehicleService */, 7A06E0B12C707DD7005731AC /* SettingsService */, @@ -1050,6 +1058,7 @@ 7A45FB372C27073700618694 /* StorageService.swift */, 7AB587332C42D3FA00FA7B66 /* StorageService+Notes.swift */, 7AA514DF2D0B75B3001CAC50 /* StorageService+Events.swift */, + 7ABDA80E2D8723F90083C715 /* StorageService+AudioRecords.swift */, 7A54BFD22D43B95E00176D6D /* DbUpdatePolicy.swift */, ); path = StorageService; @@ -1095,6 +1104,16 @@ path = GalleryScreen; sourceTree = ""; }; + 7ABDA8012D8704C90083C715 /* VehicleRecordService */ = { + isa = PBXGroup; + children = ( + 7ABDA8022D8704F70083C715 /* VehicleRecordService.swift */, + 7ABDA8042D8705210083C715 /* VehicleRecordServiceProtocol.swift */, + 7ABDA80A2D8715DC0083C715 /* VehicleRecordError.swift */, + ); + path = VehicleRecordService; + sourceTree = ""; + }; 7AC355552969742800889457 /* ACUIKit */ = { isa = PBXGroup; children = ( @@ -1148,6 +1167,7 @@ 7A96AE30246B2FE400297C33 /* Constants.swift */, 7A000AA124C2EEDE001F5B00 /* Location.swift */, 7A64A2232C1A07EA00284124 /* Formatters.swift */, + 7ABDA8082D8710F80083C715 /* AutoCancellable.swift */, ); path = Utils; sourceTree = ""; @@ -1161,6 +1181,7 @@ 7A1CF81529A42117007962DA /* Realm.swift */, 7A6B65B22CFB0DB500AABA6B /* NullifyDate.swift */, 7A809F382D66755B00CF1B3C /* Error+Canceled.swift */, + 7ABDA80C2D8721B10083C715 /* Substrings.swift */, ); path = Extensions; sourceTree = ""; @@ -1181,7 +1202,6 @@ 7AABBE3A2CF9F85600346588 /* Binding+Map.swift */, 7A912F362D381B7400002938 /* LicensePlateView.swift */, 7AB4E42B2D397D8E0006D052 /* VehicleCellView.swift */, - 7A5912042D648A6000EC51BA /* AutoCancellable.swift */, 7AC8B2752D6A01C700190706 /* UISearchTextField+Dumb.swift */, 7AB490282D6B1217002F39C6 /* ACKeyboardView.swift */, 7AB4902A2D6B1446002F39C6 /* ACKeyboardButton.swift */, @@ -1459,7 +1479,6 @@ 7AC3554E29696C4500889457 /* DummyNewController.swift in Sources */, 7A7158122C444A6400852088 /* AdsViewModel.swift in Sources */, 7A1E78FA2CE9005C0004B740 /* ReportCoordinator.swift in Sources */, - 7A659B5B24A3768A0043A0F2 /* Substrings.swift in Sources */, 7A71580E2C4445A200852088 /* AdsCoordinator.swift in Sources */, 7AB4E4662D58A16C0006D052 /* GenericError.swift in Sources */, 7AFBE8CA2C3081C7003C491D /* ACProgressHud+Modifiers.swift in Sources */, @@ -1520,7 +1539,6 @@ 7ABD1B472D044A3200B43213 /* GalleryScreen.swift in Sources */, 7ADF6C95250D037700F237B2 /* ShowEventController.swift in Sources */, 7A71580C2C44453200852088 /* AdsScreen.swift in Sources */, - 7A5912052D648A6000EC51BA /* AutoCancellable.swift in Sources */, 7A06E0B02C7065D8005731AC /* SettingsCoordinator.swift in Sources */, 7A91894F29A2BD8700519C74 /* GestureRecognizers.swift in Sources */, 7AFBE8CC2C3085C6003C491D /* ACProgressView.swift in Sources */, @@ -1564,11 +1582,14 @@ 7A5D84B92C1AD3C200C2209B /* DtoConvertible.swift in Sources */, 7AF6D2182677C1680086EA64 /* VehicleAd.swift in Sources */, 7A761C08267E8EA20005F28F /* JWT.swift in Sources */, + 7ABDA8032D8704F70083C715 /* VehicleRecordService.swift in Sources */, 7A64A2242C1A07EA00284124 /* Formatters.swift in Sources */, + 7ABDA8092D8710F80083C715 /* AutoCancellable.swift in Sources */, 7A5D84C02C1AE4DC00C2209B /* VehicleEngine.swift in Sources */, 7AB0EF812C5CC0FE00291EE6 /* SwiftLocationProtocol.swift in Sources */, 7AF6D2282677C2DC0086EA64 /* Constants.swift in Sources */, 7A64A2182C19E64800284124 /* VehicleOwnershipPeriodDto.swift in Sources */, + 7ABDA8052D8705210083C715 /* VehicleRecordServiceProtocol.swift in Sources */, 7A599C3B2C18B36A00D47C18 /* FbVerifyTokenModel.swift in Sources */, 7A64A2162C19E4CF00284124 /* VehiclePhotoDto.swift in Sources */, 7A6B65B32CFB0DB500AABA6B /* NullifyDate.swift in Sources */, @@ -1583,6 +1604,7 @@ 7A761C042677F18E0005F28F /* ApiService.swift in Sources */, 7A95197B2D80B41600E69883 /* AudioRecordServiceProtocol.swift in Sources */, 7AF6D21C2677C1680086EA64 /* DebugInfo.swift in Sources */, + 7ABDA80D2D8721B10083C715 /* Substrings.swift in Sources */, 7AF6D2122677C12E0086EA64 /* Location.swift in Sources */, 7AF6D2142677C1680086EA64 /* VehicleEvent.swift in Sources */, 7A64A2102C19E1EB00284124 /* VehicleBrandDto.swift in Sources */, @@ -1592,6 +1614,7 @@ 7AF6D21E2677C1680086EA64 /* PlateNumber.swift in Sources */, 7A5D84C62C1AE72E00C2209B /* VehicleName.swift in Sources */, 7A64A2122C19E2A100284124 /* VehicleModelDto.swift in Sources */, + 7ABDA80B2D8715DC0083C715 /* VehicleRecordError.swift in Sources */, 7A06E0B32C707E13005731AC /* SettingsServiceProtocol.swift in Sources */, 7A06E0B52C707E2B005731AC /* SettingsService.swift in Sources */, 7AF6D21F2677C1680086EA64 /* Response.swift in Sources */, @@ -1615,6 +1638,7 @@ 7A809F392D66755B00CF1B3C /* Error+Canceled.swift in Sources */, 7AF6D21D2677C1680086EA64 /* Osago.swift in Sources */, 7A1CF81629A42117007962DA /* Realm.swift in Sources */, + 7ABDA80F2D8723F90083C715 /* StorageService+AudioRecords.swift in Sources */, 7A64A2142C19E3B700284124 /* VehicleEngineDto.swift in Sources */, 7A761C052677F1BC0005F28F /* CocoaError.swift in Sources */, 7AF6D2132677C15A0086EA64 /* AudioRecord.swift in Sources */, diff --git a/AutoCat/Controllers/MainTabController.swift b/AutoCat/Controllers/MainTabController.swift index 5ff695d..7581303 100644 --- a/AutoCat/Controllers/MainTabController.swift +++ b/AutoCat/Controllers/MainTabController.swift @@ -18,7 +18,7 @@ class MainTabController: UITabBarController, UITabBarControllerDelegate { } addHistoryTab() - //addRecordsTab() + addRecordsTab() #if !targetEnvironment(macCatalyst) addDummyTab() diff --git a/AutoCat/SceneDelegate.swift b/AutoCat/SceneDelegate.swift index 6856158..012bc0a 100644 --- a/AutoCat/SceneDelegate.swift +++ b/AutoCat/SceneDelegate.swift @@ -61,7 +61,15 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { locationService: locationService) container.register(VehicleServiceProtocol.self, instance: vehicleService) - container.register(AudioRecordServiceProtocol.self, instance: AudioRecordService()) + let audioRecordService = AudioRecordService() + container.register(AudioRecordServiceProtocol.self, instance: audioRecordService) + + let vehicleRecordService = VehicleRecordService( + recordService: audioRecordService, + locationService: locationService, + settingsService: settingsService + ) + container.register(VehicleRecordServiceProtocol.self, instance: vehicleRecordService) } func setupRootController(scene: UIScene, openReport number: String?) { diff --git a/AutoCat/Screens/RecordsScreen/RecordsCoordinator.swift b/AutoCat/Screens/RecordsScreen/RecordsCoordinator.swift index 6a1ef19..b65eb96 100644 --- a/AutoCat/Screens/RecordsScreen/RecordsCoordinator.swift +++ b/AutoCat/Screens/RecordsScreen/RecordsCoordinator.swift @@ -19,9 +19,8 @@ final class RecordsCoordinator { let resolver = ServiceContainer.shared let viewModel = RecordsViewModel( - recordService: resolver.resolve(AudioRecordServiceProtocol.self), - storageService: resolver.resolve(StorageServiceProtocol.self), - locationService: resolver.resolve(LocationServiceProtocol.self) + recordService: resolver.resolve(VehicleRecordServiceProtocol.self), + storageService: resolver.resolve(StorageServiceProtocol.self) ) let view = RecordsScreen(viewModel: viewModel) diff --git a/AutoCat/Screens/RecordsScreen/RecordsScreen.swift b/AutoCat/Screens/RecordsScreen/RecordsScreen.swift index 21ca4ae..601eb4e 100644 --- a/AutoCat/Screens/RecordsScreen/RecordsScreen.swift +++ b/AutoCat/Screens/RecordsScreen/RecordsScreen.swift @@ -13,6 +13,33 @@ struct RecordsScreen: View { @State var viewModel: RecordsViewModel var body: some View { - Text("Hello, World!") + List { + ForEach(viewModel.records) { record in + Text(record.path) + } + } + .listStyle(.plain) + .hud($viewModel.hud) + .navigationTitle("Voice records") + .onAppear { + Task { await viewModel.onAppear() } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + Task { await viewModel.startRecording() } + } label: { + Image(systemName: "plus") + } + .alert("Recording...", isPresented: $viewModel.showRecordingAlert) { + Button("Cancel", role: .cancel) { + Task { await viewModel.cancelRecording() } + } + Button("Done") { + Task { await viewModel.stopRecording() } + } + } + } + } } } diff --git a/AutoCat/Screens/RecordsScreen/RecordsViewModel.swift b/AutoCat/Screens/RecordsScreen/RecordsViewModel.swift index ccdc6fe..97be2e4 100644 --- a/AutoCat/Screens/RecordsScreen/RecordsViewModel.swift +++ b/AutoCat/Screens/RecordsScreen/RecordsViewModel.swift @@ -11,18 +11,48 @@ import AutoCatCore @MainActor @Observable -final class RecordsViewModel { +final class RecordsViewModel: ACHudContainer { - let recordService: AudioRecordServiceProtocol + let recordService: VehicleRecordServiceProtocol let storageService: StorageServiceProtocol - let locationService: LocationServiceProtocol - init(recordService: AudioRecordServiceProtocol, - storageService: StorageServiceProtocol, - locationService: LocationServiceProtocol) { + var hud: ACHud? + var showRecordingAlert: Bool = false + var records: [AudioRecordDto] = [] + + init(recordService: VehicleRecordServiceProtocol, + storageService: StorageServiceProtocol) { self.recordService = recordService self.storageService = storageService - self.locationService = locationService + } + + func onAppear() async { + await wrapWithToast(showProgress: false) { [weak self] in + guard let self else { return } + records = try await storageService.loadRecords() + } + await recordService.requestPermissionsIfNeeded() + } + + func startRecording() async { + await wrapWithToast(showProgress: false) { [weak self] in + guard let self else { return } + try await recordService.startRecording() + showRecordingAlert = true + } + } + + func stopRecording() async { + await wrapWithToast { [weak self] in + guard let self else { return } + let record = try await recordService.stopRecording() + try await storageService.add(record: record) + records = try await storageService.loadRecords() + } + } + + func cancelRecording() async { + await recordService.cancelRecording() } } diff --git a/AutoCat/ru.lproj/Localizable.strings b/AutoCat/ru.lproj/Localizable.strings index 8b2327f..365c770 100644 --- a/AutoCat/ru.lproj/Localizable.strings +++ b/AutoCat/ru.lproj/Localizable.strings @@ -421,3 +421,5 @@ "Something went wrong" = "Что-то пошло не так"; "Are you sure you want to delete this event?" = "Вы уверены, что хотите удалить это событие?"; + +"Voice records" = "Голосовые записи"; diff --git a/AutoCat/Extensions/Substrings.swift b/AutoCatCore/Extensions/Substrings.swift similarity index 95% rename from AutoCat/Extensions/Substrings.swift rename to AutoCatCore/Extensions/Substrings.swift index 517983e..975637c 100644 --- a/AutoCat/Extensions/Substrings.swift +++ b/AutoCatCore/Extensions/Substrings.swift @@ -1,6 +1,6 @@ import Foundation -extension String { +public extension String { func index(from: Int) -> Index { return self.index(startIndex, offsetBy: from) } diff --git a/AutoCatCore/Models/DTO/AudioRecordDto.swift b/AutoCatCore/Models/DTO/AudioRecordDto.swift index 5fccb03..274c529 100644 --- a/AutoCatCore/Models/DTO/AudioRecordDto.swift +++ b/AutoCatCore/Models/DTO/AudioRecordDto.swift @@ -8,7 +8,7 @@ import Foundation -public struct AudioRecordDto: Decodable { +public struct AudioRecordDto: Decodable, Sendable { public var path: String = "" public var number: String? @@ -17,13 +17,20 @@ public struct AudioRecordDto: Decodable { public var duration: TimeInterval = 0 public var event: VehicleEventDto? - public init(path: String, number: String?, raw: String, duration: TimeInterval, event: VehicleEventDto?) { + public init( + path: String, + number: String?, + raw: String, + addedDate: TimeInterval = Date().timeIntervalSince1970, + duration: TimeInterval, + event: VehicleEventDto? + ) { self.path = path self.number = number self.duration = duration self.rawText = raw self.event = event - self.addedDate = Date().timeIntervalSince1970 + self.addedDate = addedDate } public func getAddedDate() -> TimeInterval { diff --git a/AutoCatCore/Services/AudioRecordService/AudioRecordService.swift b/AutoCatCore/Services/AudioRecordService/AudioRecordService.swift index d9b9570..ec7a6f2 100644 --- a/AutoCatCore/Services/AudioRecordService/AudioRecordService.swift +++ b/AutoCatCore/Services/AudioRecordService/AudioRecordService.swift @@ -9,7 +9,7 @@ import AVFoundation import Speech -public final class AudioRecordService { +public actor AudioRecordService { let audioFileSettings: [String : Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, @@ -22,9 +22,17 @@ public final class AudioRecordService { public init() { } +} + +extension AudioRecordService: AudioRecordServiceProtocol { + + public func requestRecordPermissions() async -> Bool { + + await AVAudioApplication.requestRecordPermission() + } @discardableResult - func requestRecognitionAuthorization() async -> SFSpeechRecognizerAuthorizationStatus { + public func requestRecognitionAuthorization() async -> SFSpeechRecognizerAuthorizationStatus { let status = SFSpeechRecognizer.authorizationStatus() guard status == .notDetermined else { @@ -37,15 +45,6 @@ public final class AudioRecordService { } } } -} - -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 { @@ -74,10 +73,21 @@ extension AudioRecordService: AudioRecordServiceProtocol { recorder?.record() } - public func stopRecording() { + public func stopRecording() async { - recorder?.stop() - recorder = nil + if recorder?.isRecording == true { + recorder?.stop() + } + self.recorder = nil + } + + public func cancelRecording() async { + + if recorder?.isRecording == true { + recorder?.stop() + recorder?.deleteRecording() + } + self.recorder = nil } public func recognizeText(from url: URL) async -> String? { @@ -87,6 +97,7 @@ extension AudioRecordService: AudioRecordServiceProtocol { } let request = SFSpeechURLRecognitionRequest(url: url) + request.shouldReportPartialResults = false return await withCheckedContinuation { continuation in recognizer.recognitionTask(with: request) { result, error in @@ -94,4 +105,11 @@ extension AudioRecordService: AudioRecordServiceProtocol { } } } + + public func getDuration(from url: URL) async throws -> TimeInterval { + + let asset = AVURLAsset(url: url) + let duration = try await asset.load(.duration) + return CMTimeGetSeconds(duration) + } } diff --git a/AutoCatCore/Services/AudioRecordService/AudioRecordServiceProtocol.swift b/AutoCatCore/Services/AudioRecordService/AudioRecordServiceProtocol.swift index edbe451..aebf4a1 100644 --- a/AutoCatCore/Services/AudioRecordService/AudioRecordServiceProtocol.swift +++ b/AutoCatCore/Services/AudioRecordService/AudioRecordServiceProtocol.swift @@ -6,12 +6,21 @@ // Copyright © 2025 Selim Mustafaev. All rights reserved. // -import Foundation +import Speech +import Mockable -public protocol AudioRecordServiceProtocol { +@Mockable +public protocol AudioRecordServiceProtocol: Sendable { + + @discardableResult + func requestRecordPermissions() async -> Bool + + @discardableResult + func requestRecognitionAuthorization() async -> SFSpeechRecognizerAuthorizationStatus - func requestPermissions() async -> Bool func startRecording(to url: URL) async throws - func stopRecording() + func stopRecording() async + func cancelRecording() async func recognizeText(from url: URL) async -> String? + func getDuration(from url: URL) async throws -> TimeInterval } diff --git a/AutoCatCore/Services/StorageService/StorageService+AudioRecords.swift b/AutoCatCore/Services/StorageService/StorageService+AudioRecords.swift new file mode 100644 index 0000000..7ad28ee --- /dev/null +++ b/AutoCatCore/Services/StorageService/StorageService+AudioRecords.swift @@ -0,0 +1,26 @@ +// +// StorageService+AudioRecords.swift +// AutoCatCore +// +// Created by Selim Mustafaev on 16.03.2025. +// Copyright © 2025 Selim Mustafaev. All rights reserved. +// + +import Foundation + +public extension StorageService { + + func add(record: AudioRecordDto) async throws { + + try await realm.asyncWrite { + realm.add(AudioRecord(dto: record)) + } + } + + func loadRecords() async throws -> [AudioRecordDto] { + + realm.objects(AudioRecord.self) + .sorted(byKeyPath: "addedDate", ascending: false) + .map(\.dto) + } + } diff --git a/AutoCatCore/Services/StorageService/StorageService+Notes.swift b/AutoCatCore/Services/StorageService/StorageService+Notes.swift index f956be1..4344beb 100644 --- a/AutoCatCore/Services/StorageService/StorageService+Notes.swift +++ b/AutoCatCore/Services/StorageService/StorageService+Notes.swift @@ -16,7 +16,7 @@ extension StorageService { throw StorageError.vehicleNotFound } - let note = VehicleNote(text: text, user: await settingsService.user.email) + let note = VehicleNote(text: text, user: settingsService.user.email) try await realm.asyncWrite { vehicle.notes.append(note) diff --git a/AutoCatCore/Services/StorageService/StorageServiceProtocol.swift b/AutoCatCore/Services/StorageService/StorageServiceProtocol.swift index c8c6779..63416ee 100644 --- a/AutoCatCore/Services/StorageService/StorageServiceProtocol.swift +++ b/AutoCatCore/Services/StorageService/StorageServiceProtocol.swift @@ -31,4 +31,8 @@ public protocol StorageServiceProtocol: Sendable { func add(event: VehicleEventDto, to number: String) async throws -> VehicleDto func remove(event id: String, from number: String) async throws -> VehicleDto func edit(event: VehicleEventDto, for number: String) async throws -> VehicleDto + + // Audio records + func add(record: AudioRecordDto) async throws + func loadRecords() async throws -> [AudioRecordDto] } diff --git a/AutoCatCore/Services/VehicleRecordService/VehicleRecordError.swift b/AutoCatCore/Services/VehicleRecordService/VehicleRecordError.swift new file mode 100644 index 0000000..4bc5c3e --- /dev/null +++ b/AutoCatCore/Services/VehicleRecordService/VehicleRecordError.swift @@ -0,0 +1,21 @@ +// +// VehicleRecordError.swift +// AutoCatCore +// +// Created by Selim Mustafaev on 16.03.2025. +// Copyright © 2025 Selim Mustafaev. All rights reserved. +// + +import Foundation + +enum VehicleRecordError: LocalizedError { + + case emptyUrl + + var errorDescription: String? { + switch self { + case .emptyUrl: + return "Can't find record url" + } + } +} diff --git a/AutoCatCore/Services/VehicleRecordService/VehicleRecordService.swift b/AutoCatCore/Services/VehicleRecordService/VehicleRecordService.swift new file mode 100644 index 0000000..653f154 --- /dev/null +++ b/AutoCatCore/Services/VehicleRecordService/VehicleRecordService.swift @@ -0,0 +1,142 @@ +// +// VehicleRecordService.swift +// AutoCatCore +// +// Created by Selim Mustafaev on 16.03.2025. +// Copyright © 2025 Selim Mustafaev. All rights reserved. +// + +import Foundation + +public actor VehicleRecordService { + + let recordService: AudioRecordServiceProtocol + let locationService: LocationServiceProtocol + let settingsService: SettingsServiceProtocol + + let validLetters = Constants.pnLettersMap.keys.map(String.init) + + var url: URL? + var date = Date() + var location: VehicleEventDto? + + @AutoCancellable + var locationTask: Task? + + public init( + recordService: AudioRecordServiceProtocol, + locationService: LocationServiceProtocol, + settingsService: SettingsServiceProtocol + ) { + self.recordService = recordService + self.locationService = locationService + self.settingsService = settingsService + } + + func getPlateNumber(from recognizedText: String?) -> String? { + guard let recognizedText else { + return nil + } + + let trimmed = recognizedText + .replacingOccurrences(of: " ", with: "") + .uppercased() + .replacingOccurrences(of: "Ф", with: "В") + .replacingOccurrences(of: "НОЛЬ", with: "0") + .replacingOccurrences(of: "Э", with: "") + + var result = "" + if let range = trimmed.range(of: #"\S\d\d\d\S\S\d\d\d?"#, options: .regularExpression) { + result = String(trimmed[range]) + } else if let range = trimmed.range(of: #"\S\S\S\d\d\d\d\d\d?"#, options: .regularExpression), settingsService.recognizeAlternativeOrder { + let n = String(trimmed[range]) + result = String(n.prefix(1)) + n.substring(with: 3..<6) + n.substring(with: 1..<3) + n.substring(from: 6) + } else if let range = trimmed.range(of: #"\S\d\d\d\S\S"#, options: .regularExpression), settingsService.recognizeShortenedNumbers { + result = String(trimmed[range]) + settingsService.defaultRegion + } else if let range = trimmed.range(of: #"\S\S\S\d\d\d"#, options: .regularExpression), settingsService.recognizeAlternativeOrder && settingsService.recognizeShortenedNumbers { + let n = String(trimmed[range]) + result = String(n.prefix(1)) + n.substring(with: 3..<6) + n.substring(with: 1..<3) + settingsService.defaultRegion + } + + if !result.isEmpty && valid(number: result) { + return result + } else { + return nil + } + } + + func valid(number: String) -> Bool { + guard number.count >= 8 else { return false } + + let first = String(number.prefix(1)) + let second = number.substring(with: 4..<5) + let third = number.substring(with: 5..<6) + let digits = Int(number.substring(with: 1..<4)) + let region = Int(number.substring(from: 6)) + + return self.validLetters.contains(first) + && self.validLetters.contains(second) + && self.validLetters.contains(third) + && digits != nil + && region != nil + && region! < 1000 + } +} + +extension VehicleRecordService: VehicleRecordServiceProtocol { + + public func requestPermissionsIfNeeded() async { + + await recordService.requestRecordPermissions() + await recordService.requestRecognitionAuthorization() + } + + public func startRecording() async throws { + + date = Date() + let fileName = "recording-\(date.timeIntervalSince1970).m4a" + let url = try FileManager.default.url(for: fileName, in: "recordings") + self.url = url + + try await recordService.startRecording(to: url) + locationTask = Task { + location = try await locationService.getRecentLocation() + } + } + + public func stopRecording() async throws -> AudioRecordDto { + guard let url else { + await recordService.cancelRecording() + throw VehicleRecordError.emptyUrl + } + + await recordService.stopRecording() + + async let recognitionTask = recordService.recognizeText(from: url) + async let durationTask = recordService.getDuration(from: url) + + let (text, duration) = await (recognitionTask, try? durationTask) + + locationTask?.cancel() + locationTask = nil + self.url = nil + + let record = AudioRecordDto( + path: url.lastPathComponent, + number: getPlateNumber(from: text), + raw: text ?? "", + duration: duration ?? 0, + event: location + ) + + return record + } + + public func cancelRecording() async { + + await recordService.cancelRecording() + locationTask?.cancel() + locationTask = nil + url = nil + } +} diff --git a/AutoCatCore/Services/VehicleRecordService/VehicleRecordServiceProtocol.swift b/AutoCatCore/Services/VehicleRecordService/VehicleRecordServiceProtocol.swift new file mode 100644 index 0000000..344bce7 --- /dev/null +++ b/AutoCatCore/Services/VehicleRecordService/VehicleRecordServiceProtocol.swift @@ -0,0 +1,17 @@ +// +// VehicleRecordServiceProtocol.swift +// AutoCatCore +// +// Created by Selim Mustafaev on 16.03.2025. +// Copyright © 2025 Selim Mustafaev. All rights reserved. +// + +import Foundation + +public protocol VehicleRecordServiceProtocol: Sendable { + + func requestPermissionsIfNeeded() async + func startRecording() async throws + func stopRecording() async throws -> AudioRecordDto + func cancelRecording() async +} diff --git a/AutoCat/SwiftUI/AutoCancellable.swift b/AutoCatCore/Utils/AutoCancellable.swift similarity index 74% rename from AutoCat/SwiftUI/AutoCancellable.swift rename to AutoCatCore/Utils/AutoCancellable.swift index e7de256..40afbb6 100644 --- a/AutoCat/SwiftUI/AutoCancellable.swift +++ b/AutoCatCore/Utils/AutoCancellable.swift @@ -9,9 +9,9 @@ import Foundation @propertyWrapper -struct AutoCancellable { +public struct AutoCancellable { - var wrappedValue: Task? { + public var wrappedValue: Task? { didSet { if let oldValue, oldValue.isCancelled { return @@ -20,7 +20,7 @@ struct AutoCancellable { } } - init(wrappedValue: Task?) { + public init(wrappedValue: Task?) { self.wrappedValue = wrappedValue } } diff --git a/AutoCatCore/Utils/Formatters.swift b/AutoCatCore/Utils/Formatters.swift index 66d3bd7..5d8c74b 100644 --- a/AutoCatCore/Utils/Formatters.swift +++ b/AutoCatCore/Utils/Formatters.swift @@ -11,9 +11,27 @@ import Foundation public struct Formatters { public static let standard: DateFormatter = { - let f = DateFormatter() - f.dateStyle = .medium - f.timeStyle = .medium - return f + + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .medium + return formatter + }() + + public static let short: DateFormatter = { + + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter + }() + + public static let time: DateComponentsFormatter = { + + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .abbreviated + formatter.allowedUnits = [.minute, .second] + formatter.zeroFormattingBehavior = .pad + return formatter }() } diff --git a/AutoCatCoreTests/VehicleRecordServiceTests.swift b/AutoCatCoreTests/VehicleRecordServiceTests.swift new file mode 100644 index 0000000..d9d2980 --- /dev/null +++ b/AutoCatCoreTests/VehicleRecordServiceTests.swift @@ -0,0 +1,54 @@ +// +// VehicleRecordServiceTests.swift +// AutoCatCoreTests +// +// Created by Selim Mustafaev on 16.03.2025. +// Copyright © 2025 Selim Mustafaev. All rights reserved. +// + +import Testing +import Mockable +import AutoCatCore + +struct VehicleRecordServiceTests { + + let storageServiceMock = MockStorageServiceProtocol() + let locationServiceMock: MockLocationServiceProtocol + let audioRecordServiceMock = MockAudioRecordServiceProtocol() + + let vehicleRecordService: VehicleRecordService + init() async { + + self.locationServiceMock = await .init() + + self.vehicleRecordService = .init( + recordService: audioRecordServiceMock, + storageService: storageServiceMock, + locationService: locationServiceMock + ) + } + + @Test("Requesting permissions") + func requestPermissions() async throws { + + given(audioRecordServiceMock) + .requestRecordPermissions() + .willReturn(true) + + given(audioRecordServiceMock) + .requestRecognitionAuthorization() + .willReturn(.authorized) + + await vehicleRecordService.requestPermissionsIfNeeded() + + verify(audioRecordServiceMock) + .requestRecordPermissions() + .called(.once) + + verify(audioRecordServiceMock) + .requestRecognitionAuthorization() + .called(.once) + } + + +}