diff --git a/Accelerometer.xcodeproj/project.pbxproj b/Accelerometer.xcodeproj/project.pbxproj index 178d3d4..0abacfb 100644 --- a/Accelerometer.xcodeproj/project.pbxproj +++ b/Accelerometer.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ 0152C57173B5A7117F4F15D8 /* Pods_AccelerometerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 58D1941BEB77F79AC9EA829F /* Pods_AccelerometerTests.framework */; }; 0B05EB5E2A9A1523005497F6 /* MagnitudeAxes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B05EB5D2A9A1523005497F6 /* MagnitudeAxes.swift */; }; 0B05EB602A9A1CFB005497F6 /* TriangleAxes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B05EB5F2A9A1CFB005497F6 /* TriangleAxes.swift */; }; + 0B1845512E7DA059004F5474 /* RecordingProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B1845502E7DA052004F5474 /* RecordingProcessor.swift */; }; + 0B1845542E7DCB90004F5474 /* MemoryMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B1845532E7DCB90004F5474 /* MemoryMonitor.swift */; }; 0B1E6D2328724B2B003B8C5B /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B1E6D2228724B2B003B8C5B /* Collection.swift */; }; 0B24EC0828621BBE00AD3EF1 /* AccelerometerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B24EC0728621BBE00AD3EF1 /* AccelerometerApp.swift */; }; 0B24EC0A28621BBE00AD3EF1 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B24EC0928621BBE00AD3EF1 /* ContentView.swift */; }; @@ -33,6 +35,7 @@ 0B36DFCA289A9E85007130D9 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B36DFC9289A9E85007130D9 /* Settings.swift */; }; 0B36DFCC289AA096007130D9 /* ExportDateFormatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B36DFCB289AA096007130D9 /* ExportDateFormatView.swift */; }; 0B38C323288DC56D00A4541A /* AxesSummaryViewExtended.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B38C322288DC56D00A4541A /* AxesSummaryViewExtended.swift */; }; + 0B45FF962D8EC361009F5CDF /* RecordingSmallChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B45FF952D8EC357009F5CDF /* RecordingSmallChartView.swift */; }; 0B4BE8532D74BB420065D938 /* AnimationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B4BE8522D74BB2F0065D938 /* AnimationsView.swift */; }; 0B4CC363287C30FF000F0C94 /* DiagramAxesNamesStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B4CC362287C30FF000F0C94 /* DiagramAxesNamesStyle.swift */; }; 0B4CC365287C31AB000F0C94 /* DiagramViewStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B4CC364287C31AB000F0C94 /* DiagramViewStyle.swift */; }; @@ -41,11 +44,18 @@ 0B508CDB289C7F0200326C6E /* RecordingsRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B508CDA289C7F0200326C6E /* RecordingsRepository.swift */; }; 0B548E1A2876ED5000EDCDA8 /* MeasurementPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B548E192876ED5000EDCDA8 /* MeasurementPreview.swift */; }; 0B6DD8CF2B816BED0076D93A /* ChartEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B6DD8CE2B816BED0076D93A /* ChartEntry.swift */; }; + 0B7A81602E816FE900399013 /* AlwaysNotEnoughMemoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B7A815F2E816FE900399013 /* AlwaysNotEnoughMemoryView.swift */; }; 0B801286286DA9C000F4AA85 /* AnimatableArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B801285286DA9C000F4AA85 /* AnimatableArray.swift */; }; 0B8C3B2A2A99F9FF00488B1E /* Axis.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B8C3B292A99F9FF00488B1E /* Axis.swift */; }; 0B8C3B2C2A99FC9600488B1E /* Axes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B8C3B2B2A99FC9600488B1E /* Axes.swift */; }; - 0B9BDC11289973CC00842161 /* SwiftUICharts in Frameworks */ = {isa = PBXBuildFile; productRef = 0B9BDC10289973CC00842161 /* SwiftUICharts */; }; + 0B96B73F2E8A91510007E730 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 0B96B73E2E8A91510007E730 /* DequeModule */; }; + 0B96B7432E8AD39C0007E730 /* RecordingBigChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B96B7422E8AD39B0007E730 /* RecordingBigChartView.swift */; }; + 0B96B7452E8AD3D20007E730 /* RecordingChartContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B96B7442E8AD3D10007E730 /* RecordingChartContainerView.swift */; }; + 0B96B7472E8AD49F0007E730 /* RecordingChartContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B96B7462E8AD49E0007E730 /* RecordingChartContent.swift */; }; 0B9BDC132899A6F900842161 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B9BDC122899A6F900842161 /* Array.swift */; }; + 0B9FB1532E8F365B00D70505 /* PreviewUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B9FB1522E8F365B00D70505 /* PreviewUtils.swift */; }; + 0B9FB1552E8F52CA00D70505 /* DebugSamplesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B9FB1542E8F52CA00D70505 /* DebugSamplesView.swift */; }; + 0B9FB1572E8F653300D70505 /* ExportModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B9FB1562E8F653300D70505 /* ExportModifier.swift */; }; 0BA5B7BC2AD319AC004F1808 /* AttitudeAxesSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BA5B7BB2AD319AC004F1808 /* AttitudeAxesSummaryView.swift */; }; 0BA619CF2AADC9E7005DABD3 /* ObservableAxesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BA619CE2AADC9E7005DABD3 /* ObservableAxesTests.swift */; }; 0BA63B6C289598B40040B63D /* NewRecordingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BA63B6B289598B40040B63D /* NewRecordingView.swift */; }; @@ -53,7 +63,6 @@ 0BA63B702897EFED0040B63D /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BA63B6F2897EFED0040B63D /* Date.swift */; }; 0BA63B722897FBCE0040B63D /* RecordingPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BA63B712897FBCE0040B63D /* RecordingPreview.swift */; }; 0BADCA5D2899ED5C00238A25 /* TextFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BADCA5C2899ED5C00238A25 /* TextFile.swift */; }; - 0BADCA5F289A720600238A25 /* RecordingMeasurementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BADCA5E289A720600238A25 /* RecordingMeasurementView.swift */; }; 0BBAD4AD2895564C00251262 /* RecordingSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BBAD4AC2895564C00251262 /* RecordingSummaryView.swift */; }; 0BBAD4AF2895715400251262 /* CGFloat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BBAD4AE2895715400251262 /* CGFloat.swift */; }; 0BBDC4E0288C188400CE6061 /* AxeType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BBDC4DF288C188400CE6061 /* AxeType.swift */; }; @@ -97,6 +106,8 @@ /* Begin PBXFileReference section */ 0B05EB5D2A9A1523005497F6 /* MagnitudeAxes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MagnitudeAxes.swift; sourceTree = ""; }; 0B05EB5F2A9A1CFB005497F6 /* TriangleAxes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriangleAxes.swift; sourceTree = ""; }; + 0B1845502E7DA052004F5474 /* RecordingProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingProcessor.swift; sourceTree = ""; }; + 0B1845532E7DCB90004F5474 /* MemoryMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryMonitor.swift; sourceTree = ""; }; 0B1E6D2228724B2B003B8C5B /* Collection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = ""; }; 0B24EC0428621BBE00AD3EF1 /* Acc.elerometer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Acc.elerometer.app; sourceTree = BUILT_PRODUCTS_DIR; }; 0B24EC0728621BBE00AD3EF1 /* AccelerometerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccelerometerApp.swift; sourceTree = ""; }; @@ -123,6 +134,7 @@ 0B36DFC9289A9E85007130D9 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; 0B36DFCB289AA096007130D9 /* ExportDateFormatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportDateFormatView.swift; sourceTree = ""; }; 0B38C322288DC56D00A4541A /* AxesSummaryViewExtended.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AxesSummaryViewExtended.swift; sourceTree = ""; }; + 0B45FF952D8EC357009F5CDF /* RecordingSmallChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingSmallChartView.swift; sourceTree = ""; }; 0B4BE8522D74BB2F0065D938 /* AnimationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationsView.swift; sourceTree = ""; }; 0B4CC362287C30FF000F0C94 /* DiagramAxesNamesStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagramAxesNamesStyle.swift; sourceTree = ""; }; 0B4CC364287C31AB000F0C94 /* DiagramViewStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagramViewStyle.swift; sourceTree = ""; }; @@ -131,10 +143,17 @@ 0B508CDA289C7F0200326C6E /* RecordingsRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingsRepository.swift; sourceTree = ""; }; 0B548E192876ED5000EDCDA8 /* MeasurementPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasurementPreview.swift; sourceTree = ""; }; 0B6DD8CE2B816BED0076D93A /* ChartEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartEntry.swift; sourceTree = ""; }; + 0B7A815F2E816FE900399013 /* AlwaysNotEnoughMemoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlwaysNotEnoughMemoryView.swift; sourceTree = ""; }; 0B801285286DA9C000F4AA85 /* AnimatableArray.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatableArray.swift; sourceTree = ""; }; 0B8C3B292A99F9FF00488B1E /* Axis.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Axis.swift; sourceTree = ""; }; 0B8C3B2B2A99FC9600488B1E /* Axes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Axes.swift; sourceTree = ""; }; + 0B96B7422E8AD39B0007E730 /* RecordingBigChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingBigChartView.swift; sourceTree = ""; }; + 0B96B7442E8AD3D10007E730 /* RecordingChartContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingChartContainerView.swift; sourceTree = ""; }; + 0B96B7462E8AD49E0007E730 /* RecordingChartContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingChartContent.swift; sourceTree = ""; }; 0B9BDC122899A6F900842161 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; + 0B9FB1522E8F365B00D70505 /* PreviewUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewUtils.swift; sourceTree = ""; }; + 0B9FB1542E8F52CA00D70505 /* DebugSamplesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugSamplesView.swift; sourceTree = ""; }; + 0B9FB1562E8F653300D70505 /* ExportModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportModifier.swift; sourceTree = ""; }; 0BA5B7BB2AD319AC004F1808 /* AttitudeAxesSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttitudeAxesSummaryView.swift; sourceTree = ""; }; 0BA619CE2AADC9E7005DABD3 /* ObservableAxesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableAxesTests.swift; sourceTree = ""; }; 0BA63B6B289598B40040B63D /* NewRecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewRecordingView.swift; sourceTree = ""; }; @@ -142,7 +161,6 @@ 0BA63B6F2897EFED0040B63D /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = ""; }; 0BA63B712897FBCE0040B63D /* RecordingPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingPreview.swift; sourceTree = ""; }; 0BADCA5C2899ED5C00238A25 /* TextFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFile.swift; sourceTree = ""; }; - 0BADCA5E289A720600238A25 /* RecordingMeasurementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingMeasurementView.swift; sourceTree = ""; }; 0BBAD4AC2895564C00251262 /* RecordingSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingSummaryView.swift; sourceTree = ""; }; 0BBAD4AE2895715400251262 /* CGFloat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGFloat.swift; sourceTree = ""; }; 0BBDC4DF288C188400CE6061 /* AxeType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AxeType.swift; sourceTree = ""; }; @@ -178,8 +196,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 0B9BDC11289973CC00842161 /* SwiftUICharts in Frameworks */, A844F53C6B72222DA87BA782 /* Pods_Accelerometer.framework in Frameworks */, + 0B96B73F2E8A91510007E730 /* DequeModule in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -202,6 +220,17 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0B041D792E8F3096007BBFCF /* Charts */ = { + isa = PBXGroup; + children = ( + 0B45FF952D8EC357009F5CDF /* RecordingSmallChartView.swift */, + 0B96B7422E8AD39B0007E730 /* RecordingBigChartView.swift */, + 0B96B7442E8AD3D10007E730 /* RecordingChartContainerView.swift */, + 0B96B7462E8AD49E0007E730 /* RecordingChartContent.swift */, + ); + path = Charts; + sourceTree = ""; + }; 0B24EBFB28621BBE00AD3EF1 = { isa = PBXGroup; children = ( @@ -276,11 +305,13 @@ 0B24EC3C2862327E00AD3EF1 /* Settings */ = { isa = PBXGroup; children = ( - 0B4BE8522D74BB2F0065D938 /* AnimationsView.swift */, + 0B36DFC9289A9E85007130D9 /* Settings.swift */, 0B24EC3928622B7100AD3EF1 /* SettingsView.swift */, 0B24EC3D286232A900AD3EF1 /* RefreshRateView.swift */, 0B36DFCB289AA096007130D9 /* ExportDateFormatView.swift */, - 0B36DFC9289A9E85007130D9 /* Settings.swift */, + 0B4BE8522D74BB2F0065D938 /* AnimationsView.swift */, + 0B7A815F2E816FE900399013 /* AlwaysNotEnoughMemoryView.swift */, + 0B9FB1542E8F52CA00D70505 /* DebugSamplesView.swift */, ); path = Settings; sourceTree = ""; @@ -371,6 +402,8 @@ isa = PBXGroup; children = ( 0B801285286DA9C000F4AA85 /* AnimatableArray.swift */, + 0B1845532E7DCB90004F5474 /* MemoryMonitor.swift */, + 0B9FB1522E8F365B00D70505 /* PreviewUtils.swift */, ); path = Other; sourceTree = ""; @@ -392,15 +425,17 @@ 0BE138EC2893FF8A0026D0DC /* Recordings */ = { isa = PBXGroup; children = ( + 0B041D792E8F3096007BBFCF /* Charts */, 0B508CD9289C7EEB00326C6E /* Database */, + 0BE138F5289409820026D0DC /* Recorder.swift */, + 0BE138F1289400E70026D0DC /* Recording.swift */, + 0B1845502E7DA052004F5474 /* RecordingProcessor.swift */, + 0BDB67DB2AC9AC6200FA5C09 /* RecordingUtils.swift */, 0BE138ED2893FFA10026D0DC /* RecordingsView.swift */, 0BA63B712897FBCE0040B63D /* RecordingPreview.swift */, 0BBAD4AC2895564C00251262 /* RecordingSummaryView.swift */, - 0BADCA5E289A720600238A25 /* RecordingMeasurementView.swift */, 0BA63B6B289598B40040B63D /* NewRecordingView.swift */, - 0BE138F1289400E70026D0DC /* Recording.swift */, - 0BE138F5289409820026D0DC /* Recorder.swift */, - 0BDB67DB2AC9AC6200FA5C09 /* RecordingUtils.swift */, + 0B9FB1562E8F653300D70505 /* ExportModifier.swift */, ); path = Recordings; sourceTree = ""; @@ -447,7 +482,7 @@ ); name = Accelerometer; packageProductDependencies = ( - 0B9BDC10289973CC00842161 /* SwiftUICharts */, + 0B96B73E2E8A91510007E730 /* DequeModule */, ); productName = Accelerometer; productReference = 0B24EC0428621BBE00AD3EF1 /* Acc.elerometer.app */; @@ -525,7 +560,7 @@ ); mainGroup = 0B24EBFB28621BBE00AD3EF1; packageReferences = ( - 0B9BDC0F289973CC00842161 /* XCRemoteSwiftPackageReference "ChartView" */, + 0B96B73D2E8A91510007E730 /* XCRemoteSwiftPackageReference "swift-collections" */, ); productRefGroup = 0B24EC0528621BBE00AD3EF1 /* Products */; projectDirPath = ""; @@ -639,10 +674,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Accelerometer/Pods-Accelerometer-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Accelerometer/Pods-Accelerometer-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Accelerometer/Pods-Accelerometer-frameworks.sh\"\n"; @@ -656,10 +695,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Accelerometer-AccelerometerUITests/Pods-Accelerometer-AccelerometerUITests-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Accelerometer-AccelerometerUITests/Pods-Accelerometer-AccelerometerUITests-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Accelerometer-AccelerometerUITests/Pods-Accelerometer-AccelerometerUITests-frameworks.sh\"\n"; @@ -672,26 +715,32 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0B1845542E7DCB90004F5474 /* MemoryMonitor.swift in Sources */, 0B24EC472862496C00AD3EF1 /* LevelView.swift in Sources */, 0BDB67DC2AC9AC6200FA5C09 /* RecordingUtils.swift in Sources */, 0B24EC45286247AD00AD3EF1 /* String.swift in Sources */, + 0B9FB1552E8F52CA00D70505 /* DebugSamplesView.swift in Sources */, 0BDED2D828983A4D00CAEAC3 /* DateComponents.swift in Sources */, 0B24EC3A28622B7100AD3EF1 /* SettingsView.swift in Sources */, 0B36DFCA289A9E85007130D9 /* Settings.swift in Sources */, 0BA5B7BC2AD319AC004F1808 /* AttitudeAxesSummaryView.swift in Sources */, 0B24EC3428621E1C00AD3EF1 /* AppDelegate.swift in Sources */, + 0B45FF962D8EC361009F5CDF /* RecordingSmallChartView.swift in Sources */, 0B548E1A2876ED5000EDCDA8 /* MeasurementPreview.swift in Sources */, 0BA63B6C289598B40040B63D /* NewRecordingView.swift in Sources */, 0BCE8EC62864896300A0E3E0 /* TriangleDiagramShape.swift in Sources */, 0B24EC582863527800AD3EF1 /* DiagramView.swift in Sources */, 0BA63B6E2897EDD80040B63D /* DateFormatter.swift in Sources */, + 0B96B7452E8AD3D20007E730 /* RecordingChartContainerView.swift in Sources */, 0BDBDAEE2B8264880045A008 /* BooleanAxesSummaryView.swift in Sources */, + 0B9FB1532E8F365B00D70505 /* PreviewUtils.swift in Sources */, 0BDBDAEA2B825CAC0045A008 /* BooleanAxes.swift in Sources */, 0BF7A0B22AD29D6F005E75CC /* AxesSummaryValueView.swift in Sources */, 0B24EC3828622AB700AD3EF1 /* MeasurementsView.swift in Sources */, 0B8C3B2C2A99FC9600488B1E /* Axes.swift in Sources */, 0B801286286DA9C000F4AA85 /* AnimatableArray.swift in Sources */, 0BE138F4289401210026D0DC /* MeasurementType.swift in Sources */, + 0B7A81602E816FE900399013 /* AlwaysNotEnoughMemoryView.swift in Sources */, 0B4BE8532D74BB420065D938 /* AnimationsView.swift in Sources */, 0BDBDAEC2B825F910045A008 /* Bool.swift in Sources */, 0B9BDC132899A6F900842161 /* Array.swift in Sources */, @@ -716,18 +765,21 @@ 0BBF54AA2AD1F9CF0095B63F /* MagnitudeAxesSummaryView.swift in Sources */, 0BC7C19A2AADF269004ECD9F /* AttitudeAxes.swift in Sources */, 0B24EC4128623C1B00AD3EF1 /* Double.swift in Sources */, + 0B96B7432E8AD39C0007E730 /* RecordingBigChartView.swift in Sources */, 0B508CD6289C3E4A00326C6E /* DatabaseManager.swift in Sources */, 0B4CC365287C31AB000F0C94 /* DiagramViewStyle.swift in Sources */, 0B4CC363287C30FF000F0C94 /* DiagramAxesNamesStyle.swift in Sources */, - 0BADCA5F289A720600238A25 /* RecordingMeasurementView.swift in Sources */, 0BBF0918289D163300B42740 /* RecordingEntryRealm.swift in Sources */, 0B38C323288DC56D00A4541A /* AxesSummaryViewExtended.swift in Sources */, + 0B9FB1572E8F653300D70505 /* ExportModifier.swift in Sources */, 0BFBA1E828737CC300529B50 /* ObservableAxes.swift in Sources */, 0B24EC3228621BE000AD3EF1 /* Measurer.swift in Sources */, + 0B96B7472E8AD49F0007E730 /* RecordingChartContent.swift in Sources */, 0BBAD4AF2895715400251262 /* CGFloat.swift in Sources */, 0B24EC4E2862898F00AD3EF1 /* View.swift in Sources */, 0BBAD4AD2895564C00251262 /* RecordingSummaryView.swift in Sources */, 0BE138EE2893FFA10026D0DC /* RecordingsView.swift in Sources */, + 0B1845512E7DA059004F5474 /* RecordingProcessor.swift in Sources */, 0BA63B702897EFED0040B63D /* Date.swift in Sources */, 0B24EC4C2862690800AD3EF1 /* HeaderView.swift in Sources */, 0BA63B722897FBCE0040B63D /* RecordingPreview.swift in Sources */, @@ -817,7 +869,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -871,7 +923,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -893,17 +945,20 @@ DEVELOPMENT_TEAM = 3HG9ZBM8AV; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Acc.elerometer; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1.2; + MARKETING_VERSION = 0.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.panandafog.Accelerometer; PRODUCT_NAME = Acc.elerometer; SWIFT_EMIT_LOC_STRINGS = YES; @@ -924,17 +979,20 @@ DEVELOPMENT_TEAM = 3HG9ZBM8AV; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Acc.elerometer; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1.2; + MARKETING_VERSION = 0.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.panandafog.Accelerometer; PRODUCT_NAME = Acc.elerometer; SWIFT_EMIT_LOC_STRINGS = YES; @@ -1065,21 +1123,21 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 0B9BDC0F289973CC00842161 /* XCRemoteSwiftPackageReference "ChartView" */ = { + 0B96B73D2E8A91510007E730 /* XCRemoteSwiftPackageReference "swift-collections" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/AppPear/ChartView"; + repositoryURL = "https://github.com/apple/swift-collections.git"; requirement = { - kind = exactVersion; - version = 1.5.5; + kind = upToNextMajorVersion; + minimumVersion = 1.2.1; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 0B9BDC10289973CC00842161 /* SwiftUICharts */ = { + 0B96B73E2E8A91510007E730 /* DequeModule */ = { isa = XCSwiftPackageProductDependency; - package = 0B9BDC0F289973CC00842161 /* XCRemoteSwiftPackageReference "ChartView" */; - productName = SwiftUICharts; + package = 0B96B73D2E8A91510007E730 /* XCRemoteSwiftPackageReference "swift-collections" */; + productName = DequeModule; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/Accelerometer.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Accelerometer.xcworkspace/xcshareddata/swiftpm/Package.resolved index 99bc634..a95f96a 100644 --- a/Accelerometer.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Accelerometer.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "4fa034e453faafc0169cb98ce1b88bc4e38f64f1cc0a176127db6dd00618e302", + "originHash" : "51f90653b2c9f9f7064c0d52159b40bf7d222e5f314be23e62fe28520fec03db", "pins" : [ { - "identity" : "chartview", + "identity" : "swift-collections", "kind" : "remoteSourceControl", - "location" : "https://github.com/AppPear/ChartView", + "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "9115a992c91fa19cbb4f2241084240d38654a1fc", - "version" : "1.5.5" + "revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341", + "version" : "1.2.1" } } ], diff --git a/Accelerometer/AccelerometerApp.swift b/Accelerometer/AccelerometerApp.swift index 8a4b22c..f5fbb44 100644 --- a/Accelerometer/AccelerometerApp.swift +++ b/Accelerometer/AccelerometerApp.swift @@ -17,7 +17,7 @@ struct AccelerometerApp: App { init() { self.measurer = Measurer(settings: settings) - self.recorder = Recorder(measurer: measurer) + self.recorder = Recorder(measurer: measurer, settings: settings) } var body: some Scene { diff --git a/Accelerometer/ContentView.swift b/Accelerometer/ContentView.swift index 0b2581a..b87866e 100644 --- a/Accelerometer/ContentView.swift +++ b/Accelerometer/ContentView.swift @@ -8,44 +8,50 @@ import SwiftUI struct ContentView: View { - + @EnvironmentObject var recorder: Recorder + @State private var selectedTab = 0 + var body: some View { - TabView { + TabView(selection: $selectedTab) { NavigationView { MeasurementsView() - .navigationTitle(Text("Measurements")) + .navigationTitle("Measurements") } .phoneOnlyStackNavigationView() .tabItem { Label("Measurements", systemImage: "list.bullet") } - + .tag(0) + NavigationView { RecordingsView() - .navigationTitle(Text("Recordings")) + .navigationTitle("Recordings") } .phoneOnlyStackNavigationView() .tabItem { Label("Recordings", systemImage: "play") } - + .tag(1) + NavigationView { SettingsView() - .navigationTitle(Text("Settings")) + .navigationTitle("Settings") } .navigationViewStyle(.stack) .tabItem { Label("Settings", systemImage: "gear") } + .tag(2) } } } + struct ContentView_Previews: PreviewProvider { static let settings = Settings() static let measurer = Measurer(settings: settings) - static let recorder = Recorder(measurer: measurer) + static let recorder = Recorder(measurer: measurer, settings: settings) static var previews: some View { ContentView() diff --git a/Accelerometer/Extensions/Bool.swift b/Accelerometer/Extensions/Bool.swift index d7067b7..333b05a 100644 --- a/Accelerometer/Extensions/Bool.swift +++ b/Accelerometer/Extensions/Bool.swift @@ -6,9 +6,29 @@ // import Foundation +import Charts -extension Bool: Comparable { +extension Bool: @retroactive Comparable { public static func < (lhs: Bool, rhs: Bool) -> Bool { (lhs == false) && (rhs == true) } } + +extension Bool: @retroactive Plottable { + public typealias PrimitivePlottable = Double + + public var primitivePlottable: Double { + self ? 1.0 : 0.0 + } + + public init?(primitivePlottable: Double) { + switch primitivePlottable { + case 0.0: + self = false + case 1.0: + self = true + default: + return nil + } + } +} diff --git a/Accelerometer/Extensions/View.swift b/Accelerometer/Extensions/View.swift index 8acd732..d22c573 100644 --- a/Accelerometer/Extensions/View.swift +++ b/Accelerometer/Extensions/View.swift @@ -23,11 +23,11 @@ extension View { var statusBarOrientation: UIInterfaceOrientation? { get { guard let orientation = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation else { - #if DEBUG +#if DEBUG fatalError("Could not obtain UIInterfaceOrientation from a valid windowScene") - #else +#else return nil - #endif +#endif } return orientation } @@ -40,6 +40,30 @@ extension View { self } } + + func exportable( + isPresented: Binding, + url: Binding, + filename: String + ) -> some View { + modifier(ExportModifier( + isPresented: isPresented, + url: url, + filename: filename + )) + } + + func exportable( + isPresented: Binding, + url: Binding, + measurementType: MeasurementType + ) -> some View { + modifier(ExportModifier( + isPresented: isPresented, + url: url, + measurementType: measurementType + )) + } } struct RoundedCorner: Shape { diff --git a/Accelerometer/Measurements/Axes/AxesSummaryViewExtended.swift b/Accelerometer/Measurements/Axes/AxesSummaryViewExtended.swift index e11f32d..09ffc4c 100644 --- a/Accelerometer/Measurements/Axes/AxesSummaryViewExtended.swift +++ b/Accelerometer/Measurements/Axes/AxesSummaryViewExtended.swift @@ -66,113 +66,75 @@ struct AxesSummaryViewExtended: View { } } +// MARK: - Previews + struct AxesSummaryViewExtended_Previews: PreviewProvider { - static let settings = Settings() + // MARK: Test Measurers - // TODO: refactor this - static let measurer1: Measurer = { - let measurer = Measurer(settings: settings) - var axes = TriangleAxes.zero - axes.displayableAbsMax = 1.0 - measurer.saveData( - axesType: TriangleAxes.self, - measurementType: .userAcceleration, - values: [ - .x: 0, - .y: 0, - .z: 0 - ] - ) - measurer.saveData( - axesType: TriangleAxes.self, - measurementType: .magneticField, - values: [ - .x: 0, - .y: 0, - .z: 0 - ] - ) - return measurer - }() + struct MeasurerScenario { + let title: String + let measurer: Measurer + } - static let measurer2: Measurer = { - let measurer = Measurer(settings: settings) - var axes = TriangleAxes.zero - axes.displayableAbsMax = 1.0 - measurer.saveData( - axesType: TriangleAxes.self, - measurementType: .userAcceleration, - values: [ - .x: 0.5, - .y: 0.5, - .z: 0.5 - ] - ) - measurer.saveData( - axesType: TriangleAxes.self, - measurementType: .magneticField, - values: [ - .x: 100, - .y: 100, - .z: 100 - ] - ) - return measurer - }() + private static let settings = Settings() - static let measurer3: Measurer = { - let measurer = Measurer(settings: settings) - var axes = TriangleAxes.zero - axes.displayableAbsMax = 1.0 - measurer.saveData( - axesType: TriangleAxes.self, - measurementType: .userAcceleration, - values: [ - .x: 1, - .y: 1, - .z: 1 - ] - ) - measurer.saveData( - axesType: TriangleAxes.self, - measurementType: .magneticField, - values: [ - .x: 200, - .y: 200, - .z: 200 - ] - ) - return measurer - }() + private static func makeMeasurer(x: Double, y: Double, z: Double) -> Measurer { + let m = Measurer(settings: settings) + m.saveData(axesType: TriangleAxes.self, measurementType: .userAcceleration, + values: [.x: x, .y: y, .z: z]) + m.saveData(axesType: TriangleAxes.self, measurementType: .magneticField, + values: [.x: x * 200, .y: y * 200, .z: z * 200]) + return m + } + + private static let scenarios: [MeasurerScenario] = [ + MeasurerScenario(title: "Zero", measurer: makeMeasurer(x: 0, y: 0, z: 0)), + MeasurerScenario(title: "Half-Max",measurer: makeMeasurer(x: 0.5, y: 0.5, z: 0.5)), + MeasurerScenario(title: "Full-Max",measurer: makeMeasurer(x: 1, y: 1, z: 1)) + ] + + // MARK: Preview static var previews: some View { Group { - AxesSummaryViewExtended(type: .userAcceleration) - .padding() - .previewLayout(.sizeThatFits) - .environmentObject(measurer1) - AxesSummaryViewExtended(type: .userAcceleration) - .padding() - .previewLayout(.sizeThatFits) - .environmentObject(measurer2) - AxesSummaryViewExtended(type: .userAcceleration) - .padding() - .previewLayout(.sizeThatFits) - .environmentObject(measurer3) - AxesSummaryViewExtended(type: .magneticField) - .padding() - .previewLayout(.sizeThatFits) - .environmentObject(measurer1) - AxesSummaryViewExtended(type: .magneticField) - .padding() - .previewLayout(.sizeThatFits) - .environmentObject(measurer2) - AxesSummaryViewExtended(type: .magneticField) - .padding() - .previewLayout(.sizeThatFits) - .environmentObject(measurer3) + ForEach([MeasurementType.userAcceleration, .magneticField], id: \.self) { type in + PreviewSection(type: type, scenarios: scenarios) + } } .environmentObject(settings) } } + +// MARK: Helper Views + +private struct PreviewSection: View { + let type: MeasurementType + let scenarios: [AxesSummaryViewExtended_Previews.MeasurerScenario] + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(type.name.capitalizingFirstLetter()) + .font(.title2) + .padding(.bottom) + + VStack(spacing: 20) { + ForEach(scenarios, id: \.title) { scenario in + VStack(spacing: 8) { + Text(scenario.title) + .font(.caption) + AxesSummaryViewExtended(type: type) + .environmentObject(scenario.measurer) + .padding() + .background(Color(.systemBackground)) + .cornerRadius(6) + .shadow(radius: 1) + } + } + } + } + .padding() + .previewDisplayName(type.name.capitalizingFirstLetter()) + .previewLayout(.sizeThatFits) + } +} diff --git a/Accelerometer/Measurements/MeasurementSummaryView.swift b/Accelerometer/Measurements/MeasurementSummaryView.swift index d66c765..79434f1 100644 --- a/Accelerometer/Measurements/MeasurementSummaryView.swift +++ b/Accelerometer/Measurements/MeasurementSummaryView.swift @@ -23,9 +23,15 @@ struct MeasurementSummaryView: View { } var recordButton: some View { - let enabled = !recorder.recordingInProgress - let text = enabled ? "Start recording this value" : "Recording is already enabled" - let backgroundColor: Color = enabled ? .enabledButton : .disabledButton + let enabled = !recorder.recordingInProgress && recorder.hasEnoughMemory + + let text = if !recorder.hasEnoughMemory { + "Not enough memory" + } else if recorder.recordingInProgress { + "Recording is already started" + } else { + "Start recording this value" + } return Button( action: { @@ -35,10 +41,10 @@ struct MeasurementSummaryView: View { .foregroundColor(.background) } ) + .buttonStyle(.glassProminent) + .controlSize(.large) .disabled(!enabled) .padding(.defaultPadding) - .background(backgroundColor) - .cornerRadius(.defaultCornerRadius) } var body: some View { @@ -99,12 +105,12 @@ struct MeasurementSummaryView_Previews: PreviewProvider { }() static let recorder1: Recorder = { - let recorder = Recorder(measurer: measurer) + let recorder = Recorder(measurer: measurer, settings: settings) return recorder }() static let recorder2: Recorder = { - let recorder = Recorder(measurer: measurer) + let recorder = Recorder(measurer: measurer, settings: settings) recorder.record(measurements: [.acceleration]) return recorder }() diff --git a/Accelerometer/Measurements/MeasurementType.swift b/Accelerometer/Measurements/MeasurementType.swift index 953f7bb..736240c 100644 --- a/Accelerometer/Measurements/MeasurementType.swift +++ b/Accelerometer/Measurements/MeasurementType.swift @@ -20,6 +20,8 @@ enum MeasurementType: String, CaseIterable, Sendable { Self.allCases.filter { !$0.isHidden } } + // MARK: - Axes Configuration + var axesType: AxesType { switch self { case .attitude: @@ -31,6 +33,8 @@ enum MeasurementType: String, CaseIterable, Sendable { } } + // MARK: - Display Properties + var name: String { switch self { case .acceleration: @@ -105,6 +109,29 @@ A G is a unit of gravitation force equal to that exerted by the earth’s gravit } } + /// SF Symbol name for this measurement type + var iconName: String { + switch self { + case .acceleration: + return "speedometer" + case .rotationRate: + return "gyroscope" + case .userAcceleration: + return "figure.walk.motion" + case .magneticField: + return "wave.3.left" + case .attitude: + return "perspective" + case .gravity: + return "globe" + case .proximity: + return "antenna.radiowaves.left.and.right" + } + } + + + // MARK: - Capabilities + var hasMinimum: Bool { switch self { case .acceleration: diff --git a/Accelerometer/Measurements/Values/Axes.swift b/Accelerometer/Measurements/Values/Axes.swift index 46f371a..f9ecfaa 100644 --- a/Accelerometer/Measurements/Values/Axes.swift +++ b/Accelerometer/Measurements/Values/Axes.swift @@ -5,10 +5,11 @@ // Created by Andrey on 26.08.2023. // +import Charts import Foundation protocol Axes: Equatable, Sendable { - associatedtype ValueType: LosslessStringConvertible, Comparable, Sendable + associatedtype ValueType: LosslessStringConvertible, Comparable, Sendable, Plottable static var axesTypes: Set { get } diff --git a/Accelerometer/Other/MemoryMonitor.swift b/Accelerometer/Other/MemoryMonitor.swift new file mode 100644 index 0000000..ebab1b4 --- /dev/null +++ b/Accelerometer/Other/MemoryMonitor.swift @@ -0,0 +1,24 @@ +// +// MemoryMonitor.swift +// Accelerometer +// +// Created by Andrey Pantyuhin on 19.09.2025. +// + +import Foundation + +actor MemoryMonitor { + + func freeSpaceMB() -> Double { + let fm = FileManager.default + + guard let url = fm.urls(for: .documentDirectory, in: .userDomainMask).first, + let attrs = try? fm.attributesOfFileSystem(forPath: url.path), + let free = attrs[.systemFreeSize] as? NSNumber + else { + return .infinity + } + + return free.doubleValue / 1_048_576.0 + } +} diff --git a/Accelerometer/Other/PreviewUtils.swift b/Accelerometer/Other/PreviewUtils.swift new file mode 100644 index 0000000..2f0c543 --- /dev/null +++ b/Accelerometer/Other/PreviewUtils.swift @@ -0,0 +1,216 @@ +// +// PreviewUtils.swift +// Accelerometer +// +// Created by Andrey Pantyuhin on 03.10.2025. +// + +import Foundation + +#if DEBUG +struct PreviewUtils { + + // MARK: - Sample Recordings + + static let shortRecording = Recording( + id: "short-sample", + start: Date().addingTimeInterval(-30), + end: Date(), + entries: generateEntries( + duration: 30, + pointCount: 300, + measurementTypes: [.acceleration, .userAcceleration] + ), + state: .completed, + measurementTypes: [.acceleration, .userAcceleration] + ) + + static let mediumRecording = Recording( + id: "medium-sample", + start: Date().addingTimeInterval(-600), // 10 minutes + end: Date(), + entries: generateEntries( + duration: 600, + pointCount: 1200, + measurementTypes: [.acceleration, .rotationRate, .gravity] + ), + state: .completed, + measurementTypes: [.acceleration, .rotationRate, .gravity] + ) + + static let longRecording = Recording( + id: "long-sample", + start: Date().addingTimeInterval(-3600), // 1 hour + end: Date(), + entries: generateEntries( + duration: 3600, + pointCount: 3600, + measurementTypes: [.acceleration, .rotationRate, .userAcceleration, .magneticField] + ), + state: .completed, + measurementTypes: [.acceleration, .rotationRate, .userAcceleration, .magneticField] + ) + + static let activeRecording = Recording( + id: "active-sample", + start: Date().addingTimeInterval(-120), // 2 minutes ago + end: nil, + entries: generateEntries( + duration: 120, + pointCount: 240, + measurementTypes: [.acceleration] + ), + state: .inProgress, + measurementTypes: [.acceleration] + ) + + static let emptyRecording = Recording( + id: "empty-sample", + start: Date().addingTimeInterval(-60), + end: Date(), + entries: [], + state: .completed, + measurementTypes: [.acceleration] + ) + + // MARK: - Sample Axes + + static var randomTriangleAxes: TriangleAxes { + var axes = TriangleAxes.zero + axes.displayableAbsMax = 1.0 + axes.values = [ + .x: Axis(type_: .x, value: Double.random(in: -1.0...1.0)), + .y: Axis(type_: .y, value: Double.random(in: -1.0...1.0)), + .z: Axis(type_: .z, value: Double.random(in: -1.0...1.0)) + ] + return axes + } + + static func sinusoidalAxes(index: Int, amplitude: Double = 1.0) -> TriangleAxes { + var axes = TriangleAxes.zero + axes.displayableAbsMax = amplitude + axes.values = [ + .x: Axis(type_: .x, value: sin(Double(index) * 0.1) * amplitude * Double.random(in: 0.8...1.0)), + .y: Axis(type_: .y, value: cos(Double(index) * 0.15) * amplitude * Double.random(in: 0.8...1.0)), + .z: Axis(type_: .z, value: sin(Double(index) * 0.05) * amplitude * Double.random(in: 0.6...1.0)) + ] + return axes + } + + static func magneticFieldAxes(index: Int) -> TriangleAxes { + var axes = TriangleAxes.zero + axes.displayableAbsMax = 100.0 + axes.values = [ + .x: Axis(type_: .x, value: sin(Double(index) * 0.02) * 50 + Double.random(in: -10...10)), + .y: Axis(type_: .y, value: cos(Double(index) * 0.03) * 30 + Double.random(in: -5...5)), + .z: Axis(type_: .z, value: 40 + Double.random(in: -15...15)) + ] + return axes + } + + // MARK: - Entry Generation + + static func generateEntries( + duration: TimeInterval, + pointCount: Int, + measurementTypes: [MeasurementType] + ) -> [Recording.Entry] { + let startDate = Date().addingTimeInterval(-duration) + let timeStep = duration / Double(pointCount) + var entries: [Recording.Entry] = [] + + for i in 0.. any Axes { + switch type { + case .acceleration, .userAcceleration, .gravity: + return sinusoidalAxes(index: index, amplitude: 2.0) + case .rotationRate: + return sinusoidalAxes(index: index, amplitude: 0.5) + case .magneticField: + return magneticFieldAxes(index: index) + case .attitude: + // Simplified attitude axes + return sinusoidalAxes(index: index, amplitude: Double.pi) + case .proximity: + // Boolean proximity data + var axes = BooleanAxes() + axes.values = [ + .bool: Axis( + type_: .bool, + value: index % 10 < 3 // ~30% true + ) + ] + return axes + } + } + + // MARK: - Convenience Methods + + static func recordingWithType(_ type: MeasurementType, duration: TimeInterval = 300) -> Recording { + Recording( + id: "sample-\(type.rawValue)", + start: Date().addingTimeInterval(-duration), + end: Date(), + entries: generateEntries( + duration: duration, + pointCount: Int(duration / 2), // 2 second intervals + measurementTypes: [type] + ), + state: .completed, + measurementTypes: [type] + ) + } + + static func recordingWithMultipleTypes( + _ types: [MeasurementType], + duration: TimeInterval = 600 + ) -> Recording { + Recording( + id: "multi-sample-\(UUID().uuidString.prefix(8))", + start: Date().addingTimeInterval(-duration), + end: Date(), + entries: generateEntries( + duration: duration, + pointCount: Int(duration), + measurementTypes: types + ), + state: .completed, + measurementTypes: Set(types) + ) + } + + // MARK: - Preset Scenarios + + static let allSampleRecordings: [Recording] = [ + shortRecording, + mediumRecording, + longRecording, + activeRecording, + emptyRecording + ] + + static let allSampleCompletedRecordings: [Recording] = + allSampleRecordings.filter { $0.end != nil } + + static let magneticFieldTestRecording = recordingWithType(.magneticField, duration: 120) + + static let gravityTestRecording = recordingWithType(.gravity, duration: 60) +} +#endif + diff --git a/Accelerometer/Recordings/Charts/RecordingBigChartView.swift b/Accelerometer/Recordings/Charts/RecordingBigChartView.swift new file mode 100644 index 0000000..c74ede5 --- /dev/null +++ b/Accelerometer/Recordings/Charts/RecordingBigChartView.swift @@ -0,0 +1,46 @@ +// +// RecordingBigChartView.swift +// Accelerometer +// +// Created by Andrey Pantyuhin on 29.09.2025. +// + +import SwiftUI + +struct RecordingBigChartView: View { + let recording: Recording + let measurementType: MeasurementType + + var body: some View { + RecordingChartContainerView( + recording: recording, + measurementType: measurementType, + style: .big + ) + .padding(.vertical) + .navigationTitle(measurementType.name.capitalized) + .navigationBarTitleDisplayMode(.inline) + } +} + +struct RecordingBigChartView_Previews: PreviewProvider { + static var previews: some View { + Group { + NavigationView { + RecordingBigChartView( + recording: PreviewUtils.shortRecording, + measurementType: .acceleration + ) + } + .previewDisplayName("Medium") + + NavigationView { + RecordingBigChartView( + recording: PreviewUtils.longRecording, + measurementType: .magneticField + ) + } + .previewDisplayName("Long") + } + } +} diff --git a/Accelerometer/Recordings/Charts/RecordingChartContainerView.swift b/Accelerometer/Recordings/Charts/RecordingChartContainerView.swift new file mode 100644 index 0000000..bab8d14 --- /dev/null +++ b/Accelerometer/Recordings/Charts/RecordingChartContainerView.swift @@ -0,0 +1,227 @@ +// +// RecordingChartContainerView.swift +// Accelerometer +// +// Created by Andrey Pantyuhin on 22.03.2025. +// + +import SwiftUI +import Charts +import UniformTypeIdentifiers + +struct RecordingChartContainerView: View { + + let recording: Recording + let measurementType: MeasurementType + let style: Style + + @EnvironmentObject private var settings: Settings + @State private var chartEntries: [Recording.Entry] = [] + @State private var isLoading = true + @State private var isPresentingExporter = false + @State private var exportURL: URL? + + private let processor = RecordingProcessor() + + private var startDate: Date { + chartEntries.first?.date ?? Date() + } + + private var totalDuration: TimeInterval { + guard let last = chartEntries.last?.date else { return 0 } + return last.timeIntervalSince(startDate) + } + + private var chartXAxisStride: Double { + let niceIntervals: [Double] = [ + // seconds + 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, + // minutes + 2*60, 5*60, 10*60, 15*60, 30*60, + // hours + 60*60, 2*60*60, 3*60*60, 6*60*60, 12*60*60 + ] + + for interval in niceIntervals { + let tickCount = Int(totalDuration / interval) + if tickCount <= style.tickCount { + return interval + } + } + + return totalDuration / Double(style.tickCount) + } + + private func formatElapsedTime(_ seconds: TimeInterval) -> String { + let absSeconds = abs(seconds) + + if absSeconds < 1 { + return String(format: "%.1fs", seconds) + } else if absSeconds < 60 { + return String(format: "%.0fs", seconds) + } else if absSeconds < 3600 { + // If stride is less than a minute, display minutes:seconds + if chartXAxisStride < 60 { + let minutes = Int(seconds / 60) + let remainingSeconds = Int(seconds.truncatingRemainder(dividingBy: 60)) + return String(format: "%dm%02ds", minutes, remainingSeconds) + } else { + // Otherwise only minutes + let minutes = seconds / 60 + return String(format: "%.0fm", minutes) + } + } else { + if chartXAxisStride < 3600 { + let hours = Int(seconds / 3600) + let minutes = Int((seconds.truncatingRemainder(dividingBy: 3600)) / 60) + return String(format: "%dh%02dm", hours, minutes) + } else { + let hours = seconds / 3600 + if hours.truncatingRemainder(dividingBy: 1) == 0 { + return String(format: "%.0fh", hours) + } else { + return String(format: "%.1fh", hours) + } + } + } + } + + var body: some View { + chart + .exportable( + isPresented: $isPresentingExporter, + url: $exportURL, + measurementType: measurementType + ) + .task { + await loadData() + } + } + + @ViewBuilder + var chartContainer: some View { + if isLoading { + ProgressView() + } else { + chart + } + } + + @ViewBuilder + var chart: some View { + Chart(chartEntries) { entry in + RecordingChartContent( + entry: entry, + // Can be delay between start and first entry + startDate: chartEntries.first?.date ?? recording.start + ) + } + .chartXScale(domain: 0...totalDuration) + .chartXAxis { + let values = AxisMarkValues.stride(by: chartXAxisStride) + AxisMarks(values: values) { value in + AxisGridLine() + AxisTick() + if let seconds = value.as(Double.self) { + AxisValueLabel { + Text(formatElapsedTime(seconds)) + } + } + } + } + .modify { + switch style { + case .big: + $0 + .chartScrollableAxes(.horizontal) + .chartScrollPosition(initialX: Int.max) + case .small: + $0 + } + } + .contextMenu { + Button("Export \(measurementType.name)") { + export() + } + } + } + + private func loadData() async { + isLoading = true + chartEntries = await RecordingProcessor() + .sampledEntries( + from: recording, + for: measurementType, + maxCount: style.maxEntries + ) + isLoading = false + } + + private func export() { + Task { + if let url = try? await processor.generateCSV( + from: recording, + for: measurementType, + dateFormat: settings.exportDateFormat + ) { + exportURL = url + isPresentingExporter = true + } + } + } + + enum Style { + case big + case small + + var maxEntries: Int { + switch self { + case .big: + 300 + case .small: + 100 + } + } + + var tickCount: Int { + switch self { + case .big: + 16 + case .small: + 4 + } + } + } +} + +// MARK: - Previews + +struct RecordingChartContainerView_Previews: PreviewProvider { + static let recording = PreviewUtils.mediumRecording + static let type = measurementType(from: recording) + + static var previews: some View { + Group { + RecordingChartContainerView( + recording: recording, + measurementType: type, + style: .small + ) + .frame(height: 200) + .padding() + .previewDisplayName("Small") + + RecordingChartContainerView( + recording: recording, + measurementType: type, + style: .big + ) + .previewDisplayName("Big") + } + .environmentObject(Settings()) + } + + private static func measurementType(from recording: Recording) -> MeasurementType { + recording.sortedMeasurementTypes.first ?? .acceleration + } +} diff --git a/Accelerometer/Recordings/Charts/RecordingChartContent.swift b/Accelerometer/Recordings/Charts/RecordingChartContent.swift new file mode 100644 index 0000000..cbdc9dc --- /dev/null +++ b/Accelerometer/Recordings/Charts/RecordingChartContent.swift @@ -0,0 +1,39 @@ +// +// RecordingChartContent.swift +// Accelerometer +// +// Created by Andrey Pantyuhin on 29.09.2025. +// + +import Charts +import SwiftUI + +struct RecordingChartContent: ChartContent { + let entry: Recording.Entry + let startDate: Date + + var body: AnyChartContent { + let axes = entry.axes + AnyChartContent(erasing: createLineMarks(axes: axes)) + } + + @ChartContentBuilder + private func createLineMarks(axes: some Axes) -> some ChartContent { + + let axesTypes = type(of: axes).sortedAxesTypes + + ForEach(axesTypes) { axeType in + if let yAxis = axes.values[axeType] { + let elapsed = entry.date.timeIntervalSince(startDate) + let xName = "elapsed" + let yName = yAxis.type_.name + + LineMark( + x: .value(xName, elapsed), + y: .value(yName, yAxis.value) + ) + .foregroundStyle(by: .value("name", yName)) + } + } + } +} diff --git a/Accelerometer/Recordings/Charts/RecordingSmallChartView.swift b/Accelerometer/Recordings/Charts/RecordingSmallChartView.swift new file mode 100644 index 0000000..cc0074b --- /dev/null +++ b/Accelerometer/Recordings/Charts/RecordingSmallChartView.swift @@ -0,0 +1,57 @@ +// +// RecordingMeasurementChartView.swift +// Accelerometer +// +// Created by Andrey Pantyuhin on 22.03.2025. +// + +import Charts +import SwiftUI + +struct RecordingSmallChartView: View { + let recording: Recording + let measurementType: MeasurementType + + var body: some View { + NavigationLink(destination: RecordingBigChartView( + recording: recording, + measurementType: measurementType + )) { + RecordingChartContainerView( + recording: recording, + measurementType: measurementType, + style: .small + ) + } + } +} + +// MARK: - Previews + +struct RecordingSmallChartView_Previews: PreviewProvider { + + static var sampleRecordings: [Recording] = [ + PreviewUtils.shortRecording, + PreviewUtils.mediumRecording, + PreviewUtils.longRecording + ] + + static var previews: some View { + Group { + ForEach(sampleRecordings, id: \.id) { rec in + VStack(alignment: .leading) { + Text("Recording: \(rec.id)") + .font(.subheadline) + .foregroundColor(.secondary) + RecordingSmallChartView( + recording: rec, + measurementType: rec.sortedMeasurementTypes.first! + ) + } + .padding() + .previewDisplayName(rec.id) + .previewLayout(.sizeThatFits) + } + } + } +} diff --git a/Accelerometer/Recordings/Database/RecordingRealm.swift b/Accelerometer/Recordings/Database/RecordingRealm.swift index 042e632..1a9dfe0 100644 --- a/Accelerometer/Recordings/Database/RecordingRealm.swift +++ b/Accelerometer/Recordings/Database/RecordingRealm.swift @@ -11,16 +11,25 @@ import RealmSwift class RecordingRealm: Object { @objc dynamic var id: String = .init() - @objc dynamic var created: NSDate = .init() + @objc dynamic var start: NSDate = .init() + @objc dynamic var end: NSDate? = nil @objc dynamic var state: String = .init() let measurementTypes = List() let entries = List() - convenience init(id: String, created: NSDate, state: String, measurementTypes: [String], entries: [RecordingEntryRealm]) { + convenience init( + id: String, + start: NSDate, + end: NSDate?, + state: String, + measurementTypes: [String], + entries: [RecordingEntryRealm] + ) { self.init() self.id = id - self.created = created + self.start = start + self.end = end self.state = state self.measurementTypes.append(objectsIn: measurementTypes) self.entries.append(objectsIn: entries) @@ -36,7 +45,8 @@ extension RecordingRealm { var recording: Recording { Recording( id: id, - created: created as Date, + start: start as Date, + end: end as Date?, entries: Array(entries) .compactMap { $0.entry }, state: .init(rawValue: state)!, @@ -45,13 +55,26 @@ extension RecordingRealm { ) } + var recordingMetadata: Recording { + return Recording( + id: id, + start: start as Date, + end: end as Date?, + entries: nil, + state: .init(rawValue: state)!, + measurementTypes: Set(Array(measurementTypes) + .compactMap { MeasurementType(rawValue: $0) }) + ) + } + convenience init(recording: Recording) { self.init( id: recording.id, - created: recording.created as NSDate, + start: recording.start as NSDate, + end: recording.end as NSDate?, state: recording.state.rawValue, measurementTypes: Array(recording.measurementTypes).map { $0.rawValue }, - entries: recording.entries.compactMap { RecordingEntryRealm(entry: $0) } + entries: recording.entries?.compactMap { RecordingEntryRealm(entry: $0) } ?? [] ) } } diff --git a/Accelerometer/Recordings/Database/RecordingsRepository.swift b/Accelerometer/Recordings/Database/RecordingsRepository.swift index 2c6ad02..67d6f20 100644 --- a/Accelerometer/Recordings/Database/RecordingsRepository.swift +++ b/Accelerometer/Recordings/Database/RecordingsRepository.swift @@ -7,34 +7,59 @@ import RealmSwift -class RecordingsRepository: ObservableObject { +actor RecordingsRepository { - private let databaseManager = DatabaseManagerImpl(configuration: .defaultConfiguration) - - var recordings: [Recording] = [] + private let databaseManager = DatabaseManagerImpl( + configuration: .defaultConfiguration + ) + + private(set) var recordingsMetadata: [String: Recording] = [:] - func save(_ recordings: [Recording]) { - databaseManager.write( - recordings.map { RecordingRealm(recording: $0) } - ) + func save(_ items: [Recording]) { + let realms = items.map { RecordingRealm(recording: $0) } + databaseManager.write(realms) + + updateMetadata() } - func delete(recordings: [Recording]) { - databaseManager.delete(recordings.map({ RecordingRealm(recording: $0) })) + func delete(recordingID: String) { + let all: [RecordingRealm] = Array(databaseManager.read() as Results) + if let recording = all.first(where: { $0.id == recordingID }) { + databaseManager.delete([recording]) + } + + recordingsMetadata.removeValue(forKey: recordingID) } - func delete(recordingID: String) { - let recordings: [RecordingRealm] = Array(databaseManager.read() as Results) - guard let recording = recordings.first(where: { $0.id == recordingID }) else { - return + func delete(recordingIDs: [String]) { + let all: [RecordingRealm] = Array(databaseManager.read() as Results) + let toDelete = all.filter { recordingIDs.contains($0.id) } + databaseManager.delete(toDelete) + + recordingIDs.forEach { + recordingsMetadata.removeValue(forKey: $0) } - databaseManager.delete([recording]) } - func update() { - let realmRecordings: Results = databaseManager.read() - recordings = Array(realmRecordings) - .map { $0.recording } + func updateMetadata() { + let realmRecs: Results = databaseManager.read() + let metas = realmRecs + .map(\.recordingMetadata) .reversed() + + recordingsMetadata = Dictionary( + uniqueKeysWithValues: metas.map { metadata in + (metadata.id, metadata) + } + ) + } + + func loadFullRecording(id: String) -> Recording? { + let results: Results = databaseManager.read() + guard let realm = results.first(where: { $0.id == id }) else { + return nil + } + + return realm.recording } } diff --git a/Accelerometer/Recordings/ExportModifier.swift b/Accelerometer/Recordings/ExportModifier.swift new file mode 100644 index 0000000..4068475 --- /dev/null +++ b/Accelerometer/Recordings/ExportModifier.swift @@ -0,0 +1,46 @@ +// +// ExportModifier.swift +// Accelerometer +// +// Created by Andrey Pantyuhin on 03.10.2025. +// + +import SwiftUI +import UniformTypeIdentifiers + +struct ExportModifier: ViewModifier { + + @Binding var isPresented: Bool + @Binding var url: URL? + let filename: String + + init( + isPresented: Binding, + url: Binding, + filename: String + ) { + _isPresented = isPresented + _url = url + self.filename = filename + } + + init( + isPresented: Binding, + url: Binding, + measurementType: MeasurementType + ) { + _isPresented = isPresented + _url = url + self.filename = "\(measurementType.rawValue).csv" + } + + func body(content: Content) -> some View { + content + .fileExporter( + isPresented: $isPresented, + document: url.map { FileDocumentWrapper(url: $0) }, + contentType: UTType.plainText, + defaultFilename: filename + ) { _ in } + } +} diff --git a/Accelerometer/Recordings/NewRecordingView.swift b/Accelerometer/Recordings/NewRecordingView.swift index a73db6f..ee85aa1 100644 --- a/Accelerometer/Recordings/NewRecordingView.swift +++ b/Accelerometer/Recordings/NewRecordingView.swift @@ -10,105 +10,79 @@ import SwiftUI struct NewRecordingView: View { @Environment(\.presentationMode) var presentationMode - @EnvironmentObject var recorder: Recorder @State var measurementTypes: [MeasurementType: Bool] = { - var dictionary: [MeasurementType: Bool] = [:] - MeasurementType.allShownCases.forEach { - dictionary[$0] = true - } - return dictionary + var dict: [MeasurementType: Bool] = [:] + MeasurementType.allShownCases.forEach { dict[$0] = true } + return dict }() - func setAllMeasurementTypes(selected: Bool) { - for key in Array(measurementTypes.keys) { - measurementTypes[key] = selected - } - } - - func allMeasurementTypesAre(selected: Bool) -> Bool { - !Array(measurementTypes.values).contains { - $0 == !selected - } - } - - var allMeasurementAreSelected: Bool { - allMeasurementTypesAre(selected: true) + private func startRecording() { + let enabled = measurementTypes + .compactMap { $0.value ? $0.key : nil } + recorder.record(measurements: Set(enabled)) + presentationMode.wrappedValue.dismiss() } - var allMeasurementAreUnselected: Bool { - allMeasurementTypesAre(selected: false) + private func setAll(_ selected: Bool) { + measurementTypes.keys.forEach { measurementTypes[$0] = selected } } - func startRecording() { - let enabledMeasurementTypes: Set = Set(measurementTypes.compactMap { - $0.value ? $0.key : nil - }) - recorder.record(measurements: enabledMeasurementTypes) - presentationMode.wrappedValue.dismiss() + private var allSelected: Bool { + !measurementTypes.values.contains(false) } - var startRecordingButton: some View { - let startAllowed = !allMeasurementAreUnselected - let text = startAllowed ? "Start recording" : "Select measurements to start recording" - let backgroundColor: Color = startAllowed ? .enabledButton : .disabledButton - - return Button( - action: { - startRecording() - } - ) { - Text(text) - .foregroundColor(.background) - - } - .disabled(!startAllowed) - .padding(.defaultPadding) - .background(backgroundColor) - .cornerRadius(CGFloat.defaultCornerRadius) + private var noneSelected: Bool { + !measurementTypes.values.contains(true) } var body: some View { NavigationView { - VStack { + VStack(spacing: 16) { + HStack { Spacer() - Button(action: { - if allMeasurementAreSelected { - setAllMeasurementTypes(selected: false) - } else { - setAllMeasurementTypes(selected: true) - } - }, label: { - Text(allMeasurementAreSelected ? "Unselect all" : "Select all") - .padding() - }) + Button(allSelected ? "Deselect all" : "Select all") { + setAll(!allSelected) + } + .buttonStyle(.bordered) + .tint(.accentColor) } + .padding(.horizontal) + List { - ForEach(MeasurementType.allShownCases, id: \.self) { measurementType in - HStack { - Toggle(isOn: .init( - get: { - measurementTypes[measurementType] ?? false - }, - set: { newValue in - measurementTypes[measurementType] = newValue + ForEach(MeasurementType.allShownCases, id: \.self) { type in + Toggle(type.name, isOn: Binding( + get: { measurementTypes[type] ?? false }, + set: { newValue in + withAnimation(.easeInOut) { + measurementTypes[type] = newValue } - )) { - Text(measurementType.name) } - } + )) + .tint(.accentColor) } } - .listStyle(.inset) - startRecordingButton - .padding() + .listStyle(.plain) + .animation(.easeInOut, value: measurementTypes) + + Button("Start") { + startRecording() + } + .buttonStyle(.glassProminent) + .controlSize(.large) + .tint(noneSelected ? .gray : .accentColor) + .disabled(noneSelected) + .padding(.horizontal) + .animation(.easeInOut, value: noneSelected) } .navigationTitle("New recording") .toolbar { - Button("Cancel") { - presentationMode.wrappedValue.dismiss() + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + presentationMode.wrappedValue.dismiss() + } } } } diff --git a/Accelerometer/Recordings/Recorder.swift b/Accelerometer/Recordings/Recorder.swift index 9d739da..82676cf 100644 --- a/Accelerometer/Recordings/Recorder.swift +++ b/Accelerometer/Recordings/Recorder.swift @@ -5,105 +5,213 @@ // Created by Andrey on 29.07.2022. // +import DequeModule import Combine import SwiftUI @MainActor class Recorder: ObservableObject { + + private static let memoryCheckCooldownNs: UInt64 = 5_000_000_000 + + @Published var isInEditMode = false + @Published var hasEnoughMemory = true + + @Published private(set) var recordingsMetadata: [Recording] = [] + @Published private(set) var activeRecording: Recording? = nil + private var activeRecordingEntries: Deque = [] + @ObservedObject private var measurer: Measurer + @ObservedObject private var settings: Settings - private let repository: RecordingsRepository = { - let repository = RecordingsRepository() - repository.update() - return repository - }() + private let repository = RecordingsRepository() + private let memoryMonitor = MemoryMonitor() private let disableIdleTimer = true + private var subscriptions: [AnyCancellable] = [] + + init(measurer: Measurer, settings: Settings) { + self.measurer = measurer + self.settings = settings + + Task { + await refreshRecordings() + } + Task { + await watchFreeSpace() + } + } - private(set) var activeRecording: Recording? = nil var recordingInProgress: Bool { activeRecording != nil } - var recordings: [Recording] { - var savedRecordings = repository.recordings - if let activeRecording = activeRecording { - savedRecordings.insert(activeRecording, at: 0) + // MARK: - Recordings management + + func record(measurements types: Set) { + Task { + if !hasEnoughMemory { return } + + await MainActor.run { + if disableIdleTimer { + UIApplication.shared.isIdleTimerDisabled = true + } + + guard !recordingInProgress, !types.isEmpty else { return } + activeRecording = Recording( + entries: [], + state: .inProgress, + measurementTypes: types + ) + activeRecordingEntries = [] + + types.forEach(subscribeForChanges) + + objectWillChange.send() + } } - return savedRecordings } - private var subscriptions: [AnyCancellable?] = [] - - init(measurer: Measurer) { - self.measurer = measurer + func stopRecording() { + Task { + await MainActor.run { + if disableIdleTimer { + UIApplication.shared.isIdleTimerDisabled = false + } + } + + guard var activeRecording = activeRecording else { return } + activeRecording.state = .completed + activeRecording.end = Date.now + activeRecording.entries = Array(activeRecordingEntries) + +#if (DEBUG) + print("--- Recording stopped ---") + for measurementType in activeRecording.sortedMeasurementTypes { + let count = activeRecordingEntries.filter { $0.measurementType == measurementType }.count + print("\(measurementType.name): \(count) entries") + } + let totalCount = activeRecordingEntries.count + print("Total entries: \(totalCount)") + print("-------------------------") +#endif + + activeRecordingEntries = [] + + await repository.save([activeRecording]) + await repository.updateMetadata() + await refreshRecordings() + + await MainActor.run { + self.activeRecording = nil + subscriptions.removeAll() + objectWillChange.send() + } + } } - func record(measurements measurementTypes: Set) { - if disableIdleTimer { - UIApplication.shared.isIdleTimerDisabled = true - } - - guard !recordingInProgress, !measurementTypes.isEmpty else { - return - } - - let newRecording = Recording(entries: [], state: .inProgress, measurementTypes: measurementTypes) - activeRecording = newRecording -// repository.save([newRecording]) - - for type in measurementTypes { - subscribeForChanges(of: type) + func delete(recordingID: String) { + Task { + await repository.delete(recordingID: recordingID) + await repository.updateMetadata() + await refreshRecordings() } } - func stopRecording() { - if disableIdleTimer { - UIApplication.shared.isIdleTimerDisabled = false - } - - guard recordingInProgress else { - return + func delete(recordingIDs: [String]) { + Task { + await repository.delete(recordingIDs: recordingIDs) + await repository.updateMetadata() + await refreshRecordings() } + } + + func loadFullRecording(id: String) async -> Recording? { + return await repository.loadFullRecording(id: id) + } + + private func subscribeForChanges(of type: MeasurementType) { + guard let obs = measurer.observableAxes[type] else { return } - if var activeRecording = activeRecording { - activeRecording.state = .completed - repository.save([activeRecording]) - repository.update() + let sub = obs.objectWillChange.sink { [weak self] in + self?.appendEntry(for: type) } - - print(activeRecording?.entries.count) - - activeRecording = nil - cancelSubscriptions() - objectWillChange.send() + subscriptions.append(sub) } - func delete(recordingID: String) { - repository.delete(recordingID: recordingID) - objectWillChange.send() + private func appendEntry(for type: MeasurementType) { + guard + let axes = measurer.observableAxes[type]?.axes, + activeRecording != nil, + hasEnoughMemory + else { return } + + activeRecordingEntries.append( + Recording.Entry( + measurementType: type, + date: Date(), + axes: axes + ) + ) } - private func subscribeForChanges(of measurementType: MeasurementType) { - let newSubsription = measurer.observableAxes[measurementType]?.objectWillChange.sink { [ weak self ] in - self?.save(axes: self?.measurer.observableAxes[measurementType]?.axes, of: measurementType) + private func refreshRecordings() async { + await repository.updateMetadata() + let stored = await repository.recordingsMetadata + await MainActor.run { + recordingsMetadata = Array(stored.values) + .sorted { $0.start > $1.start } } - subscriptions.append(newSubsription) } - private func save(axes: (any Axes)?, of type: MeasurementType) { - guard let axes = axes else { return } - save(axes: axes, of: type) + // MARK: - Memory control + + private func watchFreeSpace() async { + while true { + await checkMemory() + + try? await Task.sleep( + nanoseconds: Self.memoryCheckCooldownNs + ) + } } - private func save(axes: any Axes, of type: MeasurementType) { - let newEntry = Recording.Entry(measurementType: type, date: Date(), axes: axes) - activeRecording?.entries.append(newEntry) + private func checkMemory() async { +#if (DEBUG) + let hasEnoughMemory = if settings.alwaysNotEnoughMemory { + false + } else { + await hasEnoughMemory() + } +#else + let hasEnoughMemory = await hasEnoughMemory() +#endif + + if !hasEnoughMemory { + await MainActor.run { stopRecording() } + } - objectWillChange.send() + await MainActor.run { self.hasEnoughMemory = hasEnoughMemory } } - private func cancelSubscriptions() { - subscriptions = [] + + private func hasEnoughMemory() async -> Bool { + let freeMB = await memoryMonitor.freeSpaceMB() + let hasEnoughMemory = freeMB >= Double(Settings.minFreeSpaceMB) + return hasEnoughMemory + } + +#if (DEBUG) + func createDebugSamples() { + Task { + await repository.save( + PreviewUtils.allSampleCompletedRecordings + ) + await refreshRecordings() + await MainActor.run { + objectWillChange.send() + } + } } +#endif } diff --git a/Accelerometer/Recordings/Recording.swift b/Accelerometer/Recordings/Recording.swift index a5a796a..c96f225 100644 --- a/Accelerometer/Recordings/Recording.swift +++ b/Accelerometer/Recordings/Recording.swift @@ -6,27 +6,20 @@ // import Foundation -import CoreData struct Recording: Identifiable { let id: String - let created: Date + let start: Date + var end: Date? - var entries: [Entry] + var entries: [Entry]? var state: State - let measurementTypes: Set - - var start: Date? { - entries.first?.date ?? created - } - var end: Date? { - entries.last?.date - } + let measurementTypes: Set var duration: DateComponents? { - guard let start = start, let end = end else { + guard let end = (state == .inProgress) ? Date.now : end else { return nil } @@ -40,40 +33,21 @@ struct Recording: Identifiable { } } - init(id: String = UUID().uuidString, created: Date = Date(), entries: [Entry], state: State, measurementTypes: Set) { + init( + id: String = UUID().uuidString, + start: Date = Date(), + end: Date? = nil, + entries: [Entry]? = nil, + state: State, + measurementTypes: Set + ) { self.id = id - self.created = created + self.start = start + self.end = end self.entries = entries self.state = state self.measurementTypes = measurementTypes } - - func csv(of type: MeasurementType, dateFormat: Settings.ExportDateFormat) -> TextFile? { - guard measurementTypes.contains(type) else { - return nil - } - let filteredEntries = entries - .filter({ $0.measurementType == type }) - - var csvStrings = [RecordingUtils.stringsHeader(of: type)] - csvStrings.append(contentsOf: filteredEntries.map({ $0.getCsvString(dateFormat: dateFormat) })) - - return TextFile(initialText: csvStrings.joined(separator: RecordingUtils.rowSeparator)) - } - - func chartValues(of measurementType: MeasurementType) -> [[Double]] { - var chartEntries: [any ChartEntry] = [] - - for entry in entries where entry.measurementType == measurementType { - if let axes = entry.axes as? (any ChartEntry) { - chartEntries.append(axes) - } - } - - return chartEntries - .map({ $0.chartValues }) - .transposed() - } } extension Recording { diff --git a/Accelerometer/Recordings/RecordingMeasurementView.swift b/Accelerometer/Recordings/RecordingMeasurementView.swift deleted file mode 100644 index 4bd90c0..0000000 --- a/Accelerometer/Recordings/RecordingMeasurementView.swift +++ /dev/null @@ -1,170 +0,0 @@ -// -// RecordingMeasurementView.swift -// Accelerometer -// -// Created by Andrey on 03.08.2022. -// - -import SwiftUI -import SwiftUICharts - -struct RecordingMeasurementView: View { - - private static let maxRate = 999 - private static let minRate = -999 - - let recording: Recording - let measurementType: MeasurementType - let screenSize: CGSize - - let presentExporter: () -> Void - - func chartForm(generalSize: CGSize) -> CGSize { - CGSize(width: generalSize.width * 0.7, height: 240) - } - - var values: [[Double]] { - var entries = recording.chartValues(of: measurementType) - let limit = 100 - - for index in 0 ..< entries.count { - if entries[index].count > limit { - entries[index] = entries[index] - .chunked(into: Int((Double(entries[index].count) / 100.0).rounded())) - .map({ chunk -> Double in - let sumArray = chunk.reduce(0, +) - return sumArray / Double(chunk.count) - }) - } - } - - return entries - } - - var chartData: [([Double], GradientColor)] { - values.map { - ($0, GradientColor( - start: .intensity(0.0), - end: .intensity(1.0) - )) - } - } - - var rateValue: Int { - let allValues = values.first - guard let first = allValues?.first, - let last = allValues?.last else { - return 0 - } - guard first != 0.0 else { - if last == 0.0 { - return 0 - } else if last > 0.0 { - return Self.maxRate - } else { - return Self.minRate - } - } - let rate = Int((((last - first) / first) * 100.0).rounded()) - return max(min(rate, Self.maxRate), Self.minRate) - } - - var title: String { measurementType.name.capitalizingFirstLetter() } - - var body: some View { - HStack { - // TODO: common title - if measurementType.supportsChartRepresentation { - ScrollView { - MultiLineChartView( - data: chartData, - title: title, - legend: nil, - style: .recordingEntry, - form: chartForm(generalSize: screenSize), - rateValue: rateValue, - dropShadow: false - ) - }.disabled(true) - } else { - Text(title) - } - Spacer() - VStack { - Menu { - Section { - Button(action: { - presentExporter() - }) { - Label("Export to .csv", systemImage: "folder") - } - } - } label: { - Image(systemName: "square.and.arrow.up") - } - .padding([.vertical, .leading]) - .buttonStyle(BorderlessButtonStyle()) - Spacer() - } - } - } -} - -private extension ChartStyle { - - static var recordingEntry: ChartStyle { - ChartStyle( - backgroundColor: Color.secondaryBackground, - accentColor: Color.accentColor, - gradientColor: .init( - start: Color.accentColor, - end: Color.accentColor - ), - textColor: Color.primary, - legendTextColor: Color.primary, - dropShadowColor: Color.clear - ) - } - - static var recordingEntryDarkMode: ChartStyle { - ChartStyle( - backgroundColor: Color.accentColor, - accentColor: Color.accentColor, - gradientColor: .init( - start: Color.accentColor, - end: Color.accentColor - ), - textColor: Color.primary, - legendTextColor: Color.primary, - dropShadowColor: Color.clear - ) - } -} - -struct RecordingMeasurementView_Previews: PreviewProvider { - - static let axes: TriangleAxes = { - var axes = TriangleAxes.zero - axes.displayableAbsMax = 1.0 - return axes - }() - - static var previews: some View { - RecordingMeasurementView( - recording: Recording( - entries: [ - .init( - measurementType: .acceleration, - date: .init(), - axes: axes - ) - ], - state: .completed, - measurementTypes: [.acceleration] - ), - measurementType: .acceleration, - screenSize: CGSize(width: 300, height: 700), - presentExporter: {} - ) - } -} diff --git a/Accelerometer/Recordings/RecordingPreview.swift b/Accelerometer/Recordings/RecordingPreview.swift index fe2e7af..00fc002 100644 --- a/Accelerometer/Recordings/RecordingPreview.swift +++ b/Accelerometer/Recordings/RecordingPreview.swift @@ -6,47 +6,62 @@ // import SwiftUI +import Combine struct RecordingPreview: View { let recording: Recording + + @State private var timerPublisher: Timer.TimerPublisher? + @State private var timerCancellable: Cancellable? - var startString: String { - let startString: String - if let start = recording.start { - startString = DateFormatter.Recordings.string(from: start) - } else { - startString = "???" - } - return startString + @State private var durationStringState: String = "" + + private var durationString: String { + recording.duration?.durationString ?? "???" } - var durationString: String { - let durationString: String - if let duration = recording.duration { - durationString = duration.durationString - } else { - durationString = "???" - } - return durationString + private var startString: String { + DateFormatter.Recordings.string(from: recording.start) } var body: some View { - VStack { - VStack(alignment: .leading, spacing: 5) { - Text("Started: " + startString) - Text("Duration: " + durationString) - .padding([.bottom], 5) - Text("Measurements:") - ForEach(Array(recording.sortedMeasurementTypes).sorted(by: { lhs, rhs in - lhs.name < rhs.name - }), id: \.self) { measurementType in - Text(measurementType.name) - .padding(.leading) - .foregroundColor(.accentColor) - } + VStack(alignment: .leading, spacing: 5) { + Text("Started: " + startString) + Text("Duration: " + durationStringState) + .padding([.bottom], 5) + Text("Measurements:") + ForEach(Array(recording.sortedMeasurementTypes).sorted(by: { lhs, rhs in + lhs.name < rhs.name + }), id: \.self) { measurementType in + Text(measurementType.name) + .padding(.leading) + .foregroundColor(.accentColor) } } + .onAppear { + durationStringState = durationString + setupTimerIfNeeded() + } + .onChange(of: recording.state) { + setupTimerIfNeeded() + } + } + + private func setupTimerIfNeeded() { + timerCancellable?.cancel() + timerCancellable = nil + timerPublisher = nil + + guard recording.state == .inProgress else { return } + + let publisher = Timer.publish(every: 1, on: .main, in: .common) + timerPublisher = publisher + timerCancellable = publisher + .autoconnect() + .sink { _ in + durationStringState = durationString + } } } diff --git a/Accelerometer/Recordings/RecordingProcessor.swift b/Accelerometer/Recordings/RecordingProcessor.swift new file mode 100644 index 0000000..e199d24 --- /dev/null +++ b/Accelerometer/Recordings/RecordingProcessor.swift @@ -0,0 +1,55 @@ +// +// RecordingProcessor.swift +// Accelerometer +// +// Created by Andrey Pantyuhin on 19.09.2025. +// + +import Foundation + +actor RecordingProcessor { + + func generateCSV( + from recording: Recording, + for type: MeasurementType, + dateFormat: Settings.ExportDateFormat + ) async throws -> URL { + guard recording.measurementTypes.contains(type) else { + throw ProcessingError.invalidType + } + + // TODO load entries for csv + let header = RecordingUtils.stringsHeader(of: type) + let rows = (recording.entries ?? []) + .filter { $0.measurementType == type } + .map { $0.getCsvString(dateFormat: dateFormat) } + let csvText = ([header] + rows).joined(separator: RecordingUtils.rowSeparator) + + let tmpURL = FileManager.default + .temporaryDirectory + .appendingPathComponent("\(type.rawValue).csv") + + try csvText.write(to: tmpURL, atomically: true, encoding: .utf8) + return tmpURL + } + + func sampledEntries( + from recording: Recording, + for type: MeasurementType, + maxCount: Int = 50 + ) async -> [Recording.Entry] { + let filtered = (recording.entries ?? []) + .filter { $0.measurementType == type } + + guard filtered.count > maxCount else { return filtered } + + let stride = filtered.count / maxCount + return filtered.enumerated().compactMap { idx, entry in + idx % stride == 0 ? entry : nil + } + } + + enum ProcessingError: Error { + case invalidType + } +} diff --git a/Accelerometer/Recordings/RecordingSummaryView.swift b/Accelerometer/Recordings/RecordingSummaryView.swift index 1aed8ba..45807b3 100644 --- a/Accelerometer/Recordings/RecordingSummaryView.swift +++ b/Accelerometer/Recordings/RecordingSummaryView.swift @@ -6,209 +6,248 @@ // import SwiftUI -import SwiftUICharts +import UniformTypeIdentifiers struct RecordingSummaryView: View { - let recording: Recording + // MARK: - Properties - @EnvironmentObject var settings: Settings - @EnvironmentObject var recorder: Recorder + let recordingMetadata: Recording - @Environment(\.presentationMode) var presentationMode: Binding - @State private var isPresentingDeleteConfirmation = false + @EnvironmentObject private var settings: Settings + @EnvironmentObject private var recorder: Recorder + @Environment(\.presentationMode) private var presentationMode - @State private var isPresentingExporter = false - @State private var exportMeasurementType: MeasurementType? = nil + @State private var fullRecording: Recording? + @State private var isLoading = false - let deleteAlertTitleText = "Are you sure?" + @State private var exportingMeasurement: MeasurementType? = nil + @State private var exportURL: URL? + @State private var exportLoading = false - var entriesView: some View { - GeometryReader { geometry in - List { - Section(header: Text("Info")) { - RecordingPreview(recording: recording) - .padding(.vertical) - } - Section(header: Text("Measurements")) { - ForEach(Array(recording.sortedMeasurementTypes), id: \.self) { measurementType in - HStack { - RecordingMeasurementView( + private let processor = RecordingProcessor() + + // MARK: - Body + + var body: some View { + let recording = fullRecording ?? recordingMetadata + + List { + Section("Info") { + RecordingPreview(recording: recording) + } + Section("Measurements") { + if isLoading { + ProgressView() + } else { + ForEach( + recording.sortedMeasurementTypes, + id: \.self + ) { type in + VStack { + Text( + type + .name + .capitalizingFirstLetter() + ) + .font(.headline) + RecordingSmallChartView( recording: recording, - measurementType: measurementType, - screenSize: geometry.size - ) { - isPresentingExporter = true - exportMeasurementType = measurementType - } + measurementType: type + ) } } + .navigationLinkIndicatorVisibility(.hidden) } } } - } - - var body: some View { - entriesView - .navigationTitle("Recording") - - // MARK: Toolbar - .toolbar { - ToolbarItem(placement: .primaryAction) { - if #available(iOS 15.0, *) { - Button(role: .destructive, action: { - isPresentingDeleteConfirmation = true - }) { - Label("Delete", systemImage: "trash") - } - } else { - Button(action: { - isPresentingDeleteConfirmation = true - }) { - Label("Delete", systemImage: "trash") - } - } + .navigationTitle("Recording") + .toolbar { + // MARK: Toolbar Items + + ToolbarItem(placement: .secondaryAction) { + Button( + role: .destructive, + action: deleteRecording + ) { + Label("Delete", systemImage: "trash") } + .disabled(fullRecording == nil) } - - // MARK: Delete confirmation - .modify { - if #available(iOS 15.0, *) { - $0.confirmationDialog( - deleteAlertTitleText, - isPresented: $isPresentingDeleteConfirmation - ) { - Button("Delete", role: .destructive) { - deleteRecording() - } - } - } else { - $0.alert(isPresented: $isPresentingDeleteConfirmation) { - Alert( - title: Text(deleteAlertTitleText), - primaryButton: .destructive( - Text("Delete"), - action: { - deleteRecording() - } - ), - secondaryButton: .cancel( - Text("Cancel"), - action: { } + + ToolbarItem(placement: .primaryAction) { + Menu { + Text("Export") + .font(.headline) + .padding(.vertical, 4) + Divider() + ForEach( + fullRecording?.sortedMeasurementTypes ?? [], + id: \.self + ) { type in + Button { + export(type: type) + } label: { + Label( + type.name, + systemImage: type.iconName ) - ) + } } + } label: { + Label( + "Export", + systemImage: "square.and.arrow.up" + ) } + .disabled(fullRecording == nil) } - - // MARK: Exporter - .fileExporter( - isPresented: $isPresentingExporter, - document: document, - contentType: TextFile.readableContentTypes.first ?? .plainText, - defaultFilename: (exportMeasurementType?.name ?? "recording") + ".csv" - ) { result in - switch result { - case .success(let url): - print("Saved to \(url)") - case .failure(let error): - print(error.localizedDescription) + } + .exportable( + isPresented: .init( + get: { exportingMeasurement != nil }, + set: { presented in + if !presented { exportingMeasurement = nil } } - } + ), + url: $exportURL, + measurementType: exportingMeasurement ?? recording.measurementTypes.first! + ) + .task { + await loadFullRecordingIfNeeded() + } } - private var document: TextFile? { - guard let type = exportMeasurementType else { - return nil + // MARK: - Private Methods + + private func loadFullRecordingIfNeeded() async { + if recordingMetadata.entries == nil { + isLoading = true + fullRecording = await recorder.loadFullRecording( + id: recordingMetadata.id + ) + isLoading = false } - return recording.csv( - of: type, - dateFormat: settings.exportDateFormat - ) + } + + private func export(type: MeasurementType) { + guard !exportLoading, let recording = fullRecording else { return } + exportLoading = true + Task { + defer { exportLoading = false } + do { + let url = try await processor.generateCSV( + from: recording, + for: type, + dateFormat: settings.exportDateFormat + ) + exportURL = url + exportingMeasurement = type + } catch { + print("Export failed:", error) + } + } + } + + private func defaultFilename() -> String { + (exportURL?.lastPathComponent ?? recordingMetadata.id) + ".csv" } private func deleteRecording() { - recorder.delete(recordingID: recording.id) + recorder.delete(recordingID: recordingMetadata.id) presentationMode.wrappedValue.dismiss() } } -private extension ChartStyle { - - static var recordingEntry: ChartStyle { - ChartStyle( - backgroundColor: Color.secondaryBackground, - accentColor: Color.accentColor, - gradientColor: .init( - start: Color.accentColor, - end: Color.accentColor - ), - textColor: Color.primary, - legendTextColor: Color.primary, - dropShadowColor: Color.clear +// MARK: - Debug Preview + +#if DEBUG +extension RecordingSummaryView { + + init(previewRecording: Recording) { + self.recordingMetadata = Recording( + id: previewRecording.id, + start: previewRecording.start, + end: previewRecording.end, + entries: nil, + state: previewRecording.state, + measurementTypes: previewRecording.measurementTypes ) + + _fullRecording = State(initialValue: previewRecording) + _isLoading = State(initialValue: false) } +} +#endif + +// MARK: - FileDocument Wrapper + +struct FileDocumentWrapper: FileDocument { - static var recordingEntryDarkMode: ChartStyle { - ChartStyle( - backgroundColor: Color.accentColor, - accentColor: Color.accentColor, - gradientColor: .init( - start: Color.accentColor, - end: Color.accentColor - ), - textColor: Color.primary, - legendTextColor: Color.primary, - dropShadowColor: Color.clear - ) + let url: URL + + static var readableContentTypes: [UTType] { [.plainText] } + + init(url: URL) { self.url = url } + + init(configuration: ReadConfiguration) throws { + throw CocoaError(.coderReadCorrupt) + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + try FileWrapper(url: url, options: .immediate) } } -struct RecordingView_Previews: PreviewProvider { +// MARK: - Previews + +struct RecordingSummaryView_Previews: PreviewProvider { static let settings = Settings() - static let measurer = Measurer(settings: settings) - static let recorder = Recorder(measurer: measurer) + static let recorder = Recorder( + measurer: Measurer(settings: settings), + settings: settings + ) static let axes: TriangleAxes = { - var axes = TriangleAxes.zero - axes.displayableAbsMax = 1.0 - return axes + var a = TriangleAxes.zero + a.displayableAbsMax = 1.0 + return a }() - static var previews: some View { - RecordingSummaryView( - recording: Recording( - entries: [ - .init( - measurementType: .acceleration, - date: .init(), - axes: axes - ) - ], - state: .completed, - measurementTypes: [.acceleration] + static let sampleRecording = Recording( + id: UUID().uuidString, + start: Date().addingTimeInterval(-60), + end: Date(), + entries: [ + .init( + measurementType: .acceleration, + date: Date().addingTimeInterval(-60), + axes: axes + ), + .init( + measurementType: .acceleration, + date: Date(), + axes: axes ) - ) + ], + state: .completed, + measurementTypes: [.acceleration] + ) + + static var previews: some View { + NavigationView { + RecordingSummaryView(previewRecording: sampleRecording) + .environmentObject(settings) + .environmentObject(recorder) + } .preferredColorScheme(.light) - .environmentObject(recorder) - .environmentObject(settings) - RecordingSummaryView( - recording: Recording( - entries: [ - .init( - measurementType: .acceleration, - date: .init(), - axes: axes - ) - ], - state: .completed, - measurementTypes: [.acceleration] - ) - ) + NavigationView { + RecordingSummaryView(previewRecording: sampleRecording) + .environmentObject(settings) + .environmentObject(recorder) + } .preferredColorScheme(.dark) - .environmentObject(recorder) - .environmentObject(settings) } } diff --git a/Accelerometer/Recordings/RecordingsView.swift b/Accelerometer/Recordings/RecordingsView.swift index 9394270..2fe3fe4 100644 --- a/Accelerometer/Recordings/RecordingsView.swift +++ b/Accelerometer/Recordings/RecordingsView.swift @@ -11,7 +11,11 @@ struct RecordingsView: View { @EnvironmentObject var recorder: Recorder - @State var presentingNewRecordingSheet = false + @State private var presentingNewRecordingSheet = false + @State private var isEditMode = false + @State private var selectedRecordings = Set() + + // MARK: Recording info var recordingTitle: String { if recorder.recordingInProgress { @@ -24,21 +28,52 @@ struct RecordingsView: View { var secondaryRecordingTitle: String? { if recorder.recordingInProgress { return "Do not close the app" + } else if !recorder.hasEnoughMemory { + return "Not enough memory on device" } else { return nil } } - var startStopButtonImage: some View { - if recorder.recordingInProgress { - return Image(systemName: "stop.fill") + var lastRecordings: [Recording] { + var recordings = recorder.recordingsMetadata + if let activeRecording = recorder.activeRecording { + recordings.removeAll { $0.id == activeRecording.id } + } + return recordings + } + + // MARK: Selection + + var selectableRecordings: [Recording] { + lastRecordings.filter { $0.state == .completed } + } + + var allSelected: Bool { + let ids = Set(selectableRecordings.map(\.id)) + return !ids.isEmpty && selectedRecordings == ids + } + + func toggleSelectAll() { + if (allSelected) { + selectedRecordings.removeAll() } else { - return Image(systemName: "play.fill") + selectedRecordings = Set(selectableRecordings.map(\.id)) } } + func deleteSelected() { + recorder.delete(recordingIDs: Array(selectedRecordings)) + selectedRecordings.removeAll() + isEditMode = false + } + + // MARK: Views + var startStopButton: some View { - Button( + let enabled = !isEditMode && recorder.hasEnoughMemory + + return Button( action: { if recorder.recordingInProgress { recorder.stopRecording() @@ -47,94 +82,130 @@ struct RecordingsView: View { } }, label: { - startStopButtonImage - .padding(13) - .foregroundColor(Color.background) - .background(Color.accentColor) - .clipShape(Circle()) - .font(.title2) + Image( + systemName: recorder.recordingInProgress ? "stop.fill" : "play.fill" + ) + .padding(13) + .foregroundColor(Color.background) + .background(enabled ? Color.accentColor : Color.gray) + .clipShape(Circle()) + .font(.title2) } ) + .disabled(!enabled) } - var activeRecording: Recording? { - guard let firstRecording = recorder.recordings.first else { - return nil - } - if firstRecording.state == .inProgress { - return firstRecording + @ViewBuilder + func recordingPreview(recording: Recording) -> some View { + if recording.state == .inProgress || isEditMode { + RecordingPreview(recording: recording) } else { - return nil - } - } - - var lastRecordings: [Recording] { - var recordings = recorder.recordings - if activeRecording != nil { - recordings.removeFirst() - } - return recordings - } - - func recordingView(recording: Recording) -> some View { - switch recording.state { - case .completed: - return AnyView(completedRecordingView(recording: recording)) - case .inProgress: - return AnyView(activeRecordingView(recording: recording)) + NavigationLink { + RecordingSummaryView(recordingMetadata: recording) + } label: { + RecordingPreview(recording: recording) + } } } - func activeRecordingView(recording: Recording) -> some View { - RecordingPreview(recording: recording) - .padding(.vertical) - } - - func completedRecordingView(recording: Recording) -> some View { - NavigationLink { - RecordingSummaryView(recording: recording) - } label: { - RecordingPreview(recording: recording) + @ViewBuilder + func recordingRow(recording: Recording) -> some View { + HStack { + if isEditMode { + Button { + if selectedRecordings.contains(recording.id) { + selectedRecordings.remove(recording.id) + } else { + selectedRecordings.insert(recording.id) + } + } label: { + Image( + systemName: selectedRecordings.contains(recording.id) ? "checkmark.circle.fill" : "circle" + ) + .foregroundColor( + selectedRecordings.contains(recording.id) ? .accentColor : .gray + ) + .font(.title2) + } + .buttonStyle(PlainButtonStyle()) + } + + recordingPreview(recording: recording) .padding(.vertical) } } + // MARK: - Body + var body: some View { - List { - Section(header: Spacer()) { - HStack { - ZStack(alignment: .topLeading) { + VStack { + List { + Section(header: Spacer()) { + HStack { VStack(alignment: .leading, spacing: 10) { Text(recordingTitle) .font(.title2) - if let secondaryRecordingTitle = secondaryRecordingTitle { - Text(secondaryRecordingTitle) + if let subtitle = secondaryRecordingTitle { + Text(subtitle) .font(.footnote) - .padding([.leading]) - .foregroundColor(Color.secondary) + .foregroundColor(.secondary) + .padding(.leading) } } - Color.clear + Spacer() + startStopButton + } + } + + if let activeRecording = recorder.activeRecording { + Section(header: Text("Recording in progress")) { + recordingRow(recording: activeRecording) + } + } + + if !lastRecordings.isEmpty { + Section(header: Text("Last recordings")) { + ForEach(lastRecordings) { recording in + recordingRow(recording: recording) + } } - .padding(.vertical) - Spacer() - startStopButton - .padding(.vertical) } } - if let activeRecording = activeRecording { - Section(header: Text("Recording in progress")) { - recordingView(recording: activeRecording) + .environment(\.editMode, .constant(isEditMode ? .active : .inactive)) + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + if isEditMode { + Button(allSelected ? "Deselect All" : "Select All") { + toggleSelectAll() + } + .disabled(selectableRecordings.isEmpty) + } + } + ToolbarItem(placement: .navigationBarTrailing) { + if isEditMode { + Button("Done") { + isEditMode = false + selectedRecordings.removeAll() + } + } else if recorder.activeRecording == nil { + Button("Edit") { + isEditMode = true + } + .disabled(selectableRecordings.isEmpty) } } - if !lastRecordings.isEmpty { - Section(header: Text("Last recordings")) { - ForEach(lastRecordings) { recording in - recordingView(recording: recording) + ToolbarItem(placement: .bottomBar) { + if isEditMode && !selectedRecordings.isEmpty { + Button(role: .destructive, action: deleteSelected) { + Label("Delete (\(selectedRecordings.count))", systemImage: "trash") } + .frame(maxWidth: .infinity) } } } + .toolbar(isEditMode ? .hidden : .visible, for: .tabBar) + .environment(\.editMode, .constant(isEditMode ? .active : .inactive)) .sheet(isPresented: $presentingNewRecordingSheet) { NewRecordingView() } @@ -147,7 +218,7 @@ struct RecordingsView_Previews: PreviewProvider { static let measurer = Measurer(settings: settings) static let recorder: Recorder = { - let recorder = Recorder(measurer: measurer) + let recorder = Recorder(measurer: measurer, settings: settings) recorder.record(measurements: [.acceleration, .userAcceleration]) recorder.stopRecording() recorder.record(measurements: [.magneticField]) diff --git a/Accelerometer/Settings/AlwaysNotEnoughMemoryView.swift b/Accelerometer/Settings/AlwaysNotEnoughMemoryView.swift new file mode 100644 index 0000000..2978f03 --- /dev/null +++ b/Accelerometer/Settings/AlwaysNotEnoughMemoryView.swift @@ -0,0 +1,36 @@ +// +// AlwaysNotEnoughMemoryView.swift +// Accelerometer +// +// Created by Andrey Pantyuhin on 22.09.2025. +// + +import SwiftUI + +struct AlwaysNotEnoughMemoryView: View { + + @EnvironmentObject var settings: Settings + + var body: some View { + Toggle( + "Not enough memory on device", + isOn: Binding( + get: { + settings.alwaysNotEnoughMemory + }, + set: { + settings.alwaysNotEnoughMemory = $0 + } + ) + ) + .tint(.accentColor) + } +} + +struct AlwaysNotEnoughMemoryViewPreviews: PreviewProvider { + static var previews: some View { + AlwaysNotEnoughMemoryView() + .previewLayout(.sizeThatFits) + .environmentObject(Settings()) + } +} diff --git a/Accelerometer/Settings/AnimationsView.swift b/Accelerometer/Settings/AnimationsView.swift index a4e0b33..07cebbb 100644 --- a/Accelerometer/Settings/AnimationsView.swift +++ b/Accelerometer/Settings/AnimationsView.swift @@ -27,6 +27,7 @@ struct AnimationsView: View { set: { setEnableAnimations($0) } ) ) + .tint(.accentColor) } } diff --git a/Accelerometer/Settings/DebugSamplesView.swift b/Accelerometer/Settings/DebugSamplesView.swift new file mode 100644 index 0000000..f4e38a8 --- /dev/null +++ b/Accelerometer/Settings/DebugSamplesView.swift @@ -0,0 +1,36 @@ +// +// DebugSamplesView.swift +// Accelerometer +// +// Created by Andrey Pantyuhin on 03.10.2025. +// + +import SwiftUI + +struct DebugSamplesView: View { + + @EnvironmentObject var settings: Settings + @EnvironmentObject var recorder: Recorder + + var body: some View { + Button( + role: .confirm, + action: { + recorder.createDebugSamples() + }, + label: { + Text("Create recordings samples") + } + ) + .buttonStyle(.glassProminent) + } +} + +struct DebugSamplesViewPreviews: PreviewProvider { + static var previews: some View { + DebugSamplesView() + .previewLayout(.sizeThatFits) + .environmentObject(Settings()) + } +} + diff --git a/Accelerometer/Settings/RefreshRateView.swift b/Accelerometer/Settings/RefreshRateView.swift index 1f54253..b70a6c6 100644 --- a/Accelerometer/Settings/RefreshRateView.swift +++ b/Accelerometer/Settings/RefreshRateView.swift @@ -54,12 +54,12 @@ struct RefreshRateView_Previews: PreviewProvider { static let measurer = Measurer(settings: settings) static let recorder1: Recorder = { - let recorder = Recorder(measurer: measurer) + let recorder = Recorder(measurer: measurer, settings: settings) return recorder }() static let recorder2: Recorder = { - let recorder = Recorder(measurer: measurer) + let recorder = Recorder(measurer: measurer, settings: settings) recorder.record(measurements: [.acceleration]) return recorder }() diff --git a/Accelerometer/Settings/Settings.swift b/Accelerometer/Settings/Settings.swift index c1f57f1..9ca5d19 100644 --- a/Accelerometer/Settings/Settings.swift +++ b/Accelerometer/Settings/Settings.swift @@ -17,8 +17,12 @@ class Settings: ObservableObject { static let measurementsDisplayRoundPlaces = 3 + static let minFreeSpaceMB = 500 + private static let initialUpdateInterval = 0.5 + // MARK: - User options + var exportDateFormat: ExportDateFormat { get { let stringValue = UserDefaults.standard.string(forKey: Key.exportDateFormat.rawValue) @@ -34,16 +38,6 @@ class Settings: ObservableObject { } } - var enableAnimations: Bool { - get { - return UserDefaults.standard.bool(forKey: Key.enableAnimations.rawValue) - } - set { - objectWillChange.send() - UserDefaults.standard.set(newValue, forKey: Key.enableAnimations.rawValue) - } - } - private let updateIntervalSubject = PassthroughSubject() var updateIntervalPublisher: AnyPublisher { updateIntervalSubject.eraseToAnyPublisher() @@ -65,8 +59,33 @@ class Settings: ObservableObject { updateIntervalSubject.send(roundedValue) } } + + // MARK: - Debug options + #if DEBUG + var enableAnimations: Bool { + get { + return UserDefaults.standard.bool(forKey: Key.enableAnimations.rawValue) + } + set { + objectWillChange.send() + UserDefaults.standard.set(newValue, forKey: Key.enableAnimations.rawValue) + } + } + + var alwaysNotEnoughMemory: Bool { + get { + return UserDefaults.standard.bool(forKey: Key.alwaysNotEnoughMemory.rawValue) + } + set { + objectWillChange.send() + UserDefaults.standard.set(newValue, forKey: Key.alwaysNotEnoughMemory.rawValue) + } + } + #endif } +// MARK: - ExportDateFormat + extension Settings { enum ExportDateFormat: String, CaseIterable { @@ -91,10 +110,15 @@ extension Settings { } } +// MARK: - Keys + extension Settings { enum Key: String { case exportDateFormat = "ExportDateFormat" + #if DEBUG case enableAnimations = "EnableAnimations" + case alwaysNotEnoughMemory = "AlwaysNotEnoughMemory" + #endif case measurementsUpdateInterval = "MeasurementsUpdateInterval" } } diff --git a/Accelerometer/Settings/SettingsView.swift b/Accelerometer/Settings/SettingsView.swift index 0fa8e5b..00bd3b7 100644 --- a/Accelerometer/Settings/SettingsView.swift +++ b/Accelerometer/Settings/SettingsView.swift @@ -25,12 +25,18 @@ struct SettingsView: View { ) { ExportDateFormatView() } + + #if (DEBUG) Section( header: Spacer(), - footer: Text("Enable animations") + footer: Text("Debug options") ) { AnimationsView() + AlwaysNotEnoughMemoryView() + DebugSamplesView() } + #endif + // Section(header: Text("Accelerometer"), footer: Text("")) { // Toggle("Remove gravity", isOn: .init(get: { measurer.removeGravity }, set: { measurer.removeGravity = $0 })) // } @@ -42,7 +48,7 @@ struct SettingsView_Previews: PreviewProvider { static let settings = Settings() static let measurer = Measurer(settings: settings) - static let recorder = Recorder(measurer: measurer) + static let recorder = Recorder(measurer: measurer, settings: settings) static var previews: some View { SettingsView() diff --git a/README.md b/README.md index b816593..a4e75e3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Acc.elerometer -This iOS application allows you to view the readings of the sensors of your device and write them to a csv file. +An iOS app that lets you visualize real-time data from your device’s motion and environmental sensors, inspect detailed measurement charts, and export any sensor log to a CSV file for further analysis. ### System requirements - iOS 14.0