diff --git a/AutoCat.xcodeproj/project.pbxproj b/AutoCat.xcodeproj/project.pbxproj index 6980197..c1ddc18 100644 --- a/AutoCat.xcodeproj/project.pbxproj +++ b/AutoCat.xcodeproj/project.pbxproj @@ -11,6 +11,9 @@ 7A0516162414EC1200FC55AC /* Differentiator in Frameworks */ = {isa = PBXBuildFile; productRef = 7A0516152414EC1200FC55AC /* Differentiator */; }; 7A0516182414EC1200FC55AC /* RxDataSources in Frameworks */ = {isa = PBXBuildFile; productRef = 7A0516172414EC1200FC55AC /* RxDataSources */; }; 7A05161A2414FF0900FC55AC /* DateSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0516192414FF0900FC55AC /* DateSection.swift */; }; + 7A1090E824A394F100B4F0B2 /* AudioRecordCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1090E724A394F100B4F0B2 /* AudioRecordCell.swift */; }; + 7A1090EA24A3A26300B4F0B2 /* AudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1090E924A3A26300B4F0B2 /* AudioPlayer.swift */; }; + 7A1090EC24A4E3E100B4F0B2 /* CellProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1090EB24A4E3E100B4F0B2 /* CellProgressView.swift */; }; 7A11470123FDE7E500B424AF /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11470023FDE7E500B424AF /* AppDelegate.swift */; }; 7A11470323FDE7E500B424AF /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11470223FDE7E500B424AF /* SceneDelegate.swift */; }; 7A11470823FDE7E500B424AF /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 7A11470623FDE7E500B424AF /* Main.storyboard */; }; @@ -31,11 +34,19 @@ 7A11474923FF2B2D00B424AF /* Response.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11474823FF2B2D00B424AF /* Response.swift */; }; 7A11474B23FF368B00B424AF /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11474A23FF368B00B424AF /* Settings.swift */; }; 7A27ADC7249D43210035F39E /* RegionsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A27ADC6249D43210035F39E /* RegionsController.swift */; }; + 7A27ADF3249F8B650035F39E /* RecordsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A27ADF2249F8B650035F39E /* RecordsController.swift */; }; + 7A27ADF5249FD2F90035F39E /* FileManagerExt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A27ADF4249FD2F90035F39E /* FileManagerExt.swift */; }; + 7A27ADF7249FEF690035F39E /* Recorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A27ADF6249FEF690035F39E /* Recorder.swift */; }; + 7A27ADF924A09CAD0035F39E /* CocoaError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A27ADF824A09CAD0035F39E /* CocoaError.swift */; }; 7A33381124990DAE00D878F1 /* FiltersController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A33381024990DAE00D878F1 /* FiltersController.swift */; }; 7A333814249A532400D878F1 /* Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A333813249A532400D878F1 /* Filter.swift */; }; 7A3F07AB24360DC800E59687 /* Dated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3F07AA24360DC800E59687 /* Dated.swift */; }; 7A3F07AD2436350B00E59687 /* SearchController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3F07AC2436350B00E59687 /* SearchController.swift */; }; 7A43F9F8246C8A6200BA5B49 /* JWT.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A43F9F7246C8A6200BA5B49 /* JWT.swift */; }; + 7A488C3C24A74B990054D0B2 /* RxTableViewRealmDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A488C3824A74B990054D0B2 /* RxTableViewRealmDataSource.swift */; }; + 7A488C3D24A74B990054D0B2 /* RxCollectionViewRealmDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A488C3924A74B990054D0B2 /* RxCollectionViewRealmDataSource.swift */; }; + 7A488C3E24A74B990054D0B2 /* Reactive+RxRealmDataSources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A488C3A24A74B990054D0B2 /* Reactive+RxRealmDataSources.swift */; }; + 7A488C3F24A74B990054D0B2 /* RealmBindObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A488C3B24A74B990054D0B2 /* RealmBindObserver.swift */; }; 7A530B78240010D900CBFE6E /* InputMask in Frameworks */ = {isa = PBXBuildFile; productRef = 7A530B77240010D900CBFE6E /* InputMask */; }; 7A530B7A24001D3300CBFE6E /* CheckController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A530B7924001D3300CBFE6E /* CheckController.swift */; }; 7A530B7E24017FEE00CBFE6E /* VehicleCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A530B7D24017FEE00CBFE6E /* VehicleCell.swift */; }; @@ -51,6 +62,8 @@ 7A64AE812469E16100ABE48E /* ProgressAnimatedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A64AE7B2469E16100ABE48E /* ProgressAnimatedView.swift */; }; 7A64AE822469E16100ABE48E /* IHProgressHUD.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A64AE7C2469E16100ABE48E /* IHProgressHUD.swift */; }; 7A64AE832469E16100ABE48E /* IHProgressHUD.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 7A64AE7D2469E16100ABE48E /* IHProgressHUD.bundle */; }; + 7A659B5924A2B1BA0043A0F2 /* AudioRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A659B5824A2B1BA0043A0F2 /* AudioRecord.swift */; }; + 7A659B5B24A3768A0043A0F2 /* Substrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A659B5A24A3768A0043A0F2 /* Substrings.swift */; }; 7A6DD903242BF4A5009DE740 /* PlateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6DD902242BF4A5009DE740 /* PlateView.swift */; }; 7A6DD90824329144009DE740 /* CenterTextLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6DD90724329144009DE740 /* CenterTextLayer.swift */; }; 7A6DD90A24329541009DE740 /* RoadNumbers2.0.otf in Resources */ = {isa = PBXBuildFile; fileRef = 7A6DD90924329541009DE740 /* RoadNumbers2.0.otf */; }; @@ -80,6 +93,9 @@ /* Begin PBXFileReference section */ 7A0516192414FF0900FC55AC /* DateSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateSection.swift; sourceTree = ""; }; + 7A1090E724A394F100B4F0B2 /* AudioRecordCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecordCell.swift; sourceTree = ""; }; + 7A1090E924A3A26300B4F0B2 /* AudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayer.swift; sourceTree = ""; }; + 7A1090EB24A4E3E100B4F0B2 /* CellProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellProgressView.swift; sourceTree = ""; }; 7A1146FD23FDE7E500B424AF /* AutoCat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AutoCat.app; sourceTree = BUILT_PRODUCTS_DIR; }; 7A11470023FDE7E500B424AF /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7A11470223FDE7E500B424AF /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -96,11 +112,19 @@ 7A11474A23FF368B00B424AF /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; 7A11474D23FFEE8800B424AF /* SVProgressHUD.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SVProgressHUD.framework; path = Carthage/Build/iOS/SVProgressHUD.framework; sourceTree = ""; }; 7A27ADC6249D43210035F39E /* RegionsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegionsController.swift; sourceTree = ""; }; + 7A27ADF2249F8B650035F39E /* RecordsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordsController.swift; sourceTree = ""; }; + 7A27ADF4249FD2F90035F39E /* FileManagerExt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManagerExt.swift; sourceTree = ""; }; + 7A27ADF6249FEF690035F39E /* Recorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Recorder.swift; sourceTree = ""; }; + 7A27ADF824A09CAD0035F39E /* CocoaError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CocoaError.swift; sourceTree = ""; }; 7A33381024990DAE00D878F1 /* FiltersController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersController.swift; sourceTree = ""; }; 7A333813249A532400D878F1 /* Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Filter.swift; sourceTree = ""; }; 7A3F07AA24360DC800E59687 /* Dated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dated.swift; sourceTree = ""; }; 7A3F07AC2436350B00E59687 /* SearchController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchController.swift; sourceTree = ""; }; 7A43F9F7246C8A6200BA5B49 /* JWT.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JWT.swift; sourceTree = ""; }; + 7A488C3824A74B990054D0B2 /* RxTableViewRealmDataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RxTableViewRealmDataSource.swift; sourceTree = ""; }; + 7A488C3924A74B990054D0B2 /* RxCollectionViewRealmDataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RxCollectionViewRealmDataSource.swift; sourceTree = ""; }; + 7A488C3A24A74B990054D0B2 /* Reactive+RxRealmDataSources.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Reactive+RxRealmDataSources.swift"; sourceTree = ""; }; + 7A488C3B24A74B990054D0B2 /* RealmBindObserver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RealmBindObserver.swift; sourceTree = ""; }; 7A530B7924001D3300CBFE6E /* CheckController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckController.swift; sourceTree = ""; }; 7A530B7D24017FEE00CBFE6E /* VehicleCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VehicleCell.swift; sourceTree = ""; }; 7A530B7F2401803A00CBFE6E /* Vehicle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Vehicle.swift; sourceTree = ""; }; @@ -115,6 +139,8 @@ 7A64AE7B2469E16100ABE48E /* ProgressAnimatedView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgressAnimatedView.swift; sourceTree = ""; }; 7A64AE7C2469E16100ABE48E /* IHProgressHUD.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IHProgressHUD.swift; sourceTree = ""; }; 7A64AE7D2469E16100ABE48E /* IHProgressHUD.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = IHProgressHUD.bundle; 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 = ""; }; 7A6DD902242BF4A5009DE740 /* PlateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlateView.swift; sourceTree = ""; }; 7A6DD90724329144009DE740 /* CenterTextLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CenterTextLayer.swift; sourceTree = ""; }; 7A6DD90924329541009DE740 /* RoadNumbers2.0.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = RoadNumbers2.0.otf; sourceTree = ""; }; @@ -219,6 +245,7 @@ 7A6E03272485951700DB22ED /* OwnersController.swift */, 7A33381024990DAE00D878F1 /* FiltersController.swift */, 7A27ADC6249D43210035F39E /* RegionsController.swift */, + 7A27ADF2249F8B650035F39E /* RecordsController.swift */, ); path = Controllers; sourceTree = ""; @@ -226,6 +253,7 @@ 7A11472C23FECA3E00B424AF /* ThirdParty */ = { isa = PBXGroup; children = ( + 7A488C3724A74B990054D0B2 /* RxRealmDataSources */, 7A64AE772469E16100ABE48E /* IHProgressHUD */, 7A64AE6E2469DFB600ABE48E /* ATGMediaBrowser */, 7A6DD90724329144009DE740 /* CenterTextLayer.swift */, @@ -240,6 +268,8 @@ 7A96AE30246B2FE400297C33 /* Constants.swift */, 7A43F9F7246C8A6200BA5B49 /* JWT.swift */, 7A11474323FF06CA00B424AF /* Api.swift */, + 7A27ADF6249FEF690035F39E /* Recorder.swift */, + 7A1090E924A3A26300B4F0B2 /* AudioPlayer.swift */, ); path = Utils; sourceTree = ""; @@ -255,6 +285,7 @@ 7A6DD90D24337930009DE740 /* PlateNumber.swift */, 7A333813249A532400D878F1 /* Filter.swift */, 7AB562B9249C9E9B00473D53 /* Region.swift */, + 7A659B5824A2B1BA0043A0F2 /* AudioRecord.swift */, ); path = Models; sourceTree = ""; @@ -275,10 +306,24 @@ 7A3F07AA24360DC800E59687 /* Dated.swift */, 7A8A2208248D10EC0073DFD9 /* ResizeImage.swift */, 7A8A220A248D67B60073DFD9 /* VehicleReportImage.swift */, + 7A27ADF4249FD2F90035F39E /* FileManagerExt.swift */, + 7A27ADF824A09CAD0035F39E /* CocoaError.swift */, + 7A659B5A24A3768A0043A0F2 /* Substrings.swift */, ); path = Extensions; sourceTree = ""; }; + 7A488C3724A74B990054D0B2 /* RxRealmDataSources */ = { + isa = PBXGroup; + children = ( + 7A488C3824A74B990054D0B2 /* RxTableViewRealmDataSource.swift */, + 7A488C3924A74B990054D0B2 /* RxCollectionViewRealmDataSource.swift */, + 7A488C3A24A74B990054D0B2 /* Reactive+RxRealmDataSources.swift */, + 7A488C3B24A74B990054D0B2 /* RealmBindObserver.swift */, + ); + path = RxRealmDataSources; + sourceTree = ""; + }; 7A530B7C24017FBE00CBFE6E /* Cells */ = { isa = PBXGroup; children = ( @@ -288,6 +333,7 @@ 7A7547DB2403180A004E8406 /* SectionHeader.swift */, 7A7547DC2403180A004E8406 /* SectionHeader.xib */, 7A7547DF24032CB6004E8406 /* VehiclePhotoCell.swift */, + 7A1090E724A394F100B4F0B2 /* AudioRecordCell.swift */, ); path = Cells; sourceTree = ""; @@ -323,6 +369,7 @@ 7A6DD90B24335A6D009DE740 /* FlagLayer.swift */, 7AB67E8B2435C38700258F61 /* CustomTextField.swift */, 7AB67E8D2435D1A000258F61 /* CustomButton.swift */, + 7A1090EB24A4E3E100B4F0B2 /* CellProgressView.swift */, ); path = Views; sourceTree = ""; @@ -446,16 +493,24 @@ 7A96AE31246B2FE400297C33 /* Constants.swift in Sources */, 7A64AE822469E16100ABE48E /* IHProgressHUD.swift in Sources */, 7A11470123FDE7E500B424AF /* AppDelegate.swift in Sources */, + 7A27ADF924A09CAD0035F39E /* CocoaError.swift in Sources */, 7A6DD90824329144009DE740 /* CenterTextLayer.swift in Sources */, 7A3F07AD2436350B00E59687 /* SearchController.swift in Sources */, 7AB562BA249C9E9B00473D53 /* Region.swift in Sources */, + 7A659B5924A2B1BA0043A0F2 /* AudioRecord.swift in Sources */, + 7A488C3C24A74B990054D0B2 /* RxTableViewRealmDataSource.swift in Sources */, 7A6DD90C24335A6D009DE740 /* FlagLayer.swift in Sources */, 7AB67E8C2435C38700258F61 /* CustomTextField.swift in Sources */, + 7A27ADF5249FD2F90035F39E /* FileManagerExt.swift in Sources */, + 7A27ADF3249F8B650035F39E /* RecordsController.swift in Sources */, 7A8A2209248D10EC0073DFD9 /* ResizeImage.swift in Sources */, 7A6DD90E24337930009DE740 /* PlateNumber.swift in Sources */, + 7A659B5B24A3768A0043A0F2 /* Substrings.swift in Sources */, 7AEFE728240455E200910EB7 /* SettingsController.swift in Sources */, + 7A27ADF7249FEF690035F39E /* Recorder.swift in Sources */, 7A3F07AB24360DC800E59687 /* Dated.swift in Sources */, 7A33381124990DAE00D878F1 /* FiltersController.swift in Sources */, + 7A1090E824A394F100B4F0B2 /* AudioRecordCell.swift in Sources */, 7A11474923FF2B2D00B424AF /* Response.swift in Sources */, 7A64AE762469DFB600ABE48E /* ContentTransformers.swift in Sources */, 7A11471823FDEBFA00B424AF /* ReportController.swift in Sources */, @@ -465,19 +520,24 @@ 7A530B7A24001D3300CBFE6E /* CheckController.swift in Sources */, 7A6E03282485951700DB22ED /* OwnersController.swift in Sources */, 7A64AE742469DFB600ABE48E /* MediaContentView.swift in Sources */, + 7A1090EC24A4E3E100B4F0B2 /* CellProgressView.swift in Sources */, 7A7547DD2403180A004E8406 /* SectionHeader.swift in Sources */, 7AF58D58240309CA00CE01A0 /* VehicleTextParamCell.swift in Sources */, 7A96AE2D246B2B7400297C33 /* GoogleSignInController.swift in Sources */, + 7A1090EA24A3A26300B4F0B2 /* AudioPlayer.swift in Sources */, 7A11474723FF2AA500B424AF /* User.swift in Sources */, 7A11471623FDEB2A00B424AF /* MainSplitController.swift in Sources */, 7AF58D3124029E1000CE01A0 /* VehicleHeaderCell.swift in Sources */, 7A43F9F8246C8A6200BA5B49 /* JWT.swift in Sources */, 7A6DD903242BF4A5009DE740 /* PlateView.swift in Sources */, + 7A488C3F24A74B990054D0B2 /* RealmBindObserver.swift in Sources */, 7A11470323FDE7E500B424AF /* SceneDelegate.swift in Sources */, 7A530B7E24017FEE00CBFE6E /* VehicleCell.swift in Sources */, 7A11474423FF06CA00B424AF /* Api.swift in Sources */, + 7A488C3D24A74B990054D0B2 /* RxCollectionViewRealmDataSource.swift in Sources */, 7AB67E8E2435D1A000258F61 /* CustomButton.swift in Sources */, 7A8A220B248D67B60073DFD9 /* VehicleReportImage.swift in Sources */, + 7A488C3E24A74B990054D0B2 /* Reactive+RxRealmDataSources.swift in Sources */, 7A27ADC7249D43210035F39E /* RegionsController.swift in Sources */, 7A05161A2414FF0900FC55AC /* DateSection.swift in Sources */, 7A333814249A532400D878F1 /* Filter.swift in Sources */, @@ -722,7 +782,7 @@ repositoryURL = "https://github.com/realm/realm-cocoa"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 4.3.2; + minimumVersion = 5.0.0; }; }; 7A11472923FEA24D00B424AF /* XCRemoteSwiftPackageReference "Action" */ = { @@ -746,7 +806,7 @@ repositoryURL = "https://github.com/RxSwiftCommunity/RxRealm"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 2.0.0; + minimumVersion = 3.0.0; }; }; 7A8A220C248EF5830073DFD9 /* XCRemoteSwiftPackageReference "Swift-JWT" */ = { diff --git a/AutoCat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AutoCat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a2048b0..d92caf4 100644 --- a/AutoCat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AutoCat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -87,8 +87,8 @@ "repositoryURL": "https://github.com/airbnb/MagazineLayout", "state": { "branch": null, - "revision": "4a91fb2fa75a3c498748466227fa115fd27bb100", - "version": "1.6.0" + "revision": "12dd2cc84b7f17c4f46c7d95cde64d521c588ee8", + "version": "1.6.1" } }, { @@ -96,8 +96,8 @@ "repositoryURL": "https://github.com/realm/realm-cocoa", "state": { "branch": null, - "revision": "fa43b8e2909334c79f233ce472332c136ca108da", - "version": "4.4.1" + "revision": "b3fa932233bfa53966c373933d60157545a3f09f", + "version": "5.1.0" } }, { @@ -105,8 +105,8 @@ "repositoryURL": "https://github.com/realm/realm-core", "state": { "branch": null, - "revision": "35662ff940e340bf630ad1d1d88acfc7af18bee6", - "version": "5.23.8" + "revision": "bc900a2a8e05722c1b42f95396adb3c99eeb500f", + "version": "6.0.6" } }, { @@ -123,8 +123,8 @@ "repositoryURL": "https://github.com/RxSwiftCommunity/RxRealm", "state": { "branch": null, - "revision": "70188d79fe2eb19b5013dd1deae33e9e53f10e76", - "version": "2.0.0" + "revision": "c4dcc49acbf8073a8a6481b571c640bb169650f1", + "version": "3.0.1" } }, { diff --git a/AutoCat.xcodeproj/xcuserdata/selim.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/AutoCat.xcodeproj/xcuserdata/selim.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index 4faad41..0432c6c 100644 --- a/AutoCat.xcodeproj/xcuserdata/selim.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/AutoCat.xcodeproj/xcuserdata/selim.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -40,5 +40,28 @@ landmarkType = "7"> + + + + + + + + diff --git a/AutoCat/AppDelegate.swift b/AutoCat/AppDelegate.swift index 4bdeede..4dd6c85 100644 --- a/AutoCat/AppDelegate.swift +++ b/AutoCat/AppDelegate.swift @@ -1,5 +1,7 @@ import UIKit import RealmSwift +import RxSwift +import RxCocoa import os.log extension OSLog { @@ -9,6 +11,7 @@ extension OSLog { enum QuickAction { case none case check + case addVoiceRecord } @UIApplicationMain @@ -19,7 +22,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { let config = Realm.Configuration( - schemaVersion: 6, + schemaVersion: 8, migrationBlock: { migration, oldSchemaVersion in if oldSchemaVersion <= 3 { var numbers: [String] = [] @@ -36,10 +39,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { }) Realm.Configuration.defaultConfiguration = config + print(Realm.Configuration.defaultConfiguration.fileURL!) IHProgressHUD.set(defaultStyle: .dark) IHProgressHUD.set(defaultMaskType: .black) + Logging.URLRequests = { _ in false }; + return true } @@ -54,6 +60,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { if let shortcutItem = options.shortcutItem { if shortcutItem.type == "CheckNumberAction" { self.quickAction = .check + } else if shortcutItem.type == "AddVoiceRecordAction" { + self.quickAction = .addVoiceRecord } } diff --git a/AutoCat/AutoCat.entitlements b/AutoCat/AutoCat.entitlements index ee95ab7..afafa97 100644 --- a/AutoCat/AutoCat.entitlements +++ b/AutoCat/AutoCat.entitlements @@ -4,6 +4,8 @@ com.apple.security.app-sandbox + com.apple.security.device.audio-input + com.apple.security.network.client diff --git a/AutoCat/Base.lproj/Main.storyboard b/AutoCat/Base.lproj/Main.storyboard index 477e393..8887d2a 100644 --- a/AutoCat/Base.lproj/Main.storyboard +++ b/AutoCat/Base.lproj/Main.storyboard @@ -193,7 +193,7 @@ - + @@ -208,7 +208,7 @@ - + @@ -380,7 +380,105 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -552,6 +650,7 @@ + @@ -634,7 +733,7 @@ - + @@ -647,7 +746,7 @@ - + @@ -703,7 +802,26 @@ - + + + + + + + + + + + + + + + + + + + + @@ -713,5 +831,7 @@ + + diff --git a/AutoCat/Cells/AudioRecordCell.swift b/AutoCat/Cells/AudioRecordCell.swift new file mode 100644 index 0000000..6d0c9cd --- /dev/null +++ b/AutoCat/Cells/AudioRecordCell.swift @@ -0,0 +1,80 @@ +import UIKit +import RxSwift + +class AudioRecordCell: UITableViewCell { + + @IBOutlet weak var playButton: UIButton! + @IBOutlet weak var duration: UILabel! + @IBOutlet weak var number: UILabel! + @IBOutlet weak var date: UILabel! + @IBOutlet weak var progressView: CellProgressView! + + let dateFormatter = DateFormatter() + let componentsFormatter = DateComponentsFormatter() + var stateDisposable: Disposable? + var progressDisposable: Disposable? + + var record: AudioRecord? + + override func awakeFromNib() { + super.awakeFromNib() + + self.dateFormatter.dateStyle = .short + self.dateFormatter.timeStyle = .short + + self.componentsFormatter.unitsStyle = .abbreviated + self.componentsFormatter.allowedUnits = [.minute, .second] + self.componentsFormatter.zeroFormattingBehavior = .pad + + self.progressView.progress = 0 + } + + override func prepareForReuse() { + super.prepareForReuse() + self.record = nil + self.stateDisposable?.dispose() + self.progressDisposable?.dispose() + self.progressView.progress = 0 + } + + func configure(with record: AudioRecord) { + self.record = record + self.date.text = self.dateFormatter.string(from: Date(timeIntervalSince1970: record.addedDate)) + self.number.text = record.number ?? "Unrecognized" + self.duration.text = self.componentsFormatter.string(from: record.duration) + + self.stateDisposable = AudioPlayer.shared + .stateObservable() + .filter { _ in AudioPlayer.shared.getUrl()?.lastPathComponent == record.path } + .subscribe(onNext: { state in + let imgName = state == .playing ? "pause.fill" : "play.fill" + self.playButton.setImage(UIImage(systemName: imgName), for: .normal) + + if state == .stopped { + self.progressView.progress = 0 + } + }, onDisposed: { + self.playButton.setImage(UIImage(systemName: "play.fill"), for: .normal) + }) + + self.progressDisposable = AudioPlayer.shared + .progressObservable() + .filter { _ in AudioPlayer.shared.getUrl()?.lastPathComponent == record.path } + .subscribe(onNext: { progress in + self.progressView.progress = progress + }, onDisposed: { + self.progressView.progress = 0 + }) + } + + @IBAction func onPlay(_ sender: UIButton) { + if let record = self.record { + do { + let url = try FileManager.default.url(for: record.path, in: "recordings") + try AudioPlayer.shared.play(url: url) + } catch { + print("Error playing audio record: \(error.localizedDescription)") + } + } + } +} diff --git a/AutoCat/Controllers/CheckController.swift b/AutoCat/Controllers/CheckController.swift index 5bebb06..6024f10 100644 --- a/AutoCat/Controllers/CheckController.swift +++ b/AutoCat/Controllers/CheckController.swift @@ -42,12 +42,13 @@ class CheckController: UIViewController, MaskedTextFieldDelegateListener, UITabl .subscribe(onNext: self.updateDetailController(with:)) .disposed(by: self.bag) - - Observable.collection(from: realm.objects(Vehicle.self) - .sorted(byKeyPath: "addedDate", ascending: false)) - .map { $0.groupedByDate() } - .bind(to: self.history.rx.items(dataSource: ds)) - .disposed(by: self.bag) + DispatchQueue.main.async { + Observable.collection(from: realm.objects(Vehicle.self) + .sorted(byKeyPath: "addedDate", ascending: false)) + .map { $0.groupedByDate() } + .bind(to: self.history.rx.items(dataSource: ds)) + .disposed(by: self.bag) + } self.history.rx.setDelegate(self).disposed(by: self.bag) } @@ -75,6 +76,8 @@ class CheckController: UIViewController, MaskedTextFieldDelegateListener, UITabl if ad.quickAction == .check { ad.quickAction = .none self.number.becomeFirstResponder() + } else if ad.quickAction == .addVoiceRecord { + self.tabBarController?.selectedIndex = 1 } } diff --git a/AutoCat/Controllers/RecordsController.swift b/AutoCat/Controllers/RecordsController.swift new file mode 100644 index 0000000..05e1fb3 --- /dev/null +++ b/AutoCat/Controllers/RecordsController.swift @@ -0,0 +1,182 @@ +import UIKit +import AVFoundation +import RealmSwift +import RxSwift +import RxRealm +import RxDataSources + +class RecordsController: UIViewController, UITableViewDelegate { + + @IBOutlet weak var tableView: UITableView! + + var recorder: Recorder? + var addButton: UIBarButtonItem! + var cancelButton: UIBarButtonItem! + let bag = DisposeBag() + + override func viewDidLoad() { + super.viewDidLoad() + + guard let realm = try? Realm() else { return } + + self.addButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(onAddVoiceRecord(_:))) + self.cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(onCancelRecording(_:))) + self.navigationItem.rightBarButtonItem = self.addButton + + self.recorder = try? Recorder() + + let ds = RxTableViewSectionedAnimatedDataSource>(configureCell: { dataSource, tableView, indexPath, item in + if let cell = tableView.dequeueReusableCell(withIdentifier: "AudioRecordCell", for: indexPath) as? AudioRecordCell { + cell.configure(with: item) + return cell + } else { + return UITableViewCell() + } + }, canEditRowAtIndexPath: { _, _ in true }) + + ds.titleForHeaderInSection = { dataSourse, index in + return dataSourse.sectionModels[index].header + } + + self.tableView.rx + .modelDeleted(AudioRecord.self) + .subscribe(onNext: { record in + try? realm.write { + realm.delete(record) + } + }) + .disposed(by: self.bag) + + DispatchQueue.main.async { + Observable.collection(from: realm.objects(AudioRecord.self) + .sorted(byKeyPath: "addedDate", ascending: false)) + .map { $0.groupedByDate() } + .bind(to: self.tableView.rx.items(dataSource: ds)) + .disposed(by: self.bag) + } + + self.tableView.rx.setDelegate(self).disposed(by: self.bag) + } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + self.handleQuickActions() + } + + func handleQuickActions() { + guard let ad = UIApplication.shared.delegate as? AppDelegate else { return } + + if ad.quickAction == .addVoiceRecord { + ad.quickAction = .none + if let addButton = self.navigationItem.rightBarButtonItem { + self.onAddVoiceRecord(addButton) + } + } + } + + // MARK: - Bar button handlers + + @objc func onAddVoiceRecord(_ sender: UIBarButtonItem) { + guard let recorder = self.recorder else { + IHProgressHUD.showError(withStatus: "Audio recorder is not available") + return + } + + recorder.requestPermissions { error in + DispatchQueue.main.async { + if let error = error { + self.show(error: error) + } else { + do { + let date = Date() + let fileName = "recording-\(date.timeIntervalSince1970).m4a" + let url = try FileManager.default.url(for: fileName, in: "recordings") + try recorder.startRecording(to: url) { result in + self.navigationItem.rightBarButtonItem?.isEnabled = true + self.navigationItem.leftBarButtonItem = nil + self.title = "Voice recordings" + + let asset = AVURLAsset(url: url) + let duration = TimeInterval(CMTimeGetSeconds(asset.duration)) + let record = AudioRecord(path: url.lastPathComponent, number: self.getPlateNumber(from: result), duration: duration) + let realm = try? Realm() + try? realm?.write { + realm?.add(record) + } + } + self.title = "Recording..." + self.navigationItem.rightBarButtonItem?.isEnabled = false + self.navigationItem.leftBarButtonItem = self.cancelButton + } catch { + IHProgressHUD.showError(withStatus: error.localizedDescription) + } + } + } + } + } + + @objc func onCancelRecording(_ sender: UIBarButtonItem) { + self.recorder?.cancelRecording() + self.navigationItem.rightBarButtonItem?.isEnabled = true + self.navigationItem.leftBarButtonItem = nil + self.title = "Voice recordings" + } + + // MARK: - Processing + + func getPlateNumber(from recognizedText: String) -> String? { + let trimmed = recognizedText.replacingOccurrences(of: " ", with: "").uppercased() + if let range = trimmed.range(of: #"\S\d\d\d\S\S\d\d\d?"#, options: .regularExpression) { + return String(trimmed[range]) + } else if let range = trimmed.range(of: #"\S\S\S\d\d\d\d\d\d?"#, options: .regularExpression) { + let n = String(trimmed[range]) + return 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\d\d\d?"#, options: .regularExpression) { + return String(trimmed[range]) + "161" + } else if let range = trimmed.range(of: #"\S\S\S\d\d\d"#, options: .regularExpression) { + let n = String(trimmed[range]) + return n.prefix(1) + n.substring(with: 3..<6) + n.substring(with: 1..<3) + "161" + } + + return nil + } + + // MARK: - UITableViewDelegate + + func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + guard let record: AudioRecord = try? self.tableView.rx.model(at: indexPath) else { return nil } + + /* + let deleteAction = UIContextualAction(style: .normal, title: "Delete") { action, view, completion in + do { + let realm = try Realm() + try realm.write { + realm.delete(record) + } + completion(true) + } catch { + print("Error deleting audio record: \(error.localizedDescription)") + completion(false) + } + } + deleteAction.image = UIImage(systemName: "trash") + deleteAction.backgroundColor = .systemRed + */ + + let check = UIContextualAction(style: .normal, title: "Check") { action, view, completion in + completion(true) + } + check.backgroundColor = .systemGray2 + + let share = UIContextualAction(style: .normal, title: "Share") { action, view, completion in + completion(true) + } + share.backgroundColor = .systemGray2 + + let delete = UIContextualAction(style: .destructive, title: "Delete") { action, view, completion in + self.tableView.dataSource?.tableView!(self.tableView, commit: .delete, forRowAt: indexPath) + completion(true) + } + + return UISwipeActionsConfiguration(actions: [delete, check, share]) + } +} diff --git a/AutoCat/Extensions/CocoaError.swift b/AutoCat/Extensions/CocoaError.swift new file mode 100644 index 0000000..19cfe20 --- /dev/null +++ b/AutoCat/Extensions/CocoaError.swift @@ -0,0 +1,29 @@ +import UIKit + +extension CocoaError { + static func error(_ description: String) -> NSError { + return error(Code(rawValue: 0), userInfo: [NSLocalizedDescriptionKey: description], url: nil) as NSError + } + + static func error(_ description: String, suggestion: String) -> NSError { + let info = [ + NSLocalizedDescriptionKey: description, + NSLocalizedRecoverySuggestionErrorKey: suggestion + ] + return error(Code(rawValue: 0), userInfo: info, url: nil) as NSError + } +} + +extension UIViewController { + func show(error: NSError) { + let alert = UIAlertController(title: error.localizedDescription, message: error.localizedRecoverySuggestion, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) + self.present(alert, animated: true) + } + + func showAlert(title: String, message: String) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) + self.present(alert, animated: true) + } +} diff --git a/AutoCat/Extensions/Dated.swift b/AutoCat/Extensions/Dated.swift index 3776e1f..36c333d 100644 --- a/AutoCat/Extensions/Dated.swift +++ b/AutoCat/Extensions/Dated.swift @@ -12,6 +12,12 @@ extension Vehicle: Dated { } } +extension AudioRecord: Dated { + var date: Date { + Date(timeIntervalSince1970: self.addedDate) + } +} + extension RandomAccessCollection where Element: Dated & IdentifiableType & Equatable { func groupedByDate() -> [DateSection] { let now = Date() diff --git a/AutoCat/Extensions/FileManagerExt.swift b/AutoCat/Extensions/FileManagerExt.swift new file mode 100644 index 0000000..71bee08 --- /dev/null +++ b/AutoCat/Extensions/FileManagerExt.swift @@ -0,0 +1,15 @@ +import Foundation + +extension FileManager { + func url(for file: String, in dir: String) throws -> URL { + guard let docUrl = self.urls(for: .documentDirectory, in: .userDomainMask).first else { + throw CocoaError(.fileReadNoSuchFile) + } + + let folderUrl = docUrl.appendingPathComponent(dir, isDirectory: true) + if !self.fileExists(atPath: folderUrl.path) { + try self.createDirectory(at: folderUrl, withIntermediateDirectories: true, attributes: nil) + } + return folderUrl.appendingPathComponent(file) + } +} diff --git a/AutoCat/Extensions/Substrings.swift b/AutoCat/Extensions/Substrings.swift new file mode 100644 index 0000000..517983e --- /dev/null +++ b/AutoCat/Extensions/Substrings.swift @@ -0,0 +1,23 @@ +import Foundation + +extension String { + func index(from: Int) -> Index { + return self.index(startIndex, offsetBy: from) + } + + func substring(from: Int) -> String { + let fromIndex = index(from: from) + return String(self[fromIndex...]) + } + + func substring(to: Int) -> String { + let toIndex = index(from: to) + return String(self[..) -> String { + let startIndex = index(from: r.lowerBound) + let endIndex = index(from: r.upperBound) + return String(self[startIndex.. + NSSpeechRecognitionUsageDescription + Access is needed for recognizing plate numbers from voice recordings + NSMicrophoneUsageDescription + Access is needed for voice recordings CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable @@ -69,6 +73,14 @@ UIApplicationShortcutItems + + UIApplicationShortcutItemType + AddVoiceRecordAction + UIApplicationShortcutItemTitle + Add voice record + UIApplicationShortcutItemIconType + UIApplicationShortcutIconTypeAudio + UIApplicationShortcutItemIconType UIApplicationShortcutIconTypeCompose diff --git a/AutoCat/Models/AudioRecord.swift b/AutoCat/Models/AudioRecord.swift new file mode 100644 index 0000000..f5472a7 --- /dev/null +++ b/AutoCat/Models/AudioRecord.swift @@ -0,0 +1,32 @@ +import Foundation +import RealmSwift +import RxDataSources + +class AudioRecord: Object, IdentifiableType { + + @objc dynamic var path: String = "" + @objc dynamic var number: String? + @objc dynamic var addedDate: TimeInterval = Date().timeIntervalSince1970 + @objc dynamic var duration: TimeInterval = 0 + + var identifier: TimeInterval = 0 + var identity: TimeInterval { + if self.identifier == 0 { + self.identifier = self.addedDate + } + return self.identifier + } + + init(path: String, number: String?, duration: TimeInterval) { + self.path = path + self.number = number + self.duration = duration + } + + required init() { + } + + override class func ignoredProperties() -> [String] { + return ["identity", "identifier"] + } +} diff --git a/AutoCat/SceneDelegate.swift b/AutoCat/SceneDelegate.swift index fee3800..fa3e413 100644 --- a/AutoCat/SceneDelegate.swift +++ b/AutoCat/SceneDelegate.swift @@ -69,6 +69,22 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { tabvc.selectedIndex = 0 } } + } else if shortcutItem.type == "AddVoiceRecordAction" { + ad.quickAction = .addVoiceRecord + + if let split = self.window?.rootViewController as? MainSplitController, let tabvc = split.viewControllers.first as? UITabBarController { + if tabvc.selectedIndex == 1 { + if let nav = tabvc.selectedViewController as? UINavigationController, let child = nav.topViewController { + if let record = child as? RecordsController { + record.handleQuickActions() + } else { + nav.popToRootViewController(animated: false) + } + } + } else { + tabvc.selectedIndex = 1 + } + } } } } diff --git a/AutoCat/ThirdParty/RxRealmDataSources/Reactive+RxRealmDataSources.swift b/AutoCat/ThirdParty/RxRealmDataSources/Reactive+RxRealmDataSources.swift new file mode 100644 index 0000000..c51ca48 --- /dev/null +++ b/AutoCat/ThirdParty/RxRealmDataSources/Reactive+RxRealmDataSources.swift @@ -0,0 +1,113 @@ +// +// RxRealm extensions +// +// Copyright (c) 2016 RxSwiftCommunity. All rights reserved. +// Check the LICENSE file for details +// + +import Foundation + +import RealmSwift +import RxSwift +import RxCocoa +import RxRealm + +#if os(iOS) +// MARK: - iOS / UIKit + +import UIKit +extension Reactive where Base: UITableView { + + public func realmChanges(_ dataSource: RxTableViewRealmDataSource) + -> RealmBindObserver, RxTableViewRealmDataSource> { + + return RealmBindObserver(dataSource: dataSource) {ds, results, changes in + if ds.tableView == nil { + ds.tableView = self.base + } + ds.tableView?.dataSource = ds + ds.applyChanges(items: AnyRealmCollection(results), changes: changes) + } + } + + public func realmModelSelected(_ modelType: E.Type) -> ControlEvent where E: RealmSwift.Object { + + let source: Observable = self.itemSelected.flatMap { [weak view = self.base as UITableView] indexPath -> Observable in + guard let view = view, let ds = view.dataSource as? RxTableViewRealmDataSource else { + return Observable.empty() + } + + return Observable.just(ds.model(at: indexPath)) + } + + return ControlEvent(events: source) + } + +} + +extension Reactive where Base: UICollectionView { + + public func realmChanges(_ dataSource: RxCollectionViewRealmDataSource) + -> RealmBindObserver, RxCollectionViewRealmDataSource> { + + return RealmBindObserver(dataSource: dataSource) {ds, results, changes in + if ds.collectionView == nil { + ds.collectionView = self.base + } + ds.collectionView?.dataSource = ds + ds.applyChanges(items: AnyRealmCollection(results), changes: changes) + } + } + + public func realmModelSelected(_ modelType: E.Type) -> ControlEvent where E: RealmSwift.Object { + + let source: Observable = self.itemSelected.flatMap { [weak view = self.base as UICollectionView] indexPath -> Observable in + guard let view = view, let ds = view.dataSource as? RxCollectionViewRealmDataSource else { + return Observable.empty() + } + + return Observable.just(ds.model(at: indexPath)) + } + + return ControlEvent(events: source) + } +} + + +#elseif os(OSX) +// MARK: - macOS / Cocoa + +import Cocoa +extension Reactive where Base: NSTableView { + + public func realmChanges(_ dataSource: RxTableViewRealmDataSource) + -> RealmBindObserver, RxTableViewRealmDataSource> { + + base.delegate = dataSource + base.dataSource = dataSource + + return RealmBindObserver(dataSource: dataSource) {ds, results, changes in + if dataSource.tableView == nil { + dataSource.tableView = self.base + } + ds.applyChanges(items: AnyRealmCollection(results), changes: changes) + } + } +} + +extension Reactive where Base: NSCollectionView { + + public func realmChanges(_ dataSource: RxCollectionViewRealmDataSource) + -> RealmBindObserver, RxCollectionViewRealmDataSource> { + + return RealmBindObserver(dataSource: dataSource) {ds, results, changes in + if ds.collectionView == nil { + ds.collectionView = self.base + } + ds.collectionView?.dataSource = ds + ds.applyChanges(items: AnyRealmCollection(results), changes: changes) + } + } +} + +#endif diff --git a/AutoCat/ThirdParty/RxRealmDataSources/RealmBindObserver.swift b/AutoCat/ThirdParty/RxRealmDataSources/RealmBindObserver.swift new file mode 100644 index 0000000..a60b378 --- /dev/null +++ b/AutoCat/ThirdParty/RxRealmDataSources/RealmBindObserver.swift @@ -0,0 +1,41 @@ +// +// RxRealm extensions +// +// Copyright (c) 2016 RxSwiftCommunity. All rights reserved. +// Check the LICENSE file for details +// + +import Foundation + +import RealmSwift +import RxSwift +import RxCocoa +import RxRealm + +public class RealmBindObserver: ObserverType { + typealias BindingType = (DS, C, RealmChangeset?) -> Void + public typealias E = (C, RealmChangeset?) + + let dataSource: DS + let binding: BindingType + + init(dataSource: DS, binding: @escaping BindingType) { + self.dataSource = dataSource + self.binding = binding + } + + public func on(_ event: Event) { + switch event { + case .next(let element): + binding(dataSource, element.0, element.1) + case .error: + return + case .completed: + return + } + } + + func asObserver() -> AnyObserver { + return AnyObserver(eventHandler: on) + } +} diff --git a/AutoCat/ThirdParty/RxRealmDataSources/RxCollectionViewRealmDataSource.swift b/AutoCat/ThirdParty/RxRealmDataSources/RxCollectionViewRealmDataSource.swift new file mode 100644 index 0000000..8883732 --- /dev/null +++ b/AutoCat/ThirdParty/RxRealmDataSources/RxCollectionViewRealmDataSource.swift @@ -0,0 +1,210 @@ +// +// RxRealm extensions +// +// Copyright (c) 2016 RxSwiftCommunity. All rights reserved. +// Check the LICENSE file for details +// + +import Foundation + +import RealmSwift +import RxSwift +import RxCocoa +import RxRealm + +#if os(iOS) + // MARK: - iOS / UIKit + + import UIKit + + public typealias CollectionCellFactory = (RxCollectionViewRealmDataSource, UICollectionView, IndexPath, E) -> UICollectionViewCell + public typealias CollectionCellConfig = (CellType, IndexPath, E) -> Void + + open class RxCollectionViewRealmDataSource : NSObject, UICollectionViewDataSource { + private var items: AnyRealmCollection? + + // MARK: - Configuration + + public var collectionView: UICollectionView? + public var animated = true + + // MARK: - Init + public let cellIdentifier: String + public let cellFactory: CollectionCellFactory + + public init(cellIdentifier: String, cellFactory: @escaping CollectionCellFactory) { + self.cellIdentifier = cellIdentifier + self.cellFactory = cellFactory + } + + public init(cellIdentifier: String, cellType: CellType.Type, cellConfig: @escaping CollectionCellConfig) where CellType: UICollectionViewCell { + self.cellIdentifier = cellIdentifier + self.cellFactory = {ds, cv, ip, model in + let cell = cv.dequeueReusableCell(withReuseIdentifier: cellIdentifier, for: ip) as! CellType + cellConfig(cell, ip, model) + return cell + } + } + + // MARK: - Data access + public func model(at indexPath: IndexPath) -> E { + return items![indexPath.row] + } + + // MARK: - UICollectionViewDataSource protocol + public func numberOfSections(in collectionView: UICollectionView) -> Int { + return 1 + } + + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return items?.count ?? 0 + } + + public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + return cellFactory(self, collectionView, indexPath, items![indexPath.row]) + } + + // MARK: - Applying changeset to the collection view + private let fromRow = {(row: Int) in return IndexPath(row: row, section: 0)} + + func applyChanges(items: AnyRealmCollection, changes: RealmChangeset?) { + if self.items == nil { + self.items = items + } + + guard let collectionView = collectionView else { + fatalError("You have to bind a collection view to the data source.") + } + + guard animated else { + collectionView.reloadData() + return + } + + guard let changes = changes else { + collectionView.reloadData() + return + } + + let lastItemCount = collectionView.numberOfItems(inSection: 0) + guard items.count == lastItemCount + changes.inserted.count - changes.deleted.count else { + collectionView.reloadData() + return + } + + collectionView.performBatchUpdates({[unowned self] in + collectionView.deleteItems(at: changes.deleted.map(self.fromRow)) + collectionView.reloadItems(at: changes.updated.map(self.fromRow)) + collectionView.insertItems(at: changes.inserted.map(self.fromRow)) + }, completion: nil) + } +} + +#elseif os(OSX) +// MARK: - macOS / Cocoa + +import Cocoa + + public typealias CollectionItemFactory = (RxCollectionViewRealmDataSource, NSCollectionView, IndexPath, E) -> NSCollectionViewItem + public typealias CollectionItemConfig = (ItemType, IndexPath, E) -> Void + + open class RxCollectionViewRealmDataSource : NSObject, NSCollectionViewDataSource { + + private var items: AnyRealmCollection? + + // MARK: - Configuration + + public var collectionView: NSCollectionView? + public var animated = true + + // MARK: - Init + public let itemIdentifier: String + public let itemFactory: CollectionItemFactory + + public weak var delegate: NSCollectionViewDelegate? + public weak var dataSource: NSCollectionViewDataSource? + + public init(itemIdentifier: String, itemFactory: @escaping CollectionItemFactory) { + self.itemIdentifier = itemIdentifier + self.itemFactory = itemFactory + } + + public init(itemIdentifier: String, itemType: ItemType.Type, itemConfig: @escaping CollectionItemConfig) where ItemType: NSCollectionViewItem { + self.itemIdentifier = itemIdentifier + self.itemFactory = { ds, cv, ip, model in + let item = cv.makeItem(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: itemIdentifier), for: ip) as! ItemType + itemConfig(item, ip, model) + return item + } + } + + // MARK: - NSCollectionViewDataSource protocol + public func numberOfSections(in collectionView: NSCollectionView) -> Int { + return 1 + } + + public func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int { + return items?.count ?? 0 + } + + @available(OSX 10.11, *) + public func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem { + return itemFactory(self, collectionView, indexPath, items![indexPath.item]) + } + + // MARK: - Proxy unimplemented data source and delegate methods + open override func responds(to aSelector: Selector!) -> Bool { + if RxCollectionViewRealmDataSource.instancesRespond(to: aSelector) { + return true + } else if let delegate = delegate { + return delegate.responds(to: aSelector) + } else if let dataSource = dataSource { + return dataSource.responds(to: aSelector) + } else { + return false + } + } + + open override func forwardingTarget(for aSelector: Selector!) -> Any? { + return delegate ?? dataSource + } + + // MARK: - Applying changeset to the collection view + private let fromRow = {(row: Int) in return IndexPath(item: row, section: 0)} + + func applyChanges(items: AnyRealmCollection, changes: RealmChangeset?) { + if self.items == nil { + self.items = items + } + + guard let collectionView = collectionView else { + fatalError("You have to bind a collection view to the data source.") + } + + guard animated else { + collectionView.reloadData() + return + } + + guard let changes = changes else { + collectionView.reloadData() + return + } + + let lastItemCount = collectionView.numberOfItems(inSection: 0) + guard items.count == lastItemCount + changes.inserted.count - changes.deleted.count else { + collectionView.reloadData() + return + } + + collectionView.performBatchUpdates({[unowned self] in + //TODO: this should be animated, but doesn't seem to be? + collectionView.animator().deleteItems(at: Set(changes.deleted.map(self.fromRow))) + collectionView.animator().reloadItems(at: Set(changes.updated.map(self.fromRow))) + collectionView.animator().insertItems(at: Set(changes.inserted.map(self.fromRow))) + }, completionHandler: nil) + } + } + + +#endif diff --git a/AutoCat/ThirdParty/RxRealmDataSources/RxTableViewRealmDataSource.swift b/AutoCat/ThirdParty/RxRealmDataSources/RxTableViewRealmDataSource.swift new file mode 100644 index 0000000..c80bb80 --- /dev/null +++ b/AutoCat/ThirdParty/RxRealmDataSources/RxTableViewRealmDataSource.swift @@ -0,0 +1,223 @@ +// +// RxRealm extensions +// +// Copyright (c) 2016 RxSwiftCommunity. All rights reserved. +// Check the LICENSE file for details +// + +import Foundation + +import RealmSwift +import RxSwift +import RxCocoa +import RxRealm + +#if os(iOS) + // MARK: - iOS / UIKit + + import UIKit + + public typealias TableCellFactory = (RxTableViewRealmDataSource, UITableView, IndexPath, E) -> UITableViewCell + public typealias TableCellConfig = (CellType, IndexPath, E) -> Void + + open class RxTableViewRealmDataSource: NSObject, UITableViewDataSource { + + private var items: AnyRealmCollection? + + // MARK: - Configuration + + public var tableView: UITableView? + public var animated = true + public var rowAnimations = ( + insert: UITableView.RowAnimation.automatic, + update: UITableView.RowAnimation.automatic, + delete: UITableView.RowAnimation.automatic) + + public var headerTitle: String? + public var footerTitle: String? + + // MARK: - Init + public let cellIdentifier: String + public let cellFactory: TableCellFactory + + public init(cellIdentifier: String, cellFactory: @escaping TableCellFactory) { + self.cellIdentifier = cellIdentifier + self.cellFactory = cellFactory + } + + public init(cellIdentifier: String, cellType: CellType.Type, cellConfig: @escaping TableCellConfig) where CellType: UITableViewCell { + self.cellIdentifier = cellIdentifier + self.cellFactory = {ds, tv, ip, model in + let cell = tv.dequeueReusableCell(withIdentifier: cellIdentifier, for: ip) as! CellType + cellConfig(cell, ip, model) + return cell + } + } + + // MARK: - Data access + public func model(at indexPath: IndexPath) -> E { + return items![indexPath.row] + } + + // MARK: - UITableViewDataSource protocol + public func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return items?.count ?? 0 + } + + public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + return cellFactory(self, tableView, indexPath, items![indexPath.row]) + } + + public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + return headerTitle + } + + public func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + return footerTitle + } + + // MARK: - Applying changeset to the table view + private let fromRow = {(row: Int) in return IndexPath(row: row, section: 0)} + + func applyChanges(items: AnyRealmCollection, changes: RealmChangeset?) { + if self.items == nil { + self.items = items + } + + guard let tableView = tableView else { + fatalError("You have to bind a table view to the data source.") + } + + guard animated else { + tableView.reloadData() + return + } + + guard let changes = changes else { + tableView.reloadData() + return + } + + let lastItemCount = tableView.numberOfRows(inSection: 0) + guard items.count == lastItemCount + changes.inserted.count - changes.deleted.count else { + tableView.reloadData() + return + } + + tableView.beginUpdates() + tableView.deleteRows(at: changes.deleted.map(fromRow), with: rowAnimations.delete) + tableView.insertRows(at: changes.inserted.map(fromRow), with: rowAnimations.insert) + tableView.reloadRows(at: changes.updated.map(fromRow), with: rowAnimations.update) + tableView.endUpdates() + } + } + +#elseif os(OSX) + // MARK: - macOS / Cocoa + + import Cocoa + + public typealias TableCellFactory = (RxTableViewRealmDataSource, NSTableView, Int, E) -> NSTableCellView + public typealias TableCellConfig = (CellType, Int, E) -> Void + + open class RxTableViewRealmDataSource: NSObject, NSTableViewDataSource, NSTableViewDelegate { + + private var items: AnyRealmCollection? + + // MARK: - Configuration + + public var tableView: NSTableView? + public var animated = true + public var rowAnimations = ( + insert: NSTableView.AnimationOptions.effectFade, + update: NSTableView.AnimationOptions.effectFade, + delete: NSTableView.AnimationOptions.effectFade) + + public weak var delegate: NSTableViewDelegate? + public weak var dataSource: NSTableViewDataSource? + + // MARK: - Init + public let cellIdentifier: String + public let cellFactory: TableCellFactory + + public init(cellIdentifier: String, cellFactory: @escaping TableCellFactory) { + self.cellIdentifier = cellIdentifier + self.cellFactory = cellFactory + } + + public init(cellIdentifier: String, cellType: CellType.Type, cellConfig: @escaping TableCellConfig) where CellType: NSTableCellView { + self.cellIdentifier = cellIdentifier + self.cellFactory = { ds, tv, row, model in + let cell = tv.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: cellIdentifier), owner: tv) as! CellType + cellConfig(cell, row, model) + return cell + } + } + + // MARK: - UITableViewDataSource protocol + public func numberOfRows(in tableView: NSTableView) -> Int { + return items?.count ?? 0 + } + + public func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + return cellFactory(self, tableView, row, items![row]) + } + + // MARK: - Proxy unimplemented data source and delegate methods + open override func responds(to aSelector: Selector!) -> Bool { + if RxTableViewRealmDataSource.instancesRespond(to: aSelector) { + return true + } else if let delegate = delegate { + return delegate.responds(to: aSelector) + } else if let dataSource = dataSource { + return dataSource.responds(to: aSelector) + } else { + return false + } + } + + open override func forwardingTarget(for aSelector: Selector!) -> Any? { + return delegate ?? dataSource + } + + // MARK: - Applying changeset to the table view + private let fromRow = {(row: Int) in return IndexPath(item: row, section: 0)} + + func applyChanges(items: AnyRealmCollection, changes: RealmChangeset?) { + if self.items == nil { + self.items = items + } + + guard let tableView = tableView else { + fatalError("You have to bind a table view to the data source.") + } + + guard animated else { + tableView.reloadData() + return + } + + guard let changes = changes else { + tableView.reloadData() + return + } + + let lastItemCount = tableView.numberOfRows + guard items.count == lastItemCount + changes.inserted.count - changes.deleted.count else { + tableView.reloadData() + return + } + + tableView.beginUpdates() + tableView.removeRows(at: IndexSet(changes.deleted), withAnimation: rowAnimations.delete) + tableView.insertRows(at: IndexSet(changes.inserted), withAnimation: rowAnimations.insert) + tableView.reloadData(forRowIndexes: IndexSet(changes.updated), columnIndexes: IndexSet([0])) + tableView.endUpdates() + } + } + +#endif diff --git a/AutoCat/Utils/AudioPlayer.swift b/AutoCat/Utils/AudioPlayer.swift new file mode 100644 index 0000000..9f6bc8b --- /dev/null +++ b/AutoCat/Utils/AudioPlayer.swift @@ -0,0 +1,107 @@ +import Foundation +import AVFoundation +import RxSwift +import RxRelay + +enum PlayerState { + case stopped + case paused + case playing +} + +class AudioPlayer: NSObject, AVAudioPlayerDelegate { + + static let shared = AudioPlayer() + + private var player: AVAudioPlayer? + private var url: URL? + private var state = BehaviorRelay(value: .stopped) + private var progress = BehaviorRelay(value: 0) + private var progressTimer: Timer? + + func set(url: URL) throws { + if let curUrl = self.url, curUrl == url { + return + } + + self.stop() + self.url = url + self.player = try AVAudioPlayer(contentsOf: url) + self.player?.delegate = self + } + + func play() { + if let player = self.player { + if player.isPlaying { + player.pause() + self.state.accept(.paused) + } else { + player.play() + self.state.accept(.playing) + if self.progressTimer == nil { + self.progressTimer = Timer.scheduledTimer(timeInterval: 0.2, target: self, selector: #selector(progressTick), userInfo: nil, repeats: true) + } + } + } + } + + func play(url: URL) throws { + try self.set(url: url) + self.play() + } + + func pause() { + if let player = self.player { + player.pause() + self.state.accept(.paused) + } + } + + func stop() { + if let player = self.player { + player.stop() + self.state.accept(.stopped) + self.progressTimer?.invalidate() + self.progressTimer = nil + } + } + + func getState() -> PlayerState { + return self.state.value + } + + func getProgress() -> Double { + return self.progress.value + } + + func getUrl() -> URL? { + return self.url + } + + func duration() -> TimeInterval { + return self.player?.duration ?? 0 + } + + func stateObservable() -> Observable { + return self.state.asObservable() + } + + func progressObservable() -> Observable { + return self.progress.asObservable() + } + + // MARK: - AVAudioPlayerDelegate + + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + self.progress.accept(1) + self.stop() + self.state.accept(.stopped) + } + + @objc func progressTick() { + if let player = self.player { + let progress = player.currentTime/player.duration + self.progress.accept(progress) + } + } +} diff --git a/AutoCat/Utils/Constants.swift b/AutoCat/Utils/Constants.swift index d2dcfcc..074f9be 100644 --- a/AutoCat/Utils/Constants.swift +++ b/AutoCat/Utils/Constants.swift @@ -3,8 +3,8 @@ import Foundation enum Constants { static var baseUrl: String { #if DEBUG - return "http://127.0.0.1:3000/" - //return "https://vps.aliencat.pro:8443/" + //return "http://127.0.0.1:3000/" + return "https://vps.aliencat.pro:8443/" #else return "https://vps.aliencat.pro:8443/" #endif diff --git a/AutoCat/Utils/Recorder.swift b/AutoCat/Utils/Recorder.swift new file mode 100644 index 0000000..4796abc --- /dev/null +++ b/AutoCat/Utils/Recorder.swift @@ -0,0 +1,118 @@ +import Foundation +import Speech +import AVFoundation +import AudioToolbox + +class Recorder { + + let session = AVAudioSession.sharedInstance() + let engine = AVAudioEngine() + var fileRef: ExtAudioFileRef? = nil + let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "ru_RU")) + let request = SFSpeechAudioBufferRecognitionRequest() + var recognitionTask: SFSpeechRecognitionTask? + var endRecognitionTimer: Timer? + var result: String = "" + + let recordingSettings: [String:Any] = [ + AVFormatIDKey:kAudioFormatMPEG4AAC_HE, + AVSampleRateKey:44100.0, + AVNumberOfChannelsKey:2, + //AVEncoderBitRateKey:320*1024, + //AVLinearPCMBitDepthKey:16, + AVEncoderAudioQualityKey:AVAudioQuality.max.rawValue + ] + + init() throws { + + try self.session.setCategory(.playAndRecord, mode: .spokenAudio, options: .mixWithOthers) + } + + func requestPermissions(completion: @escaping (NSError?) -> Void) { + self.session.requestRecordPermission { allowed in + if allowed { + SFSpeechRecognizer.requestAuthorization { status in + switch status { + case .authorized: + completion(nil) + break + case .denied: + let error = CocoaError.error("Access denied", suggestion: "Please give permission to use speech recognition in system settings") + completion(error) + break + case .restricted: + let error = CocoaError.error("Access restricted", suggestion: "Speech recognition is restricted on this device") + completion(error) + break + case .notDetermined: + let error = CocoaError.error("Access error", suggestion: "Speech recognition status is not yet determined") + completion(error) + break + @unknown default: + let error = CocoaError.error("Access error", suggestion: "Unknown error accessing speech recognizer") + completion(error) + break + } + } + } else { + let error = CocoaError.error("Access denied", suggestion: "Please give permission to use microphone in system settings") + completion(error) + } + } + } + + func startRecording(to file: URL, completion: @escaping (String) -> Void) throws { + let inFormat = self.engine.inputNode.outputFormat(forBus: 0) + guard let aac = AVAudioFormat(settings: self.recordingSettings) else { + throw CocoaError.error("Recording error", suggestion: "Format not supported") + } + + ExtAudioFileCreateWithURL(file as CFURL, kAudioFileM4AType, aac.streamDescription, nil, AudioFileFlags.eraseFile.rawValue, &fileRef) + guard let fileRef = self.fileRef else { + throw CocoaError.error(CocoaError.Code.fileWriteUnknown) + } + + ExtAudioFileSetProperty(fileRef, kExtAudioFileProperty_ClientDataFormat, UInt32(MemoryLayout.size), inFormat.streamDescription) + + self.engine.inputNode.installTap(onBus: 0, bufferSize: 1024, format: inFormat) { buffer, time in + self.request.append(buffer) + //print(self.recognitionTask?.state.rawValue) + ExtAudioFileWrite(fileRef, buffer.frameLength, buffer.audioBufferList) + } + + self.recognitionTask = self.recognizer!.recognitionTask(with: self.request) { result, error in + if let transcription = result?.bestTranscription { + self.result = transcription.formattedString + self.endRecognitionTimer?.invalidate() + self.endRecognitionTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { timer in + self.cancelRecording() + completion(self.result) + } + } + } + + self.endRecognitionTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { timer in + self.cancelRecording() + completion(self.result) + } + + self.engine.prepare() + try self.engine.start() + } + + func cancelRecording() { + self.engine.stop() + self.engine.inputNode.removeTap(onBus: 0) + self.request.endAudio() + self.recognitionTask?.cancel() + + if let fileRef = self.fileRef { + ExtAudioFileDispose(fileRef) + } + } + + func stopRecording() -> String { + self.cancelRecording() + return self.result + } +} diff --git a/AutoCat/Views/CellProgressView.swift b/AutoCat/Views/CellProgressView.swift new file mode 100644 index 0000000..abd38f3 --- /dev/null +++ b/AutoCat/Views/CellProgressView.swift @@ -0,0 +1,41 @@ +import UIKit + +class CellProgressView: UIView { + + let progressLayer = CAShapeLayer() + + var progress: Double = 0 { + didSet { + let path = UIBezierPath(rect: self.rect(for: self.progress)) + let pathFrom = self.progressLayer.path + self.progressLayer.path = path.cgPath + if self.progress != 0 { + let animation = CABasicAnimation(keyPath: "path") + animation.duration = 0.2 + animation.fromValue = pathFrom + animation.toValue = path.cgPath + animation.timingFunction = CAMediaTimingFunction(name: .linear) + self.progressLayer.add(animation, forKey: "pathAnimation") + } + } + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + + self.progressLayer.frame = self.layer.bounds + self.progressLayer.path = UIBezierPath(rect: self.layer.bounds).cgPath + self.progressLayer.fillColor = UIColor.systemBlue.cgColor + self.progressLayer.opacity = 0.3 + self.layer.addSublayer(self.progressLayer) + } + + override func layoutSubviews() { + super.layoutSubviews() + self.progressLayer.frame = rect(for: self.progress) + } + + func rect(for progress: Double) -> CGRect { + return CGRect(x: 0, y: 0, width: self.bounds.width*CGFloat(progress), height: self.bounds.height) + } +}