From 2ef903210efe7a32023638fd02bb450e60dd7481 Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Sat, 22 Mar 2025 00:40:49 +0300 Subject: [PATCH 01/40] Update project.pbxproj --- Accelerometer.xcodeproj/project.pbxproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Accelerometer.xcodeproj/project.pbxproj b/Accelerometer.xcodeproj/project.pbxproj index 178d3d4..9ca0bc8 100644 --- a/Accelerometer.xcodeproj/project.pbxproj +++ b/Accelerometer.xcodeproj/project.pbxproj @@ -893,6 +893,8 @@ 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; @@ -924,6 +926,8 @@ 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; From 0454f9a7b35e617429a44a345bfa3f8cc8218deb Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Sun, 31 Aug 2025 19:49:32 +0300 Subject: [PATCH 02/40] New charts RecordingMeasurementChartView created --- Accelerometer.xcodeproj/project.pbxproj | 14 ++ Accelerometer/Extensions/Bool.swift | 22 ++- Accelerometer/Measurements/Values/Axes.swift | 3 +- Accelerometer/Recordings/Recording.swift | 7 + .../RecordingMeasurementChartView.swift | 136 ++++++++++++++++++ .../Recordings/RecordingSummaryView.swift | 25 +++- 6 files changed, 198 insertions(+), 9 deletions(-) create mode 100644 Accelerometer/Recordings/RecordingMeasurementChartView.swift diff --git a/Accelerometer.xcodeproj/project.pbxproj b/Accelerometer.xcodeproj/project.pbxproj index 9ca0bc8..9477174 100644 --- a/Accelerometer.xcodeproj/project.pbxproj +++ b/Accelerometer.xcodeproj/project.pbxproj @@ -33,6 +33,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 /* RecordingMeasurementChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B45FF952D8EC357009F5CDF /* RecordingMeasurementChartView.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 */; }; @@ -123,6 +124,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 /* RecordingMeasurementChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingMeasurementChartView.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 = ""; }; @@ -397,6 +399,7 @@ 0BA63B712897FBCE0040B63D /* RecordingPreview.swift */, 0BBAD4AC2895564C00251262 /* RecordingSummaryView.swift */, 0BADCA5E289A720600238A25 /* RecordingMeasurementView.swift */, + 0B45FF952D8EC357009F5CDF /* RecordingMeasurementChartView.swift */, 0BA63B6B289598B40040B63D /* NewRecordingView.swift */, 0BE138F1289400E70026D0DC /* Recording.swift */, 0BE138F5289409820026D0DC /* Recorder.swift */, @@ -639,10 +642,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 +663,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"; @@ -680,6 +691,7 @@ 0B36DFCA289A9E85007130D9 /* Settings.swift in Sources */, 0BA5B7BC2AD319AC004F1808 /* AttitudeAxesSummaryView.swift in Sources */, 0B24EC3428621E1C00AD3EF1 /* AppDelegate.swift in Sources */, + 0B45FF962D8EC361009F5CDF /* RecordingMeasurementChartView.swift in Sources */, 0B548E1A2876ED5000EDCDA8 /* MeasurementPreview.swift in Sources */, 0BA63B6C289598B40040B63D /* NewRecordingView.swift in Sources */, 0BCE8EC62864896300A0E3E0 /* TriangleDiagramShape.swift in Sources */, @@ -901,6 +913,7 @@ 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 = 16.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -934,6 +947,7 @@ 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 = 16.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", 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/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/Recordings/Recording.swift b/Accelerometer/Recordings/Recording.swift index a5a796a..8724b4e 100644 --- a/Accelerometer/Recordings/Recording.swift +++ b/Accelerometer/Recordings/Recording.swift @@ -61,6 +61,7 @@ struct Recording: Identifiable { return TextFile(initialText: csvStrings.joined(separator: RecordingUtils.rowSeparator)) } + // TODO: remove func chartValues(of measurementType: MeasurementType) -> [[Double]] { var chartEntries: [any ChartEntry] = [] @@ -74,6 +75,12 @@ struct Recording: Identifiable { .map({ $0.chartValues }) .transposed() } + + func chartValues2(of measurementType: MeasurementType) -> [Entry] { + let filtered: [Entry] = entries.filter { $0.measurementType == measurementType } + + return filtered + } } extension Recording { diff --git a/Accelerometer/Recordings/RecordingMeasurementChartView.swift b/Accelerometer/Recordings/RecordingMeasurementChartView.swift new file mode 100644 index 0000000..cbc71f4 --- /dev/null +++ b/Accelerometer/Recordings/RecordingMeasurementChartView.swift @@ -0,0 +1,136 @@ +// +// RecordingMeasurementChartView.swift +// Accelerometer +// +// Created by Andrey Pantyuhin on 22.03.2025. +// + +import Charts +import SwiftUI + +struct RecordingMeasurementChartView: View { + + let recording: Recording + let measurementType: MeasurementType + + let presentExporter: () -> Void + + var title: String { measurementType.name.capitalizingFirstLetter() } + + var chartEntries: [Recording.Entry] { + recording.chartValues2(of: measurementType) + } + + var body: some View { + VStack { + Text(title) + .padding(.top, 2) + .padding(.bottom, 2) + + Chart(chartEntries) { entry in + CustomChartContent(entry: entry) + } + .padding(.bottom, 2) + } + } +} + +private struct CustomChartContent: ChartContent { + let entry: Recording.Entry + + 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 xName = "date" + let yName = yAxis.type_.name + + LineMark( + x: .value(xName, entry.date), + y: .value(yName, yAxis.value) + ) + .foregroundStyle(by: .value("name", yName)) + } + } + } +} + +struct RecordingMeasurementChartView_Previews: PreviewProvider { + + static let axes1: TriangleAxes = { + var axes = TriangleAxes.zero + axes.displayableAbsMax = 1.0 + axes.values = [ + .x: Axis(type_: .x, value: 0.0), + .y: Axis(type_: .y, value: 0.0), + .z: Axis(type_: .z, value: 0.0) + ] + return axes + }() + + static let axes2: TriangleAxes = { + var axes = TriangleAxes.zero + axes.displayableAbsMax = 1.0 + axes.values = [ + .x: Axis(type_: .x, value: 1.0), + .y: Axis(type_: .y, value: 0.1), + .z: Axis(type_: .z, value: 0.1) + ] + return axes + }() + + static let axes3: TriangleAxes = { + var axes = TriangleAxes.zero + axes.displayableAbsMax = 1.0 + axes.values = [ + .x: Axis(type_: .x, value: 0.2), + .y: Axis(type_: .y, value: 0.2), + .z: Axis(type_: .z, value: 0.2) + ] + return axes + }() + + static let baseAxes: [TriangleAxes] = [axes1, axes2, axes3] + + static var allEntries: [Recording.Entry] { + let repeats = 5 + let interval: TimeInterval = 60 + return (0.. Date: Mon, 1 Sep 2025 23:43:40 +0300 Subject: [PATCH 03/40] Edit mode in RecordingsView --- .../Database/RecordingsRepository.swift | 4 + Accelerometer/Recordings/Recorder.swift | 12 +- Accelerometer/Recordings/RecordingsView.swift | 205 ++++++++++++------ 3 files changed, 157 insertions(+), 64 deletions(-) diff --git a/Accelerometer/Recordings/Database/RecordingsRepository.swift b/Accelerometer/Recordings/Database/RecordingsRepository.swift index 2c6ad02..3d64332 100644 --- a/Accelerometer/Recordings/Database/RecordingsRepository.swift +++ b/Accelerometer/Recordings/Database/RecordingsRepository.swift @@ -23,6 +23,10 @@ class RecordingsRepository: ObservableObject { databaseManager.delete(recordings.map({ RecordingRealm(recording: $0) })) } + func delete(recordingIDs: [String]) { + recordingIDs.forEach { delete(recordingID: $0) } + } + func delete(recordingID: String) { let recordings: [RecordingRealm] = Array(databaseManager.read() as Results) guard let recording = recordings.first(where: { $0.id == recordingID }) else { diff --git a/Accelerometer/Recordings/Recorder.swift b/Accelerometer/Recordings/Recorder.swift index 9d739da..32dbc59 100644 --- a/Accelerometer/Recordings/Recorder.swift +++ b/Accelerometer/Recordings/Recorder.swift @@ -72,15 +72,23 @@ class Recorder: ObservableObject { repository.update() } - print(activeRecording?.entries.count) - activeRecording = nil cancelSubscriptions() + objectWillChange.send() } func delete(recordingID: String) { repository.delete(recordingID: recordingID) + repository.update() + + objectWillChange.send() + } + + func delete(recordingIDs: [String]) { + repository.delete(recordingIDs: recordingIDs) + repository.update() + objectWillChange.send() } diff --git a/Accelerometer/Recordings/RecordingsView.swift b/Accelerometer/Recordings/RecordingsView.swift index 9394270..423fa61 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 { @@ -29,34 +33,6 @@ struct RecordingsView: View { } } - var startStopButtonImage: some View { - if recorder.recordingInProgress { - return Image(systemName: "stop.fill") - } else { - return Image(systemName: "play.fill") - } - } - - var startStopButton: some View { - Button( - action: { - if recorder.recordingInProgress { - recorder.stopRecording() - } else { - presentingNewRecordingSheet = true - } - }, - label: { - startStopButtonImage - .padding(13) - .foregroundColor(Color.background) - .background(Color.accentColor) - .clipShape(Circle()) - .font(.title2) - } - ) - } - var activeRecording: Recording? { guard let firstRecording = recorder.recordings.first else { return nil @@ -76,65 +52,170 @@ struct RecordingsView: View { 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)) + // 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 { + selectedRecordings = Set(selectableRecordings.map(\.id)) } } - func activeRecordingView(recording: Recording) -> some View { - RecordingPreview(recording: recording) - .padding(.vertical) + func deleteSelected() { + // TODO: implement actual deletion in Recorder + recorder.delete(recordingIDs: Array(selectedRecordings)) + selectedRecordings.removeAll() + isEditMode = false } - func completedRecordingView(recording: Recording) -> some View { - NavigationLink { - RecordingSummaryView(recording: recording) - } label: { + // MARK: Views + + var startStopButton: some View { + Button( + action: { + if recorder.recordingInProgress { + recorder.stopRecording() + } else { + presentingNewRecordingSheet = true + } + }, + label: { + Image( + systemName: recorder.recordingInProgress ? "stop.fill" : "play.fill" + ) + .padding(13) + .foregroundColor(Color.background) + .background(Color.accentColor) + .clipShape(Circle()) + .font(.title2) + } + ) + .disabled(isEditMode) + } + + @ViewBuilder + func recordingPreview(recording: Recording) -> some View { + if recording.state == .inProgress || isEditMode { RecordingPreview(recording: recording) + } else { + 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 } - .padding(.vertical) - Spacer() - startStopButton - .padding(.vertical) } + + if let activeRecording = 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) + } + } + } + } + .environment(\.editMode, .constant(isEditMode ? .active : .inactive)) + + if isEditMode && !selectedRecordings.isEmpty { + Button( + action: deleteSelected, + label: { + Label("Delete (\(selectedRecordings.count))", systemImage: "trash") + } + ) + .padding(.vertical) + .transition(.move(edge: .bottom)) } - if let activeRecording = activeRecording { - Section(header: Text("Recording in progress")) { - recordingView(recording: activeRecording) + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + if isEditMode { + Button(allSelected ? "Deselect All" : "Select All") { + toggleSelectAll() + } + .disabled(selectableRecordings.isEmpty) } } - if !lastRecordings.isEmpty { - Section(header: Text("Last recordings")) { - ForEach(lastRecordings) { recording in - recordingView(recording: recording) + ToolbarItem(placement: .navigationBarTrailing) { + if isEditMode { + Button("Done") { + isEditMode = false + selectedRecordings.removeAll() + } + } else if activeRecording == nil { + Button("Edit") { + isEditMode = true } + .disabled(selectableRecordings.isEmpty) } } } + .environment(\.editMode, .constant(isEditMode ? .active : .inactive)) .sheet(isPresented: $presentingNewRecordingSheet) { NewRecordingView() } From 730015225a2aa037b3c299ec754e37594848b43f Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Mon, 1 Sep 2025 23:43:58 +0300 Subject: [PATCH 04/40] Chart paddings update --- Accelerometer/Recordings/RecordingMeasurementChartView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Accelerometer/Recordings/RecordingMeasurementChartView.swift b/Accelerometer/Recordings/RecordingMeasurementChartView.swift index cbc71f4..8cc487d 100644 --- a/Accelerometer/Recordings/RecordingMeasurementChartView.swift +++ b/Accelerometer/Recordings/RecordingMeasurementChartView.swift @@ -24,13 +24,13 @@ struct RecordingMeasurementChartView: View { var body: some View { VStack { Text(title) - .padding(.top, 2) + .padding(.top, 4) .padding(.bottom, 2) Chart(chartEntries) { entry in CustomChartContent(entry: entry) } - .padding(.bottom, 2) + .padding(.bottom, 4) } } } From a938235b834b8c8129fcd6bc9afcc8911543ef4d Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Tue, 16 Sep 2025 23:55:27 +0300 Subject: [PATCH 05/40] Less chart values --- Accelerometer/Recordings/Recording.swift | 9 +++++++-- .../Recordings/RecordingMeasurementChartView.swift | 8 +++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Accelerometer/Recordings/Recording.swift b/Accelerometer/Recordings/Recording.swift index 8724b4e..2be0dd0 100644 --- a/Accelerometer/Recordings/Recording.swift +++ b/Accelerometer/Recordings/Recording.swift @@ -77,9 +77,14 @@ struct Recording: Identifiable { } func chartValues2(of measurementType: MeasurementType) -> [Entry] { - let filtered: [Entry] = entries.filter { $0.measurementType == measurementType } + let filtered = entries.filter { $0.measurementType == measurementType } - return filtered + guard filtered.count > 1000 else { return filtered } + + let stride = filtered.count / 1000 + return filtered.enumerated().compactMap { index, entry in + return index % stride == 0 ? entry : nil + } } } diff --git a/Accelerometer/Recordings/RecordingMeasurementChartView.swift b/Accelerometer/Recordings/RecordingMeasurementChartView.swift index 8cc487d..18a1426 100644 --- a/Accelerometer/Recordings/RecordingMeasurementChartView.swift +++ b/Accelerometer/Recordings/RecordingMeasurementChartView.swift @@ -22,15 +22,17 @@ struct RecordingMeasurementChartView: View { } var body: some View { - VStack { + VStack(spacing: 8) { Text(title) + .font(.headline) .padding(.top, 4) - .padding(.bottom, 2) Chart(chartEntries) { entry in CustomChartContent(entry: entry) } - .padding(.bottom, 4) + .frame(height: 200) + .padding(.horizontal, 16) + .padding(.bottom, 20) } } } From b5f109a6de58891f826ef32ca3dde20b7b557aa3 Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Wed, 17 Sep 2025 00:14:43 +0300 Subject: [PATCH 06/40] Delete button with safe area insets --- Accelerometer/Recordings/RecordingsView.swift | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/Accelerometer/Recordings/RecordingsView.swift b/Accelerometer/Recordings/RecordingsView.swift index 423fa61..706c7b3 100644 --- a/Accelerometer/Recordings/RecordingsView.swift +++ b/Accelerometer/Recordings/RecordingsView.swift @@ -143,6 +143,24 @@ struct RecordingsView: View { } } + private var deleteButtonBar: some View { + HStack { + Button(action: deleteSelected) { + Label("Delete (\(selectedRecordings.count))", systemImage: "trash") + .foregroundColor(.white) + .font(.headline) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color.red) + .cornerRadius(12) + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + .background(Color(.systemBackground)) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + // MARK: - Body var body: some View { @@ -180,16 +198,10 @@ struct RecordingsView: View { } } .environment(\.editMode, .constant(isEditMode ? .active : .inactive)) - - if isEditMode && !selectedRecordings.isEmpty { - Button( - action: deleteSelected, - label: { - Label("Delete (\(selectedRecordings.count))", systemImage: "trash") - } - ) - .padding(.vertical) - .transition(.move(edge: .bottom)) + .safeAreaInset(edge: .bottom, spacing: 0) { + if isEditMode && !selectedRecordings.isEmpty { + deleteButtonBar + } } } .toolbar { From 051626a4e90d6cd056a7f6b02371d4cc0929b849 Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Wed, 17 Sep 2025 02:18:46 +0300 Subject: [PATCH 07/40] Tab bar delete button --- Accelerometer/ContentView.swift | 20 ++++++++---- Accelerometer/Recordings/Recorder.swift | 1 + Accelerometer/Recordings/RecordingsView.swift | 32 ++++++------------- 3 files changed, 23 insertions(+), 30 deletions(-) diff --git a/Accelerometer/ContentView.swift b/Accelerometer/ContentView.swift index 0b2581a..f00a391 100644 --- a/Accelerometer/ContentView.swift +++ b/Accelerometer/ContentView.swift @@ -8,39 +8,45 @@ 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() diff --git a/Accelerometer/Recordings/Recorder.swift b/Accelerometer/Recordings/Recorder.swift index 32dbc59..35db693 100644 --- a/Accelerometer/Recordings/Recorder.swift +++ b/Accelerometer/Recordings/Recorder.swift @@ -10,6 +10,7 @@ import SwiftUI @MainActor class Recorder: ObservableObject { + @Published var isInEditMode = false @ObservedObject private var measurer: Measurer private let repository: RecordingsRepository = { diff --git a/Accelerometer/Recordings/RecordingsView.swift b/Accelerometer/Recordings/RecordingsView.swift index 706c7b3..64fe37f 100644 --- a/Accelerometer/Recordings/RecordingsView.swift +++ b/Accelerometer/Recordings/RecordingsView.swift @@ -143,24 +143,6 @@ struct RecordingsView: View { } } - private var deleteButtonBar: some View { - HStack { - Button(action: deleteSelected) { - Label("Delete (\(selectedRecordings.count))", systemImage: "trash") - .foregroundColor(.white) - .font(.headline) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(Color.red) - .cornerRadius(12) - .padding(.horizontal, 16) - .padding(.bottom, 8) - } - .background(Color(.systemBackground)) - .transition(.move(edge: .bottom).combined(with: .opacity)) - } - // MARK: - Body var body: some View { @@ -198,11 +180,6 @@ struct RecordingsView: View { } } .environment(\.editMode, .constant(isEditMode ? .active : .inactive)) - .safeAreaInset(edge: .bottom, spacing: 0) { - if isEditMode && !selectedRecordings.isEmpty { - deleteButtonBar - } - } } .toolbar { ToolbarItem(placement: .navigationBarLeading) { @@ -226,7 +203,16 @@ struct RecordingsView: View { .disabled(selectableRecordings.isEmpty) } } + 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() From 62c09062656e90a30987ce6223ab73f35541d7d9 Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Wed, 17 Sep 2025 18:28:35 +0300 Subject: [PATCH 08/40] Remove old charts --- Accelerometer.xcodeproj/project.pbxproj | 35 +--- .../xcshareddata/swiftpm/Package.resolved | 15 -- Accelerometer/Recordings/Recording.swift | 15 -- .../Recordings/RecordingMeasurementView.swift | 170 ------------------ .../Recordings/RecordingSummaryView.swift | 56 +----- Accelerometer/Recordings/RecordingsView.swift | 1 - 6 files changed, 10 insertions(+), 282 deletions(-) delete mode 100644 Accelerometer.xcworkspace/xcshareddata/swiftpm/Package.resolved delete mode 100644 Accelerometer/Recordings/RecordingMeasurementView.swift diff --git a/Accelerometer.xcodeproj/project.pbxproj b/Accelerometer.xcodeproj/project.pbxproj index 9477174..9b6a122 100644 --- a/Accelerometer.xcodeproj/project.pbxproj +++ b/Accelerometer.xcodeproj/project.pbxproj @@ -45,7 +45,6 @@ 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 */; }; 0B9BDC132899A6F900842161 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B9BDC122899A6F900842161 /* Array.swift */; }; 0BA5B7BC2AD319AC004F1808 /* AttitudeAxesSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BA5B7BB2AD319AC004F1808 /* AttitudeAxesSummaryView.swift */; }; 0BA619CF2AADC9E7005DABD3 /* ObservableAxesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BA619CE2AADC9E7005DABD3 /* ObservableAxesTests.swift */; }; @@ -54,7 +53,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 */; }; @@ -144,7 +142,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 = ""; }; @@ -180,7 +177,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 0B9BDC11289973CC00842161 /* SwiftUICharts in Frameworks */, A844F53C6B72222DA87BA782 /* Pods_Accelerometer.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -398,7 +394,6 @@ 0BE138ED2893FFA10026D0DC /* RecordingsView.swift */, 0BA63B712897FBCE0040B63D /* RecordingPreview.swift */, 0BBAD4AC2895564C00251262 /* RecordingSummaryView.swift */, - 0BADCA5E289A720600238A25 /* RecordingMeasurementView.swift */, 0B45FF952D8EC357009F5CDF /* RecordingMeasurementChartView.swift */, 0BA63B6B289598B40040B63D /* NewRecordingView.swift */, 0BE138F1289400E70026D0DC /* Recording.swift */, @@ -450,7 +445,6 @@ ); name = Accelerometer; packageProductDependencies = ( - 0B9BDC10289973CC00842161 /* SwiftUICharts */, ); productName = Accelerometer; productReference = 0B24EC0428621BBE00AD3EF1 /* Acc.elerometer.app */; @@ -528,7 +522,6 @@ ); mainGroup = 0B24EBFB28621BBE00AD3EF1; packageReferences = ( - 0B9BDC0F289973CC00842161 /* XCRemoteSwiftPackageReference "ChartView" */, ); productRefGroup = 0B24EC0528621BBE00AD3EF1 /* Products */; projectDirPath = ""; @@ -731,7 +724,6 @@ 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 */, 0BFBA1E828737CC300529B50 /* ObservableAxes.swift in Sources */, @@ -829,7 +821,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; @@ -883,7 +875,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; @@ -913,7 +905,7 @@ 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 = 16.6; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -947,7 +939,7 @@ 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 = 16.6; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1081,25 +1073,6 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ - -/* Begin XCRemoteSwiftPackageReference section */ - 0B9BDC0F289973CC00842161 /* XCRemoteSwiftPackageReference "ChartView" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/AppPear/ChartView"; - requirement = { - kind = exactVersion; - version = 1.5.5; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 0B9BDC10289973CC00842161 /* SwiftUICharts */ = { - isa = XCSwiftPackageProductDependency; - package = 0B9BDC0F289973CC00842161 /* XCRemoteSwiftPackageReference "ChartView" */; - productName = SwiftUICharts; - }; -/* End XCSwiftPackageProductDependency section */ }; rootObject = 0B24EBFC28621BBE00AD3EF1 /* Project object */; } diff --git a/Accelerometer.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Accelerometer.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 99bc634..0000000 --- a/Accelerometer.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,15 +0,0 @@ -{ - "originHash" : "4fa034e453faafc0169cb98ce1b88bc4e38f64f1cc0a176127db6dd00618e302", - "pins" : [ - { - "identity" : "chartview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/AppPear/ChartView", - "state" : { - "revision" : "9115a992c91fa19cbb4f2241084240d38654a1fc", - "version" : "1.5.5" - } - } - ], - "version" : 3 -} diff --git a/Accelerometer/Recordings/Recording.swift b/Accelerometer/Recordings/Recording.swift index 2be0dd0..bcfe9eb 100644 --- a/Accelerometer/Recordings/Recording.swift +++ b/Accelerometer/Recordings/Recording.swift @@ -61,21 +61,6 @@ struct Recording: Identifiable { return TextFile(initialText: csvStrings.joined(separator: RecordingUtils.rowSeparator)) } - // TODO: remove - 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() - } - func chartValues2(of measurementType: MeasurementType) -> [Entry] { let filtered = entries.filter { $0.measurementType == measurementType } 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/RecordingSummaryView.swift b/Accelerometer/Recordings/RecordingSummaryView.swift index 6975644..11bfcb6 100644 --- a/Accelerometer/Recordings/RecordingSummaryView.swift +++ b/Accelerometer/Recordings/RecordingSummaryView.swift @@ -6,7 +6,6 @@ // import SwiftUI -import SwiftUICharts struct RecordingSummaryView: View { @@ -33,24 +32,12 @@ struct RecordingSummaryView: View { Section(header: Text("Measurements")) { ForEach(Array(recording.sortedMeasurementTypes), id: \.self) { measurementType in HStack { - if #available(iOS 16.0, *) { - RecordingMeasurementChartView( - recording: recording, - measurementType: measurementType - ) { - isPresentingExporter = true - exportMeasurementType = measurementType - } - } else { - // TODO: deprecate? - RecordingMeasurementView( - recording: recording, - measurementType: measurementType, - screenSize: geometry.size - ) { - isPresentingExporter = true - exportMeasurementType = measurementType - } + RecordingMeasurementChartView( + recording: recording, + measurementType: measurementType + ) { + isPresentingExporter = true + exportMeasurementType = measurementType } } } @@ -144,37 +131,6 @@ struct RecordingSummaryView: View { } } -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 RecordingView_Previews: PreviewProvider { static let settings = Settings() diff --git a/Accelerometer/Recordings/RecordingsView.swift b/Accelerometer/Recordings/RecordingsView.swift index 64fe37f..3928407 100644 --- a/Accelerometer/Recordings/RecordingsView.swift +++ b/Accelerometer/Recordings/RecordingsView.swift @@ -72,7 +72,6 @@ struct RecordingsView: View { } func deleteSelected() { - // TODO: implement actual deletion in Recorder recorder.delete(recordingIDs: Array(selectedRecordings)) selectedRecordings.removeAll() isEditMode = false From 61302117318b98c0fdf6082f436316e8f6895b48 Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Wed, 17 Sep 2025 18:35:17 +0300 Subject: [PATCH 09/40] Less chart values, new preview --- Accelerometer/Recordings/Recording.swift | 6 +-- .../RecordingMeasurementChartView.swift | 40 +++++++++++++------ 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/Accelerometer/Recordings/Recording.swift b/Accelerometer/Recordings/Recording.swift index bcfe9eb..440c284 100644 --- a/Accelerometer/Recordings/Recording.swift +++ b/Accelerometer/Recordings/Recording.swift @@ -61,12 +61,12 @@ struct Recording: Identifiable { return TextFile(initialText: csvStrings.joined(separator: RecordingUtils.rowSeparator)) } - func chartValues2(of measurementType: MeasurementType) -> [Entry] { + func chartValues(of measurementType: MeasurementType) -> [Entry] { let filtered = entries.filter { $0.measurementType == measurementType } - guard filtered.count > 1000 else { return filtered } + guard filtered.count > 50 else { return filtered } - let stride = filtered.count / 1000 + let stride = filtered.count / 50 return filtered.enumerated().compactMap { index, entry in return index % stride == 0 ? entry : nil } diff --git a/Accelerometer/Recordings/RecordingMeasurementChartView.swift b/Accelerometer/Recordings/RecordingMeasurementChartView.swift index 18a1426..25efd4c 100644 --- a/Accelerometer/Recordings/RecordingMeasurementChartView.swift +++ b/Accelerometer/Recordings/RecordingMeasurementChartView.swift @@ -18,7 +18,7 @@ struct RecordingMeasurementChartView: View { var title: String { measurementType.name.capitalizingFirstLetter() } var chartEntries: [Recording.Entry] { - recording.chartValues2(of: measurementType) + recording.chartValues(of: measurementType) } var body: some View { @@ -105,20 +105,34 @@ struct RecordingMeasurementChartView_Previews: PreviewProvider { static let baseAxes: [TriangleAxes] = [axes1, axes2, axes3] static var allEntries: [Recording.Entry] { - let repeats = 5 - let interval: TimeInterval = 60 - return (0.. Date: Wed, 17 Sep 2025 19:48:02 +0300 Subject: [PATCH 10/40] Now recording chart starts from first entry datetime --- .../RecordingMeasurementChartView.swift | 160 +++++++++++------- 1 file changed, 98 insertions(+), 62 deletions(-) diff --git a/Accelerometer/Recordings/RecordingMeasurementChartView.swift b/Accelerometer/Recordings/RecordingMeasurementChartView.swift index 25efd4c..dfbdfe9 100644 --- a/Accelerometer/Recordings/RecordingMeasurementChartView.swift +++ b/Accelerometer/Recordings/RecordingMeasurementChartView.swift @@ -15,6 +15,10 @@ struct RecordingMeasurementChartView: View { let presentExporter: () -> Void + private var startDate: Date { + chartEntries.first?.date ?? Date() + } + var title: String { measurementType.name.capitalizingFirstLetter() } var chartEntries: [Recording.Entry] { @@ -28,7 +32,7 @@ struct RecordingMeasurementChartView: View { .padding(.top, 4) Chart(chartEntries) { entry in - CustomChartContent(entry: entry) + CustomChartContent(entry: entry, startDate: startDate) } .frame(height: 200) .padding(.horizontal, 16) @@ -39,10 +43,10 @@ struct RecordingMeasurementChartView: View { private struct CustomChartContent: ChartContent { let entry: Recording.Entry + let startDate: Date var body: AnyChartContent { let axes = entry.axes - AnyChartContent(erasing: createLineMarks(axes: axes)) } @@ -53,12 +57,12 @@ private struct CustomChartContent: ChartContent { ForEach(axesTypes) { axeType in if let yAxis = axes.values[axeType] { - - let xName = "date" + let elapsed = entry.date.timeIntervalSince(startDate) + let xName = "elapsed" let yName = yAxis.type_.name LineMark( - x: .value(xName, entry.date), + x: .value(xName, elapsed), y: .value(yName, yAxis.value) ) .foregroundStyle(by: .value("name", yName)) @@ -69,84 +73,116 @@ private struct CustomChartContent: ChartContent { struct RecordingMeasurementChartView_Previews: PreviewProvider { - static let axes1: TriangleAxes = { - var axes = TriangleAxes.zero - axes.displayableAbsMax = 1.0 - axes.values = [ - .x: Axis(type_: .x, value: 0.0), - .y: Axis(type_: .y, value: 0.0), - .z: Axis(type_: .z, value: 0.0) - ] - return axes - }() - - static let axes2: TriangleAxes = { - var axes = TriangleAxes.zero - axes.displayableAbsMax = 1.0 - axes.values = [ - .x: Axis(type_: .x, value: 1.0), - .y: Axis(type_: .y, value: 0.1), - .z: Axis(type_: .z, value: 0.1) - ] - return axes - }() - - static let axes3: TriangleAxes = { + static var axesTemplate: TriangleAxes { var axes = TriangleAxes.zero + let xValue = Double.random(in: -1.0...1.0) + let yValue = Double.random(in: -1.0...1.0) + let zValue = Double.random(in: -1.0...1.0) + axes.displayableAbsMax = 1.0 axes.values = [ - .x: Axis(type_: .x, value: 0.2), - .y: Axis(type_: .y, value: 0.2), - .z: Axis(type_: .z, value: 0.2) + .x: Axis(type_: .x, value: xValue), + .y: Axis(type_: .y, value: yValue), + .z: Axis(type_: .z, value: zValue) ] return axes - }() - - static let baseAxes: [TriangleAxes] = [axes1, axes2, axes3] + } - static var allEntries: [Recording.Entry] { - let totalPoints = 50 - let pointsPerSecond = 1.0 + static var secondsEntries: [Recording.Entry] { + let totalPoints = 60 + let pointsPerSecond = 30.0 let startDate = Date() return (0.. Date: Fri, 19 Sep 2025 11:42:59 +0300 Subject: [PATCH 11/40] Refactor recordings processing and AxesSummaryViewExtended previews Also includes x marks enhancements --- Accelerometer.xcodeproj/project.pbxproj | 4 + .../Axes/AxesSummaryViewExtended.swift | 158 ++++------ .../Database/RecordingsRepository.swift | 41 ++- Accelerometer/Recordings/Recorder.swift | 129 ++++---- Accelerometer/Recordings/Recording.swift | 25 -- .../RecordingMeasurementChartView.swift | 294 ++++++++++++------ .../Recordings/RecordingProcessor.swift | 54 ++++ .../Recordings/RecordingSummaryView.swift | 185 ++++++----- Accelerometer/Recordings/RecordingsView.swift | 19 +- 9 files changed, 487 insertions(+), 422 deletions(-) create mode 100644 Accelerometer/Recordings/RecordingProcessor.swift diff --git a/Accelerometer.xcodeproj/project.pbxproj b/Accelerometer.xcodeproj/project.pbxproj index 9b6a122..36f9017 100644 --- a/Accelerometer.xcodeproj/project.pbxproj +++ b/Accelerometer.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 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 */; }; 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 */; }; @@ -96,6 +97,7 @@ /* 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 = ""; }; 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 = ""; }; @@ -397,6 +399,7 @@ 0B45FF952D8EC357009F5CDF /* RecordingMeasurementChartView.swift */, 0BA63B6B289598B40040B63D /* NewRecordingView.swift */, 0BE138F1289400E70026D0DC /* Recording.swift */, + 0B1845502E7DA052004F5474 /* RecordingProcessor.swift */, 0BE138F5289409820026D0DC /* Recorder.swift */, 0BDB67DB2AC9AC6200FA5C09 /* RecordingUtils.swift */, ); @@ -732,6 +735,7 @@ 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 */, 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/Recordings/Database/RecordingsRepository.swift b/Accelerometer/Recordings/Database/RecordingsRepository.swift index 3d64332..4164d9e 100644 --- a/Accelerometer/Recordings/Database/RecordingsRepository.swift +++ b/Accelerometer/Recordings/Database/RecordingsRepository.swift @@ -7,38 +7,37 @@ import RealmSwift -class RecordingsRepository: ObservableObject { +actor RecordingsRepository { - private let databaseManager = DatabaseManagerImpl(configuration: .defaultConfiguration) - - var recordings: [Recording] = [] - - func save(_ recordings: [Recording]) { - databaseManager.write( - recordings.map { RecordingRealm(recording: $0) } - ) + private let databaseManager = DatabaseManagerImpl( + configuration: .defaultConfiguration + ) + + private(set) var recordings: [Recording] = [] + + func save(_ items: [Recording]) { + let realms = items.map { RecordingRealm(recording: $0) } + databaseManager.write(realms) } - 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]) + } } func delete(recordingIDs: [String]) { - recordingIDs.forEach { delete(recordingID: $0) } - } - - func delete(recordingID: String) { - let recordings: [RecordingRealm] = Array(databaseManager.read() as Results) - guard let recording = recordings.first(where: { $0.id == recordingID }) else { - return - } - databaseManager.delete([recording]) + let all: [RecordingRealm] = Array(databaseManager.read() as Results) + let toDelete = all.filter { recordingIDs.contains($0.id) } + databaseManager.delete(toDelete) } func update() { let realmRecordings: Results = databaseManager.read() - recordings = Array(realmRecordings) + let reversed = Array(realmRecordings) .map { $0.recording } .reversed() + recordings = Array(reversed) } } diff --git a/Accelerometer/Recordings/Recorder.swift b/Accelerometer/Recordings/Recorder.swift index 35db693..0fc5e87 100644 --- a/Accelerometer/Recordings/Recorder.swift +++ b/Accelerometer/Recordings/Recorder.swift @@ -11,108 +11,113 @@ import SwiftUI @MainActor class Recorder: ObservableObject { @Published var isInEditMode = false - @ObservedObject private var measurer: Measurer - - private let repository: RecordingsRepository = { - let repository = RecordingsRepository() - repository.update() - return repository - }() + @Published private(set) var recordings: [Recording] = [] + @Published private(set) var activeRecording: Recording? = nil + @ObservedObject private var measurer: Measurer + private let repository = RecordingsRepository() private let disableIdleTimer = true + private var subscriptions: [AnyCancellable] = [] - 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) + init(measurer: Measurer) { + self.measurer = measurer + Task { + await refreshRecordings() } - return savedRecordings } - private var subscriptions: [AnyCancellable?] = [] - - init(measurer: Measurer) { - self.measurer = measurer + var recordingInProgress: Bool { + activeRecording != nil } - func record(measurements measurementTypes: Set) { + func record(measurements types: Set) { if disableIdleTimer { UIApplication.shared.isIdleTimerDisabled = true } + guard !recordingInProgress, !types.isEmpty else { return } - guard !recordingInProgress, !measurementTypes.isEmpty else { - return - } + activeRecording = Recording( + entries: [], + state: .inProgress, + measurementTypes: types + ) - let newRecording = Recording(entries: [], state: .inProgress, measurementTypes: measurementTypes) - activeRecording = newRecording -// repository.save([newRecording]) + types.forEach(subscribeForChanges) - for type in measurementTypes { - subscribeForChanges(of: type) - } + objectWillChange.send() } func stopRecording() { if disableIdleTimer { UIApplication.shared.isIdleTimerDisabled = false } + guard recordingInProgress, + var current = activeRecording + else { return } - guard recordingInProgress else { - return - } + current.state = .completed - if var activeRecording = activeRecording { - activeRecording.state = .completed - repository.save([activeRecording]) - repository.update() + Task { + await repository.save([current]) + await repository.update() + await refreshRecordings() } activeRecording = nil - cancelSubscriptions() - + subscriptions.removeAll() + objectWillChange.send() } func delete(recordingID: String) { - repository.delete(recordingID: recordingID) - repository.update() - - objectWillChange.send() + Task { + await repository.delete(recordingID: recordingID) + await repository.update() + await refreshRecordings() + } } func delete(recordingIDs: [String]) { - repository.delete(recordingIDs: recordingIDs) - repository.update() - - objectWillChange.send() - } - - 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) + Task { + await repository.delete(recordingIDs: recordingIDs) + await repository.update() + await refreshRecordings() } - subscriptions.append(newSubsription) } - private func save(axes: (any Axes)?, of type: MeasurementType) { - guard let axes = axes else { return } - save(axes: axes, of: type) + private func subscribeForChanges(of type: MeasurementType) { + guard let obs = measurer.observableAxes[type] else { return } + let sub = obs.objectWillChange.sink { [weak self] in + self?.appendEntry(for: type) + } + subscriptions.append(sub) } - 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 appendEntry(for type: MeasurementType) { + guard let axes = measurer.observableAxes[type]?.axes, + var current = activeRecording + else { return } + + let entry = Recording.Entry( + measurementType: type, + date: Date(), + axes: axes + ) + current.entries.append(entry) + activeRecording = current objectWillChange.send() } - private func cancelSubscriptions() { - subscriptions = [] + // MARK: - Helpers + + private func refreshRecordings() async { + await repository.update() + let stored = await repository.recordings + var list = stored + if let current = activeRecording { + list.insert(current, at: 0) + } + recordings = list } } diff --git a/Accelerometer/Recordings/Recording.swift b/Accelerometer/Recordings/Recording.swift index 440c284..5f53599 100644 --- a/Accelerometer/Recordings/Recording.swift +++ b/Accelerometer/Recordings/Recording.swift @@ -6,7 +6,6 @@ // import Foundation -import CoreData struct Recording: Identifiable { @@ -47,30 +46,6 @@ struct Recording: Identifiable { 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) -> [Entry] { - let filtered = entries.filter { $0.measurementType == measurementType } - - guard filtered.count > 50 else { return filtered } - - let stride = filtered.count / 50 - return filtered.enumerated().compactMap { index, entry in - return index % stride == 0 ? entry : nil - } - } } extension Recording { diff --git a/Accelerometer/Recordings/RecordingMeasurementChartView.swift b/Accelerometer/Recordings/RecordingMeasurementChartView.swift index dfbdfe9..1da8ca8 100644 --- a/Accelerometer/Recordings/RecordingMeasurementChartView.swift +++ b/Accelerometer/Recordings/RecordingMeasurementChartView.swift @@ -12,35 +12,130 @@ struct RecordingMeasurementChartView: View { let recording: Recording let measurementType: MeasurementType + let export: () -> Void - let presentExporter: () -> Void + @State private var chartEntries: [Recording.Entry] = [] + @State private var isLoading = true + + 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) + } + var title: String { measurementType.name.capitalizingFirstLetter() } - var chartEntries: [Recording.Entry] { - recording.chartValues(of: measurementType) + 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) + 1 + if tickCount <= 5 { + return interval + } + } + + return totalDuration / 4.0 + } + + 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 { + let minutes = seconds / 60 + return String(format: "%.0fm", 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 { - VStack(spacing: 8) { - Text(title) - .font(.headline) - .padding(.top, 4) - - Chart(chartEntries) { entry in - CustomChartContent(entry: entry, startDate: startDate) + HStack { + VStack() { + Text(title) + .font(.headline) + .padding(.top) + + if isLoading { + ProgressView() + .frame(height: 200) + } else { + Chart(chartEntries) { entry in + CustomChartContent(entry: entry, startDate: startDate) + } + .chartXScale(domain: 0...totalDuration) + .chartXAxis { + AxisMarks(values: .stride(by: chartXAxisStride)) { value in + AxisGridLine() + AxisTick() + if let seconds = value.as(Double.self) { + AxisValueLabel { + Text(formatElapsedTime(seconds)) + } + } + } + } + .frame(height: 200) + } + } + VStack { + Menu { + Section { + Button(action: { + export() + }) { + Label("Export to .csv", systemImage: "folder") + } + } + } label: { + Image(systemName: "square.and.arrow.up") + } + .padding([.vertical, .leading]) + .buttonStyle(BorderlessButtonStyle()) + Spacer() } - .frame(height: 200) - .padding(.horizontal, 16) - .padding(.bottom, 20) + } + .task { + await loadChartData() + } + } + + private func loadChartData() async { + isLoading = true + let entries = await processor.sampledEntries( + from: recording, + for: measurementType, + maxCount: 50 + ) + await MainActor.run { + chartEntries = entries + isLoading = false } } } + private struct CustomChartContent: ChartContent { let entry: Recording.Entry let startDate: Date @@ -71,118 +166,111 @@ private struct CustomChartContent: ChartContent { } } +// MARK: - Previews + struct RecordingMeasurementChartView_Previews: PreviewProvider { - static var axesTemplate: TriangleAxes { - var axes = TriangleAxes.zero - let xValue = Double.random(in: -1.0...1.0) - let yValue = Double.random(in: -1.0...1.0) - let zValue = Double.random(in: -1.0...1.0) - - axes.displayableAbsMax = 1.0 - axes.values = [ - .x: Axis(type_: .x, value: xValue), - .y: Axis(type_: .y, value: yValue), - .z: Axis(type_: .z, value: zValue) - ] - return axes - } + // MARK: Test Data Configuration - static var secondsEntries: [Recording.Entry] { - let totalPoints = 60 - let pointsPerSecond = 30.0 - let startDate = Date() + struct PreviewData { + let title: String + let totalPoints: Int + let samplingRate: Double + let interval: Double - return (0.. URL { + guard recording.measurementTypes.contains(type) else { + throw ProcessingError.invalidType + } + + 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 11bfcb6..2f5d398 100644 --- a/Accelerometer/Recordings/RecordingSummaryView.swift +++ b/Accelerometer/Recordings/RecordingSummaryView.swift @@ -6,131 +6,120 @@ // import SwiftUI +import UniformTypeIdentifiers struct RecordingSummaryView: View { let recording: Recording - @EnvironmentObject var settings: Settings - @EnvironmentObject var recorder: Recorder + @EnvironmentObject private var settings: Settings + @EnvironmentObject private var recorder: Recorder + @Environment(\.presentationMode) private var presentationMode - @Environment(\.presentationMode) var presentationMode: Binding @State private var isPresentingDeleteConfirmation = false - @State private var isPresentingExporter = false - @State private var exportMeasurementType: MeasurementType? = nil + @State private var exportURL: URL? = nil + @State private var exportLoading = false - let deleteAlertTitleText = "Are you sure?" - - 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 { - RecordingMeasurementChartView( - recording: recording, - measurementType: measurementType - ) { - isPresentingExporter = true - exportMeasurementType = measurementType - } - } - } - } - } - } - } + private let processor = RecordingProcessor() 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") - } - } - } + List { + Section("Info") { + RecordingPreview(recording: recording) } - - // MARK: Delete confirmation - .modify { - if #available(iOS 15.0, *) { - $0.confirmationDialog( - deleteAlertTitleText, - isPresented: $isPresentingDeleteConfirmation + Section("Measurements") { + ForEach(recording.sortedMeasurementTypes, id: \.self) { type in + RecordingMeasurementChartView( + recording: recording, + measurementType: type ) { - 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: { } - ) - ) + export(type: type) } } } - - // 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) + } + .navigationTitle("Recording") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(role: .destructive) { + isPresentingDeleteConfirmation = true + } label: { + Label("Delete", systemImage: "trash") } } - } - - private var document: TextFile? { - guard let type = exportMeasurementType else { - return nil + // TODO: export +// ToolbarItem(placement: .primaryAction) { +// Button { +// } label: { +// Label("Export", systemImage: "square.and.arrow.up") +// } +// .disabled(true) +// } } - return recording.csv( - of: type, - dateFormat: settings.exportDateFormat - ) + .confirmationDialog( + "Are you sure?", + isPresented: $isPresentingDeleteConfirmation + ) { + Button("Delete", role: .destructive, action: deleteRecording) + } + .fileExporter( + isPresented: $isPresentingExporter, + document: exportURL.map { FileDocumentWrapper(url: $0) }, + contentType: UTType.plainText, + defaultFilename: defaultFilename() + ) { _ in } } private func deleteRecording() { recorder.delete(recordingID: recording.id) presentationMode.wrappedValue.dismiss() } + + private func export(type: MeasurementType) { + guard !exportLoading else { return } + exportLoading = true + Task { + defer { exportLoading = false } + do { + let url = try await processor.generateCSV( + from: recording, + for: type, + dateFormat: settings.exportDateFormat + ) + + exportURL = url + isPresentingExporter = true + } catch { + print("Export failed:", error) + } + } + } + + private func defaultFilename() -> String { + (exportURL?.lastPathComponent ?? recording.id) + ".csv" + } +} + + +struct FileDocumentWrapper: FileDocument { + + 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 { static let settings = Settings() diff --git a/Accelerometer/Recordings/RecordingsView.swift b/Accelerometer/Recordings/RecordingsView.swift index 3928407..022c34b 100644 --- a/Accelerometer/Recordings/RecordingsView.swift +++ b/Accelerometer/Recordings/RecordingsView.swift @@ -33,21 +33,10 @@ struct RecordingsView: View { } } - var activeRecording: Recording? { - guard let firstRecording = recorder.recordings.first else { - return nil - } - if firstRecording.state == .inProgress { - return firstRecording - } else { - return nil - } - } - var lastRecordings: [Recording] { var recordings = recorder.recordings - if activeRecording != nil { - recordings.removeFirst() + if let activeRecording = recorder.activeRecording { + recordings.removeAll { $0.id == activeRecording.id } } return recordings } @@ -164,7 +153,7 @@ struct RecordingsView: View { } } - if let activeRecording = activeRecording { + if let activeRecording = recorder.activeRecording { Section(header: Text("Recording in progress")) { recordingRow(recording: activeRecording) } @@ -195,7 +184,7 @@ struct RecordingsView: View { isEditMode = false selectedRecordings.removeAll() } - } else if activeRecording == nil { + } else if recorder.activeRecording == nil { Button("Edit") { isEditMode = true } From 27c258a623ed43402497c6133081ecfb0796da40 Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Fri, 19 Sep 2025 19:46:12 +0300 Subject: [PATCH 12/40] Remove delete confirmation --- Accelerometer/Recordings/RecordingSummaryView.swift | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/Accelerometer/Recordings/RecordingSummaryView.swift b/Accelerometer/Recordings/RecordingSummaryView.swift index 2f5d398..f975f67 100644 --- a/Accelerometer/Recordings/RecordingSummaryView.swift +++ b/Accelerometer/Recordings/RecordingSummaryView.swift @@ -15,8 +15,7 @@ struct RecordingSummaryView: View { @EnvironmentObject private var settings: Settings @EnvironmentObject private var recorder: Recorder @Environment(\.presentationMode) private var presentationMode - - @State private var isPresentingDeleteConfirmation = false + @State private var isPresentingExporter = false @State private var exportURL: URL? = nil @State private var exportLoading = false @@ -41,9 +40,9 @@ struct RecordingSummaryView: View { } .navigationTitle("Recording") .toolbar { - ToolbarItem(placement: .primaryAction) { + ToolbarItem(placement: .secondaryAction) { Button(role: .destructive) { - isPresentingDeleteConfirmation = true + deleteRecording() } label: { Label("Delete", systemImage: "trash") } @@ -57,12 +56,6 @@ struct RecordingSummaryView: View { // .disabled(true) // } } - .confirmationDialog( - "Are you sure?", - isPresented: $isPresentingDeleteConfirmation - ) { - Button("Delete", role: .destructive, action: deleteRecording) - } .fileExporter( isPresented: $isPresentingExporter, document: exportURL.map { FileDocumentWrapper(url: $0) }, From ee85647f1317dc788aa3a30d28f9182c9e6bb066 Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Fri, 19 Sep 2025 19:49:33 +0300 Subject: [PATCH 13/40] Toggles accent color --- Accelerometer/Recordings/NewRecordingView.swift | 1 + Accelerometer/Settings/AnimationsView.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Accelerometer/Recordings/NewRecordingView.swift b/Accelerometer/Recordings/NewRecordingView.swift index a73db6f..f388128 100644 --- a/Accelerometer/Recordings/NewRecordingView.swift +++ b/Accelerometer/Recordings/NewRecordingView.swift @@ -98,6 +98,7 @@ struct NewRecordingView: View { )) { Text(measurementType.name) } + .tint(.accentColor) } } } 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) } } From b1a0f7a0e6f68b67719fd154fd80a612a11477b3 Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Fri, 19 Sep 2025 20:04:32 +0300 Subject: [PATCH 14/40] Buttons styles --- .../Measurements/MeasurementSummaryView.swift | 5 +- .../Recordings/NewRecordingView.swift | 90 +++++++++---------- 2 files changed, 43 insertions(+), 52 deletions(-) diff --git a/Accelerometer/Measurements/MeasurementSummaryView.swift b/Accelerometer/Measurements/MeasurementSummaryView.swift index d66c765..6369b33 100644 --- a/Accelerometer/Measurements/MeasurementSummaryView.swift +++ b/Accelerometer/Measurements/MeasurementSummaryView.swift @@ -25,7 +25,6 @@ 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 return Button( action: { @@ -35,10 +34,10 @@ struct MeasurementSummaryView: View { .foregroundColor(.background) } ) + .buttonStyle(.borderedProminent) + .controlSize(.large) .disabled(!enabled) .padding(.defaultPadding) - .background(backgroundColor) - .cornerRadius(.defaultCornerRadius) } var body: some View { diff --git a/Accelerometer/Recordings/NewRecordingView.swift b/Accelerometer/Recordings/NewRecordingView.swift index f388128..56b4e45 100644 --- a/Accelerometer/Recordings/NewRecordingView.swift +++ b/Accelerometer/Recordings/NewRecordingView.swift @@ -49,67 +49,59 @@ struct NewRecordingView: View { presentationMode.wrappedValue.dismiss() } - 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 func setAll(_ selected: Bool) { + measurementTypes.keys.forEach { measurementTypes[$0] = selected } + } + + private var allSelected: Bool { + !measurementTypes.values.contains(false) + } + + 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 ? "Unselect 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 - } - )) { - Text(measurementType.name) - } - .tint(.accentColor) - } + ForEach(MeasurementType.allShownCases, id: \.self) { type in + Toggle(type.name, isOn: Binding( + get: { measurementTypes[type] ?? false }, + set: { measurementTypes[type] = $0 } + )) + .tint(.accentColor) } } - .listStyle(.inset) - startRecordingButton - .padding() + .listStyle(.insetGrouped) + + Button { + startRecording() + } label: { + Text("Start Recording") + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(noneSelected) + .padding(.horizontal) } - .navigationTitle("New recording") + .navigationTitle("New Recording") .toolbar { - Button("Cancel") { - presentationMode.wrappedValue.dismiss() + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + presentationMode.wrappedValue.dismiss() + } } } } From 966eec5ea0566a1bfdf2c36bf39ab955d01d98ad Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Fri, 19 Sep 2025 20:30:20 +0300 Subject: [PATCH 15/40] NewRecordingView animations --- .../Recordings/NewRecordingView.swift | 51 ++++++------------- 1 file changed, 16 insertions(+), 35 deletions(-) diff --git a/Accelerometer/Recordings/NewRecordingView.swift b/Accelerometer/Recordings/NewRecordingView.swift index 56b4e45..418ca1a 100644 --- a/Accelerometer/Recordings/NewRecordingView.swift +++ b/Accelerometer/Recordings/NewRecordingView.swift @@ -10,42 +10,18 @@ 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) - } - - var allMeasurementAreUnselected: Bool { - allMeasurementTypesAre(selected: false) - } - - func startRecording() { - let enabledMeasurementTypes: Set = Set(measurementTypes.compactMap { - $0.value ? $0.key : nil - }) - recorder.record(measurements: enabledMeasurementTypes) + private func startRecording() { + let enabled = measurementTypes + .compactMap { $0.value ? $0.key : nil } + recorder.record(measurements: Set(enabled)) presentationMode.wrappedValue.dismiss() } @@ -79,22 +55,27 @@ struct NewRecordingView: View { ForEach(MeasurementType.allShownCases, id: \.self) { type in Toggle(type.name, isOn: Binding( get: { measurementTypes[type] ?? false }, - set: { measurementTypes[type] = $0 } + set: { newValue in + withAnimation(.easeInOut) { + measurementTypes[type] = newValue + } + } )) .tint(.accentColor) } } .listStyle(.insetGrouped) + .animation(.easeInOut, value: measurementTypes) - Button { + Button("Start Recording") { startRecording() - } label: { - Text("Start Recording") } .buttonStyle(.borderedProminent) .controlSize(.large) + .tint(noneSelected ? .gray : .accentColor) .disabled(noneSelected) .padding(.horizontal) + .animation(.easeInOut, value: noneSelected) } .navigationTitle("New Recording") .toolbar { From ed5be58379d09bfd0812d55788dbccb1ec09adb0 Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Sun, 21 Sep 2025 17:18:51 +0300 Subject: [PATCH 16/40] Memory monitor created --- Accelerometer.xcodeproj/project.pbxproj | 4 + Accelerometer/Other/MemoryMonitor.swift | 24 +++++ Accelerometer/Recordings/Recorder.swift | 121 +++++++++++++++--------- Accelerometer/Settings/Settings.swift | 2 + 4 files changed, 108 insertions(+), 43 deletions(-) create mode 100644 Accelerometer/Other/MemoryMonitor.swift diff --git a/Accelerometer.xcodeproj/project.pbxproj b/Accelerometer.xcodeproj/project.pbxproj index 36f9017..ae7126c 100644 --- a/Accelerometer.xcodeproj/project.pbxproj +++ b/Accelerometer.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 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 */; }; @@ -98,6 +99,7 @@ 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 = ""; }; @@ -371,6 +373,7 @@ isa = PBXGroup; children = ( 0B801285286DA9C000F4AA85 /* AnimatableArray.swift */, + 0B1845532E7DCB90004F5474 /* MemoryMonitor.swift */, ); path = Other; sourceTree = ""; @@ -679,6 +682,7 @@ 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 */, 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/Recordings/Recorder.swift b/Accelerometer/Recordings/Recorder.swift index 0fc5e87..fb6f629 100644 --- a/Accelerometer/Recordings/Recorder.swift +++ b/Accelerometer/Recordings/Recorder.swift @@ -16,6 +16,8 @@ class Recorder: ObservableObject { @ObservedObject private var measurer: Measurer private let repository = RecordingsRepository() + private let memoryMonitor = MemoryMonitor() + private let disableIdleTimer = true private var subscriptions: [AnyCancellable] = [] @@ -24,6 +26,9 @@ class Recorder: ObservableObject { Task { await refreshRecordings() } + Task { + await watchFreeSpace() + } } var recordingInProgress: Bool { @@ -31,42 +36,51 @@ class Recorder: ObservableObject { } func record(measurements types: Set) { - if disableIdleTimer { - UIApplication.shared.isIdleTimerDisabled = true + Task { + if await !hasEnoughMemory() { return } + + await MainActor.run { + if disableIdleTimer { + UIApplication.shared.isIdleTimerDisabled = true + } + + guard !recordingInProgress, !types.isEmpty else { return } + activeRecording = Recording( + entries: [], + state: .inProgress, + measurementTypes: types + ) + + types.forEach(subscribeForChanges) + + objectWillChange.send() + } } - guard !recordingInProgress, !types.isEmpty else { return } - - activeRecording = Recording( - entries: [], - state: .inProgress, - measurementTypes: types - ) - - types.forEach(subscribeForChanges) - - objectWillChange.send() } func stopRecording() { - if disableIdleTimer { - UIApplication.shared.isIdleTimerDisabled = false - } - guard recordingInProgress, - var current = activeRecording - else { return } - - current.state = .completed - Task { - await repository.save([current]) + await MainActor.run { + if disableIdleTimer { + UIApplication.shared.isIdleTimerDisabled = false + } + } + + guard let current = activeRecording else { return } + + var completed = current + completed.state = .completed + + await repository.save([completed]) await repository.update() await refreshRecordings() + + await MainActor.run { + activeRecording = nil + subscriptions.removeAll() + objectWillChange.send() + } } - - activeRecording = nil - subscriptions.removeAll() - - objectWillChange.send() } func delete(recordingID: String) { @@ -87,6 +101,7 @@ class Recorder: ObservableObject { private func subscribeForChanges(of type: MeasurementType) { guard let obs = measurer.observableAxes[type] else { return } + let sub = obs.objectWillChange.sink { [weak self] in self?.appendEntry(for: type) } @@ -94,22 +109,36 @@ class Recorder: ObservableObject { } private func appendEntry(for type: MeasurementType) { - guard let axes = measurer.observableAxes[type]?.axes, - var current = activeRecording - else { return } - - let entry = Recording.Entry( - measurementType: type, - date: Date(), - axes: axes - ) - current.entries.append(entry) - activeRecording = current - - objectWillChange.send() + Task { + if await !hasEnoughMemory() { return } + + guard let axes = measurer.observableAxes[type]?.axes, + var current = activeRecording + else { return } + + let entry = Recording.Entry( + measurementType: type, + date: Date(), + axes: axes + ) + current.entries.append(entry) + + await MainActor.run { + activeRecording = current + objectWillChange.send() + } + } } - // MARK: - Helpers + private func watchFreeSpace() async { + while true { + try? await Task.sleep(nanoseconds: 10_000_000_000) + + if await !hasEnoughMemory() { + await MainActor.run { stopRecording() } + } + } + } private func refreshRecordings() async { await repository.update() @@ -118,6 +147,12 @@ class Recorder: ObservableObject { if let current = activeRecording { list.insert(current, at: 0) } - recordings = list + + await MainActor.run { recordings = list } + } + + private func hasEnoughMemory() async -> Bool { + let freeMB = await memoryMonitor.freeSpaceMB() + return freeMB >= Double(Settings.minFreeSpaceMB) } } diff --git a/Accelerometer/Settings/Settings.swift b/Accelerometer/Settings/Settings.swift index c1f57f1..20ed8ee 100644 --- a/Accelerometer/Settings/Settings.swift +++ b/Accelerometer/Settings/Settings.swift @@ -17,6 +17,8 @@ class Settings: ObservableObject { static let measurementsDisplayRoundPlaces = 3 + static let minFreeSpaceMB = 500 + private static let initialUpdateInterval = 0.5 var exportDateFormat: ExportDateFormat { From f9ecd820fafb30d338efa15500443f0282bea9e1 Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Sun, 21 Sep 2025 17:35:07 +0300 Subject: [PATCH 17/40] Active recording duplicate fix --- Accelerometer/Recordings/Recorder.swift | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Accelerometer/Recordings/Recorder.swift b/Accelerometer/Recordings/Recorder.swift index fb6f629..6f752a9 100644 --- a/Accelerometer/Recordings/Recorder.swift +++ b/Accelerometer/Recordings/Recorder.swift @@ -143,12 +143,7 @@ class Recorder: ObservableObject { private func refreshRecordings() async { await repository.update() let stored = await repository.recordings - var list = stored - if let current = activeRecording { - list.insert(current, at: 0) - } - - await MainActor.run { recordings = list } + await MainActor.run { recordings = stored } } private func hasEnoughMemory() async -> Bool { From 20c0fe87e2413c15970dc9b18e47f0c9fcc5b57d Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Mon, 22 Sep 2025 14:30:10 +0300 Subject: [PATCH 18/40] RecordingsView memory handling --- Accelerometer/Recordings/Recorder.swift | 38 +++++++++++++------ Accelerometer/Recordings/RecordingsView.swift | 10 +++-- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/Accelerometer/Recordings/Recorder.swift b/Accelerometer/Recordings/Recorder.swift index 6f752a9..78c9e3d 100644 --- a/Accelerometer/Recordings/Recorder.swift +++ b/Accelerometer/Recordings/Recorder.swift @@ -10,7 +10,12 @@ 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 recordings: [Recording] = [] @Published private(set) var activeRecording: Recording? = nil @@ -37,7 +42,7 @@ class Recorder: ObservableObject { func record(measurements types: Set) { Task { - if await !hasEnoughMemory() { return } + if !hasEnoughMemory { return } await MainActor.run { if disableIdleTimer { @@ -110,7 +115,7 @@ class Recorder: ObservableObject { private func appendEntry(for type: MeasurementType) { Task { - if await !hasEnoughMemory() { return } + if !hasEnoughMemory { return } guard let axes = measurer.observableAxes[type]?.axes, var current = activeRecording @@ -130,24 +135,35 @@ class Recorder: ObservableObject { } } + private func refreshRecordings() async { + await repository.update() + let stored = await repository.recordings + await MainActor.run { recordings = stored } + } + private func watchFreeSpace() async { while true { - try? await Task.sleep(nanoseconds: 10_000_000_000) + await checkMemory() - if await !hasEnoughMemory() { - await MainActor.run { stopRecording() } - } + try? await Task.sleep( + nanoseconds: Self.memoryCheckCooldownNs + ) } } - private func refreshRecordings() async { - await repository.update() - let stored = await repository.recordings - await MainActor.run { recordings = stored } + private func checkMemory() async { + let hasEnoughMemory = await hasEnoughMemory() + if !hasEnoughMemory { + await MainActor.run { stopRecording() } + } + + await MainActor.run { self.hasEnoughMemory = hasEnoughMemory } } + private func hasEnoughMemory() async -> Bool { let freeMB = await memoryMonitor.freeSpaceMB() - return freeMB >= Double(Settings.minFreeSpaceMB) + let hasEnoughMemory = freeMB >= Double(Settings.minFreeSpaceMB) + return hasEnoughMemory } } diff --git a/Accelerometer/Recordings/RecordingsView.swift b/Accelerometer/Recordings/RecordingsView.swift index 022c34b..d7adff1 100644 --- a/Accelerometer/Recordings/RecordingsView.swift +++ b/Accelerometer/Recordings/RecordingsView.swift @@ -28,6 +28,8 @@ 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 } @@ -69,7 +71,9 @@ struct RecordingsView: View { // MARK: Views var startStopButton: some View { - Button( + let enabled = !isEditMode && recorder.hasEnoughMemory + + return Button( action: { if recorder.recordingInProgress { recorder.stopRecording() @@ -83,12 +87,12 @@ struct RecordingsView: View { ) .padding(13) .foregroundColor(Color.background) - .background(Color.accentColor) + .background(enabled ? Color.accentColor : Color.gray) .clipShape(Circle()) .font(.title2) } ) - .disabled(isEditMode) + .disabled(!enabled) } @ViewBuilder From 45b2e599653824bf002f63b48ab988c83864c432 Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Mon, 22 Sep 2025 14:41:05 +0300 Subject: [PATCH 19/40] New recording list style --- Accelerometer/Recordings/NewRecordingView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Accelerometer/Recordings/NewRecordingView.swift b/Accelerometer/Recordings/NewRecordingView.swift index 418ca1a..afb5f87 100644 --- a/Accelerometer/Recordings/NewRecordingView.swift +++ b/Accelerometer/Recordings/NewRecordingView.swift @@ -64,7 +64,7 @@ struct NewRecordingView: View { .tint(.accentColor) } } - .listStyle(.insetGrouped) + .listStyle(.plain) .animation(.easeInOut, value: measurementTypes) Button("Start Recording") { From 38b5b07904a7a14ed73240ae786995901bc2f5f7 Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Mon, 22 Sep 2025 15:24:50 +0300 Subject: [PATCH 20/40] MeasurementSummaryView memory handling, debug settings --- Accelerometer.xcodeproj/project.pbxproj | 8 +++- Accelerometer/AccelerometerApp.swift | 2 +- Accelerometer/ContentView.swift | 2 +- .../Measurements/MeasurementSummaryView.swift | 15 +++++-- Accelerometer/Recordings/Recorder.swift | 15 ++++++- .../Recordings/RecordingSummaryView.swift | 2 +- Accelerometer/Recordings/RecordingsView.swift | 2 +- .../Settings/AlwaysNotEnoughMemoryView.swift | 36 ++++++++++++++++ Accelerometer/Settings/RefreshRateView.swift | 4 +- Accelerometer/Settings/Settings.swift | 42 ++++++++++++++----- Accelerometer/Settings/SettingsView.swift | 9 +++- 11 files changed, 112 insertions(+), 25 deletions(-) create mode 100644 Accelerometer/Settings/AlwaysNotEnoughMemoryView.swift diff --git a/Accelerometer.xcodeproj/project.pbxproj b/Accelerometer.xcodeproj/project.pbxproj index ae7126c..b834abb 100644 --- a/Accelerometer.xcodeproj/project.pbxproj +++ b/Accelerometer.xcodeproj/project.pbxproj @@ -44,6 +44,7 @@ 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 */; }; @@ -135,6 +136,7 @@ 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 = ""; }; @@ -278,11 +280,12 @@ 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 */, ); path = Settings; sourceTree = ""; @@ -704,6 +707,7 @@ 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 */, 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 f00a391..b87866e 100644 --- a/Accelerometer/ContentView.swift +++ b/Accelerometer/ContentView.swift @@ -51,7 +51,7 @@ 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/Measurements/MeasurementSummaryView.swift b/Accelerometer/Measurements/MeasurementSummaryView.swift index 6369b33..15bd072 100644 --- a/Accelerometer/Measurements/MeasurementSummaryView.swift +++ b/Accelerometer/Measurements/MeasurementSummaryView.swift @@ -23,8 +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 enabled = !recorder.recordingInProgress && recorder.hasEnoughMemory + + let text = if !recorder.hasEnoughMemory { + "Not enought memory" + } else if recorder.recordingInProgress { + "Recording is already started" + } else { + "Start recording this value" + } return Button( action: { @@ -98,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/Recordings/Recorder.swift b/Accelerometer/Recordings/Recorder.swift index 78c9e3d..ee4d8d7 100644 --- a/Accelerometer/Recordings/Recorder.swift +++ b/Accelerometer/Recordings/Recorder.swift @@ -20,14 +20,18 @@ class Recorder: ObservableObject { @Published private(set) var activeRecording: Recording? = nil @ObservedObject private var measurer: Measurer + @ObservedObject private var settings: Settings + private let repository = RecordingsRepository() private let memoryMonitor = MemoryMonitor() private let disableIdleTimer = true private var subscriptions: [AnyCancellable] = [] - init(measurer: Measurer) { + init(measurer: Measurer, settings: Settings) { self.measurer = measurer + self.settings = settings + Task { await refreshRecordings() } @@ -152,7 +156,16 @@ class Recorder: ObservableObject { } 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() } } diff --git a/Accelerometer/Recordings/RecordingSummaryView.swift b/Accelerometer/Recordings/RecordingSummaryView.swift index f975f67..02872e7 100644 --- a/Accelerometer/Recordings/RecordingSummaryView.swift +++ b/Accelerometer/Recordings/RecordingSummaryView.swift @@ -117,7 +117,7 @@ struct RecordingView_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 let axes: TriangleAxes = { var axes = TriangleAxes.zero diff --git a/Accelerometer/Recordings/RecordingsView.swift b/Accelerometer/Recordings/RecordingsView.swift index d7adff1..4de22e8 100644 --- a/Accelerometer/Recordings/RecordingsView.swift +++ b/Accelerometer/Recordings/RecordingsView.swift @@ -218,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/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 20ed8ee..9ca5d19 100644 --- a/Accelerometer/Settings/Settings.swift +++ b/Accelerometer/Settings/Settings.swift @@ -21,6 +21,8 @@ class Settings: ObservableObject { private static let initialUpdateInterval = 0.5 + // MARK: - User options + var exportDateFormat: ExportDateFormat { get { let stringValue = UserDefaults.standard.string(forKey: Key.exportDateFormat.rawValue) @@ -36,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() @@ -67,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 { @@ -93,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..26adc42 100644 --- a/Accelerometer/Settings/SettingsView.swift +++ b/Accelerometer/Settings/SettingsView.swift @@ -25,12 +25,17 @@ struct SettingsView: View { ) { ExportDateFormatView() } + + #if (DEBUG) Section( header: Spacer(), - footer: Text("Enable animations") + footer: Text("Debug options") ) { AnimationsView() + AlwaysNotEnoughMemoryView() } + #endif + // Section(header: Text("Accelerometer"), footer: Text("")) { // Toggle("Remove gravity", isOn: .init(get: { measurer.removeGravity }, set: { measurer.removeGravity = $0 })) // } @@ -42,7 +47,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() From f9ceed25c9308145e79893648b1a9738d5c4036b Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Mon, 22 Sep 2025 19:11:20 +0300 Subject: [PATCH 21/40] Recordings metadata --- Accelerometer.xcodeproj/project.pbxproj | 8 ++--- .../Recordings/Database/RecordingRealm.swift | 35 +++++++++++++++---- .../Database/RecordingsRepository.swift | 12 +++---- Accelerometer/Recordings/Recorder.swift | 31 ++++++++-------- Accelerometer/Recordings/Recording.swift | 28 ++++++++------- .../Recordings/RecordingPreview.swift | 10 ++---- .../Recordings/RecordingProcessor.swift | 5 +-- Accelerometer/Recordings/RecordingsView.swift | 2 +- 8 files changed, 78 insertions(+), 53 deletions(-) diff --git a/Accelerometer.xcodeproj/project.pbxproj b/Accelerometer.xcodeproj/project.pbxproj index b834abb..7676f3c 100644 --- a/Accelerometer.xcodeproj/project.pbxproj +++ b/Accelerometer.xcodeproj/project.pbxproj @@ -399,15 +399,15 @@ isa = PBXGroup; children = ( 0B508CD9289C7EEB00326C6E /* Database */, + 0BE138F5289409820026D0DC /* Recorder.swift */, + 0BE138F1289400E70026D0DC /* Recording.swift */, + 0B1845502E7DA052004F5474 /* RecordingProcessor.swift */, + 0BDB67DB2AC9AC6200FA5C09 /* RecordingUtils.swift */, 0BE138ED2893FFA10026D0DC /* RecordingsView.swift */, 0BA63B712897FBCE0040B63D /* RecordingPreview.swift */, 0BBAD4AC2895564C00251262 /* RecordingSummaryView.swift */, 0B45FF952D8EC357009F5CDF /* RecordingMeasurementChartView.swift */, 0BA63B6B289598B40040B63D /* NewRecordingView.swift */, - 0BE138F1289400E70026D0DC /* Recording.swift */, - 0B1845502E7DA052004F5474 /* RecordingProcessor.swift */, - 0BE138F5289409820026D0DC /* Recorder.swift */, - 0BDB67DB2AC9AC6200FA5C09 /* RecordingUtils.swift */, ); path = Recordings; sourceTree = ""; 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 4164d9e..d7bcdc1 100644 --- a/Accelerometer/Recordings/Database/RecordingsRepository.swift +++ b/Accelerometer/Recordings/Database/RecordingsRepository.swift @@ -13,7 +13,7 @@ actor RecordingsRepository { configuration: .defaultConfiguration ) - private(set) var recordings: [Recording] = [] + private(set) var recordingsMetadata: [Recording] = [] func save(_ items: [Recording]) { let realms = items.map { RecordingRealm(recording: $0) } @@ -33,11 +33,11 @@ actor RecordingsRepository { databaseManager.delete(toDelete) } - func update() { - let realmRecordings: Results = databaseManager.read() - let reversed = Array(realmRecordings) - .map { $0.recording } + func updateMetadata() { + let realmRecs: Results = databaseManager.read() + let metas = realmRecs + .map(\.recordingMetadata) .reversed() - recordings = Array(reversed) + recordingsMetadata = Array(metas) } } diff --git a/Accelerometer/Recordings/Recorder.swift b/Accelerometer/Recordings/Recorder.swift index ee4d8d7..3022e72 100644 --- a/Accelerometer/Recordings/Recorder.swift +++ b/Accelerometer/Recordings/Recorder.swift @@ -16,7 +16,7 @@ class Recorder: ObservableObject { @Published var isInEditMode = false @Published var hasEnoughMemory = true - @Published private(set) var recordings: [Recording] = [] + @Published private(set) var recordingsMetadata: [Recording] = [] @Published private(set) var activeRecording: Recording? = nil @ObservedObject private var measurer: Measurer @@ -75,17 +75,16 @@ class Recorder: ObservableObject { } } - guard let current = activeRecording else { return } + guard var activeRecording = activeRecording else { return } + activeRecording.state = .completed + activeRecording.end = Date.now - var completed = current - completed.state = .completed - - await repository.save([completed]) - await repository.update() + await repository.save([activeRecording]) + await repository.updateMetadata() await refreshRecordings() await MainActor.run { - activeRecording = nil + self.activeRecording = nil subscriptions.removeAll() objectWillChange.send() } @@ -95,7 +94,7 @@ class Recorder: ObservableObject { func delete(recordingID: String) { Task { await repository.delete(recordingID: recordingID) - await repository.update() + await repository.updateMetadata() await refreshRecordings() } } @@ -103,7 +102,7 @@ class Recorder: ObservableObject { func delete(recordingIDs: [String]) { Task { await repository.delete(recordingIDs: recordingIDs) - await repository.update() + await repository.updateMetadata() await refreshRecordings() } } @@ -130,7 +129,11 @@ class Recorder: ObservableObject { date: Date(), axes: axes ) - current.entries.append(entry) + + if current.entries == nil { + current.entries = [] + } + current.entries?.append(entry) await MainActor.run { activeRecording = current @@ -140,9 +143,9 @@ class Recorder: ObservableObject { } private func refreshRecordings() async { - await repository.update() - let stored = await repository.recordings - await MainActor.run { recordings = stored } + await repository.updateMetadata() + let stored = await repository.recordingsMetadata + await MainActor.run { recordingsMetadata = stored } } private func watchFreeSpace() async { diff --git a/Accelerometer/Recordings/Recording.swift b/Accelerometer/Recordings/Recording.swift index 5f53599..c96f225 100644 --- a/Accelerometer/Recordings/Recording.swift +++ b/Accelerometer/Recordings/Recording.swift @@ -10,22 +10,16 @@ import Foundation 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 } @@ -39,9 +33,17 @@ 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 diff --git a/Accelerometer/Recordings/RecordingPreview.swift b/Accelerometer/Recordings/RecordingPreview.swift index fe2e7af..695f520 100644 --- a/Accelerometer/Recordings/RecordingPreview.swift +++ b/Accelerometer/Recordings/RecordingPreview.swift @@ -12,13 +12,9 @@ struct RecordingPreview: View { let recording: Recording var startString: String { - let startString: String - if let start = recording.start { - startString = DateFormatter.Recordings.string(from: start) - } else { - startString = "???" - } - return startString + DateFormatter.Recordings.string( + from: recording.start + ) } var durationString: String { diff --git a/Accelerometer/Recordings/RecordingProcessor.swift b/Accelerometer/Recordings/RecordingProcessor.swift index 178f835..e199d24 100644 --- a/Accelerometer/Recordings/RecordingProcessor.swift +++ b/Accelerometer/Recordings/RecordingProcessor.swift @@ -18,8 +18,9 @@ actor RecordingProcessor { throw ProcessingError.invalidType } + // TODO load entries for csv let header = RecordingUtils.stringsHeader(of: type) - let rows = recording.entries + let rows = (recording.entries ?? []) .filter { $0.measurementType == type } .map { $0.getCsvString(dateFormat: dateFormat) } let csvText = ([header] + rows).joined(separator: RecordingUtils.rowSeparator) @@ -37,7 +38,7 @@ actor RecordingProcessor { for type: MeasurementType, maxCount: Int = 50 ) async -> [Recording.Entry] { - let filtered = recording.entries + let filtered = (recording.entries ?? []) .filter { $0.measurementType == type } guard filtered.count > maxCount else { return filtered } diff --git a/Accelerometer/Recordings/RecordingsView.swift b/Accelerometer/Recordings/RecordingsView.swift index 4de22e8..17e0a1f 100644 --- a/Accelerometer/Recordings/RecordingsView.swift +++ b/Accelerometer/Recordings/RecordingsView.swift @@ -36,7 +36,7 @@ struct RecordingsView: View { } var lastRecordings: [Recording] { - var recordings = recorder.recordings + var recordings = recorder.recordingsMetadata if let activeRecording = recorder.activeRecording { recordings.removeAll { $0.id == activeRecording.id } } From cca4e2df8605d52e2a580bef4d799c3ae6afb76c Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Mon, 22 Sep 2025 19:45:54 +0300 Subject: [PATCH 22/40] Recordings cache --- .../Database/RecordingsRepository.swift | 41 +++++- Accelerometer/Recordings/Recorder.swift | 22 +++ .../Recordings/RecordingSummaryView.swift | 134 +++++++++++------- Accelerometer/Recordings/RecordingsView.swift | 2 +- 4 files changed, 147 insertions(+), 52 deletions(-) diff --git a/Accelerometer/Recordings/Database/RecordingsRepository.swift b/Accelerometer/Recordings/Database/RecordingsRepository.swift index d7bcdc1..710a6b5 100644 --- a/Accelerometer/Recordings/Database/RecordingsRepository.swift +++ b/Accelerometer/Recordings/Database/RecordingsRepository.swift @@ -14,10 +14,15 @@ actor RecordingsRepository { ) private(set) var recordingsMetadata: [Recording] = [] - + private var recordings: [String: Recording] = [:] + func save(_ items: [Recording]) { let realms = items.map { RecordingRealm(recording: $0) } databaseManager.write(realms) + + items.forEach { + recordings.removeValue(forKey: $0.id) + } } func delete(recordingID: String) { @@ -25,12 +30,18 @@ actor RecordingsRepository { if let recording = all.first(where: { $0.id == recordingID }) { databaseManager.delete([recording]) } + + recordings.removeValue(forKey: recordingID) } 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 { + recordings.removeValue(forKey: $0) + } } func updateMetadata() { @@ -40,4 +51,32 @@ actor RecordingsRepository { .reversed() recordingsMetadata = Array(metas) } + + func loadFullRecording(id: String) -> Recording? { + if let cached = recordings[id] { + return cached + } + + let results: Results = databaseManager.read() + guard let realm = results.first(where: { $0.id == id }) else { + return nil + } + + let recording = realm.recording + recordings[id] = recording + + return recording + } + + func clearCache(for id: String) { + recordings.removeValue(forKey: id) + } + + func clearAllCache() { + recordings.removeAll() + } + + var cacheSize: Int { + recordings.count + } } diff --git a/Accelerometer/Recordings/Recorder.swift b/Accelerometer/Recordings/Recorder.swift index 3022e72..ac83553 100644 --- a/Accelerometer/Recordings/Recorder.swift +++ b/Accelerometer/Recordings/Recorder.swift @@ -44,6 +44,8 @@ class Recorder: ObservableObject { activeRecording != nil } + // MARK: - Recordings management + func record(measurements types: Set) { Task { if !hasEnoughMemory { return } @@ -148,6 +150,26 @@ class Recorder: ObservableObject { await MainActor.run { recordingsMetadata = stored } } + // MARK: - Recordings cache menagement + + func loadFullRecording(id: String) async -> Recording? { + return await repository.loadFullRecording(id: id) + } + + func clearRecordingCache(id: String) async { + await repository.clearCache(for: id) + } + + func clearAllRecordingCache() async { + await repository.clearAllCache() + } + + func getCacheSize() async -> Int { + return await repository.cacheSize + } + + // MARK: - Memory control + private func watchFreeSpace() async { while true { await checkMemory() diff --git a/Accelerometer/Recordings/RecordingSummaryView.swift b/Accelerometer/Recordings/RecordingSummaryView.swift index 02872e7..57503a9 100644 --- a/Accelerometer/Recordings/RecordingSummaryView.swift +++ b/Accelerometer/Recordings/RecordingSummaryView.swift @@ -10,30 +10,40 @@ import UniformTypeIdentifiers struct RecordingSummaryView: View { - let recording: Recording + let recordingMetadata: Recording @EnvironmentObject private var settings: Settings @EnvironmentObject private var recorder: Recorder @Environment(\.presentationMode) private var presentationMode - + + @State private var fullRecording: Recording? + @State private var isLoading = false + @State private var isPresentingExporter = false - @State private var exportURL: URL? = nil + @State private var exportURL: URL? @State private var exportLoading = false private let processor = RecordingProcessor() var body: some View { + let recording = fullRecording ?? recordingMetadata List { Section("Info") { RecordingPreview(recording: recording) } Section("Measurements") { - ForEach(recording.sortedMeasurementTypes, id: \.self) { type in - RecordingMeasurementChartView( - recording: recording, - measurementType: type - ) { - export(type: type) + if isLoading { + ProgressView() + } else { + ForEach( + recording.sortedMeasurementTypes, id: \.self + ) { type in + RecordingMeasurementChartView( + recording: recording, + measurementType: type + ) { + export(type: type) + } } } } @@ -62,15 +72,27 @@ struct RecordingSummaryView: View { contentType: UTType.plainText, defaultFilename: defaultFilename() ) { _ in } + .onDisappear { + Task { + await recorder.clearRecordingCache(id: recordingMetadata.id) + } + } + .task { + if recordingMetadata.entries == nil { + isLoading = true + fullRecording = await recorder.loadFullRecording(id: recordingMetadata.id) + isLoading = false + } + } } private func deleteRecording() { - recorder.delete(recordingID: recording.id) + recorder.delete(recordingID: recordingMetadata.id) presentationMode.wrappedValue.dismiss() } private func export(type: MeasurementType) { - guard !exportLoading else { return } + guard !exportLoading, let recording = fullRecording else { return } exportLoading = true Task { defer { exportLoading = false } @@ -80,7 +102,6 @@ struct RecordingSummaryView: View { for: type, dateFormat: settings.exportDateFormat ) - exportURL = url isPresentingExporter = true } catch { @@ -90,10 +111,30 @@ struct RecordingSummaryView: View { } private func defaultFilename() -> String { - (exportURL?.lastPathComponent ?? recording.id) + ".csv" + (exportURL?.lastPathComponent ?? recordingMetadata.id) + ".csv" } } +// Debug-only initializer for previews +#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 + struct FileDocumentWrapper: FileDocument { @@ -112,52 +153,45 @@ struct FileDocumentWrapper: FileDocument { } } - -struct RecordingView_Previews: PreviewProvider { +struct RecordingSummaryView_Previews: PreviewProvider { static let settings = Settings() - static let measurer = Measurer(settings: settings) - static let recorder = Recorder(measurer: measurer, settings: settings) + 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 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 { - RecordingSummaryView( - recording: Recording( - entries: [ - .init( - measurementType: .acceleration, - date: .init(), - axes: axes - ) - ], - state: .completed, - measurementTypes: [.acceleration] - ) - ) + 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 17e0a1f..2fe3fe4 100644 --- a/Accelerometer/Recordings/RecordingsView.swift +++ b/Accelerometer/Recordings/RecordingsView.swift @@ -101,7 +101,7 @@ struct RecordingsView: View { RecordingPreview(recording: recording) } else { NavigationLink { - RecordingSummaryView(recording: recording) + RecordingSummaryView(recordingMetadata: recording) } label: { RecordingPreview(recording: recording) } From f25c838ce77873dabc074ac56c9b776c6e2b7e43 Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Sun, 28 Sep 2025 16:49:55 +0300 Subject: [PATCH 23/40] ActiveRecordingManager created --- Accelerometer.xcodeproj/project.pbxproj | 4 ++ .../Recordings/ActiveRecordingManager.swift | 43 +++++++++++++++ Accelerometer/Recordings/Recorder.swift | 54 +++++++++---------- 3 files changed, 71 insertions(+), 30 deletions(-) create mode 100644 Accelerometer/Recordings/ActiveRecordingManager.swift diff --git a/Accelerometer.xcodeproj/project.pbxproj b/Accelerometer.xcodeproj/project.pbxproj index 7676f3c..6a3e4dd 100644 --- a/Accelerometer.xcodeproj/project.pbxproj +++ b/Accelerometer.xcodeproj/project.pbxproj @@ -35,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 */; }; + 0B3B963D2E85DDB50088CC1E /* ActiveRecordingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3B963C2E85DDAD0088CC1E /* ActiveRecordingManager.swift */; }; 0B45FF962D8EC361009F5CDF /* RecordingMeasurementChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B45FF952D8EC357009F5CDF /* RecordingMeasurementChartView.swift */; }; 0B4BE8532D74BB420065D938 /* AnimationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B4BE8522D74BB2F0065D938 /* AnimationsView.swift */; }; 0B4CC363287C30FF000F0C94 /* DiagramAxesNamesStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B4CC362287C30FF000F0C94 /* DiagramAxesNamesStyle.swift */; }; @@ -127,6 +128,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 = ""; }; + 0B3B963C2E85DDAD0088CC1E /* ActiveRecordingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveRecordingManager.swift; sourceTree = ""; }; 0B45FF952D8EC357009F5CDF /* RecordingMeasurementChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingMeasurementChartView.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 = ""; }; @@ -400,6 +402,7 @@ children = ( 0B508CD9289C7EEB00326C6E /* Database */, 0BE138F5289409820026D0DC /* Recorder.swift */, + 0B3B963C2E85DDAD0088CC1E /* ActiveRecordingManager.swift */, 0BE138F1289400E70026D0DC /* Recording.swift */, 0B1845502E7DA052004F5474 /* RecordingProcessor.swift */, 0BDB67DB2AC9AC6200FA5C09 /* RecordingUtils.swift */, @@ -691,6 +694,7 @@ 0B24EC45286247AD00AD3EF1 /* String.swift in Sources */, 0BDED2D828983A4D00CAEAC3 /* DateComponents.swift in Sources */, 0B24EC3A28622B7100AD3EF1 /* SettingsView.swift in Sources */, + 0B3B963D2E85DDB50088CC1E /* ActiveRecordingManager.swift in Sources */, 0B36DFCA289A9E85007130D9 /* Settings.swift in Sources */, 0BA5B7BC2AD319AC004F1808 /* AttitudeAxesSummaryView.swift in Sources */, 0B24EC3428621E1C00AD3EF1 /* AppDelegate.swift in Sources */, diff --git a/Accelerometer/Recordings/ActiveRecordingManager.swift b/Accelerometer/Recordings/ActiveRecordingManager.swift new file mode 100644 index 0000000..1128388 --- /dev/null +++ b/Accelerometer/Recordings/ActiveRecordingManager.swift @@ -0,0 +1,43 @@ +// +// ActiveRecordingManager.swift +// Accelerometer +// +// Created by Andrey Pantyuhin on 25.09.2025. +// + +import Foundation + +// TODO: refactor this and save active recorgding in persist memory +actor ActiveRecordingManager { + private(set) var activeRecording: Recording? + + func startRecording(_ recording: Recording) { + activeRecording = recording + } + + func appendEntry(_ entry: Recording.Entry) -> Recording? { + guard var current = activeRecording else { + return nil + } + + if current.entries == nil { + current.entries = [] + } + current.entries?.append(entry) + activeRecording = current + + return current + } + + func stopRecording() -> Recording? { + guard var current = activeRecording else { + return nil + } + + current.state = .completed + current.end = Date.now + activeRecording = nil + + return current + } +} diff --git a/Accelerometer/Recordings/Recorder.swift b/Accelerometer/Recordings/Recorder.swift index ac83553..dc3830e 100644 --- a/Accelerometer/Recordings/Recorder.swift +++ b/Accelerometer/Recordings/Recorder.swift @@ -24,6 +24,7 @@ class Recorder: ObservableObject { private let repository = RecordingsRepository() private let memoryMonitor = MemoryMonitor() + private let activeRecordingManager = ActiveRecordingManager() private let disableIdleTimer = true private var subscriptions: [AnyCancellable] = [] @@ -48,20 +49,21 @@ class Recorder: ObservableObject { func record(measurements types: Set) { Task { - if !hasEnoughMemory { return } + guard hasEnoughMemory, !recordingInProgress, !types.isEmpty else { return } + + let newRecording = Recording( + entries: [], + state: .inProgress, + measurementTypes: types + ) + + await activeRecordingManager.startRecording(newRecording) await MainActor.run { if disableIdleTimer { UIApplication.shared.isIdleTimerDisabled = true } - - guard !recordingInProgress, !types.isEmpty else { return } - activeRecording = Recording( - entries: [], - state: .inProgress, - measurementTypes: types - ) - + activeRecording = newRecording types.forEach(subscribeForChanges) objectWillChange.send() @@ -71,22 +73,19 @@ class Recorder: ObservableObject { func stopRecording() { Task { + let completedRecording = await activeRecordingManager.stopRecording() + + if let recording = completedRecording { + await repository.save([recording]) + await repository.updateMetadata() + await refreshRecordings() + } + await MainActor.run { if disableIdleTimer { UIApplication.shared.isIdleTimerDisabled = false } - } - - guard var activeRecording = activeRecording else { return } - activeRecording.state = .completed - activeRecording.end = Date.now - - await repository.save([activeRecording]) - await repository.updateMetadata() - await refreshRecordings() - - await MainActor.run { - self.activeRecording = nil + activeRecording = nil subscriptions.removeAll() objectWillChange.send() } @@ -120,11 +119,9 @@ class Recorder: ObservableObject { private func appendEntry(for type: MeasurementType) { Task { - if !hasEnoughMemory { return } + guard hasEnoughMemory else { return } - guard let axes = measurer.observableAxes[type]?.axes, - var current = activeRecording - else { return } + guard let axes = measurer.observableAxes[type]?.axes else { return } let entry = Recording.Entry( measurementType: type, @@ -132,13 +129,10 @@ class Recorder: ObservableObject { axes: axes ) - if current.entries == nil { - current.entries = [] - } - current.entries?.append(entry) + let updatedRecording = await activeRecordingManager.appendEntry(entry) await MainActor.run { - activeRecording = current + activeRecording = updatedRecording objectWillChange.send() } } From bbe08d7890e883b42df680255dbc426d0dfaa8cd Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Sun, 28 Sep 2025 17:01:25 +0300 Subject: [PATCH 24/40] Debug rec info --- Accelerometer/Recordings/Recorder.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Accelerometer/Recordings/Recorder.swift b/Accelerometer/Recordings/Recorder.swift index dc3830e..2440421 100644 --- a/Accelerometer/Recordings/Recorder.swift +++ b/Accelerometer/Recordings/Recorder.swift @@ -76,6 +76,18 @@ class Recorder: ObservableObject { let completedRecording = await activeRecordingManager.stopRecording() if let recording = completedRecording { + + #if (DEBUG) + print("--- Recording stopped ---") + for measurementType in recording.sortedMeasurementTypes { + let count = recording.entries?.filter { $0.measurementType == measurementType }.count ?? 0 + print("\(measurementType.name): \(count) entries") + } + let totalCount = recording.entries?.count ?? 0 + print("Total entries: \(totalCount)") + print("-------------------------") + #endif + await repository.save([recording]) await repository.updateMetadata() await refreshRecordings() From e46c7ea776ccf4a3f4881d528f1c1833e8638383 Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Mon, 29 Sep 2025 12:08:29 +0300 Subject: [PATCH 25/40] Revert "ActiveRecordingManager created" This reverts commit f25c838ce77873dabc074ac56c9b776c6e2b7e43. --- Accelerometer.xcodeproj/project.pbxproj | 4 -- .../Recordings/ActiveRecordingManager.swift | 43 ------------ Accelerometer/Recordings/Recorder.swift | 66 +++++++++---------- 3 files changed, 30 insertions(+), 83 deletions(-) delete mode 100644 Accelerometer/Recordings/ActiveRecordingManager.swift diff --git a/Accelerometer.xcodeproj/project.pbxproj b/Accelerometer.xcodeproj/project.pbxproj index 6a3e4dd..7676f3c 100644 --- a/Accelerometer.xcodeproj/project.pbxproj +++ b/Accelerometer.xcodeproj/project.pbxproj @@ -35,7 +35,6 @@ 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 */; }; - 0B3B963D2E85DDB50088CC1E /* ActiveRecordingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3B963C2E85DDAD0088CC1E /* ActiveRecordingManager.swift */; }; 0B45FF962D8EC361009F5CDF /* RecordingMeasurementChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B45FF952D8EC357009F5CDF /* RecordingMeasurementChartView.swift */; }; 0B4BE8532D74BB420065D938 /* AnimationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B4BE8522D74BB2F0065D938 /* AnimationsView.swift */; }; 0B4CC363287C30FF000F0C94 /* DiagramAxesNamesStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B4CC362287C30FF000F0C94 /* DiagramAxesNamesStyle.swift */; }; @@ -128,7 +127,6 @@ 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 = ""; }; - 0B3B963C2E85DDAD0088CC1E /* ActiveRecordingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveRecordingManager.swift; sourceTree = ""; }; 0B45FF952D8EC357009F5CDF /* RecordingMeasurementChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingMeasurementChartView.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 = ""; }; @@ -402,7 +400,6 @@ children = ( 0B508CD9289C7EEB00326C6E /* Database */, 0BE138F5289409820026D0DC /* Recorder.swift */, - 0B3B963C2E85DDAD0088CC1E /* ActiveRecordingManager.swift */, 0BE138F1289400E70026D0DC /* Recording.swift */, 0B1845502E7DA052004F5474 /* RecordingProcessor.swift */, 0BDB67DB2AC9AC6200FA5C09 /* RecordingUtils.swift */, @@ -694,7 +691,6 @@ 0B24EC45286247AD00AD3EF1 /* String.swift in Sources */, 0BDED2D828983A4D00CAEAC3 /* DateComponents.swift in Sources */, 0B24EC3A28622B7100AD3EF1 /* SettingsView.swift in Sources */, - 0B3B963D2E85DDB50088CC1E /* ActiveRecordingManager.swift in Sources */, 0B36DFCA289A9E85007130D9 /* Settings.swift in Sources */, 0BA5B7BC2AD319AC004F1808 /* AttitudeAxesSummaryView.swift in Sources */, 0B24EC3428621E1C00AD3EF1 /* AppDelegate.swift in Sources */, diff --git a/Accelerometer/Recordings/ActiveRecordingManager.swift b/Accelerometer/Recordings/ActiveRecordingManager.swift deleted file mode 100644 index 1128388..0000000 --- a/Accelerometer/Recordings/ActiveRecordingManager.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// ActiveRecordingManager.swift -// Accelerometer -// -// Created by Andrey Pantyuhin on 25.09.2025. -// - -import Foundation - -// TODO: refactor this and save active recorgding in persist memory -actor ActiveRecordingManager { - private(set) var activeRecording: Recording? - - func startRecording(_ recording: Recording) { - activeRecording = recording - } - - func appendEntry(_ entry: Recording.Entry) -> Recording? { - guard var current = activeRecording else { - return nil - } - - if current.entries == nil { - current.entries = [] - } - current.entries?.append(entry) - activeRecording = current - - return current - } - - func stopRecording() -> Recording? { - guard var current = activeRecording else { - return nil - } - - current.state = .completed - current.end = Date.now - activeRecording = nil - - return current - } -} diff --git a/Accelerometer/Recordings/Recorder.swift b/Accelerometer/Recordings/Recorder.swift index 2440421..ac83553 100644 --- a/Accelerometer/Recordings/Recorder.swift +++ b/Accelerometer/Recordings/Recorder.swift @@ -24,7 +24,6 @@ class Recorder: ObservableObject { private let repository = RecordingsRepository() private let memoryMonitor = MemoryMonitor() - private let activeRecordingManager = ActiveRecordingManager() private let disableIdleTimer = true private var subscriptions: [AnyCancellable] = [] @@ -49,21 +48,20 @@ class Recorder: ObservableObject { func record(measurements types: Set) { Task { - guard hasEnoughMemory, !recordingInProgress, !types.isEmpty else { return } - - let newRecording = Recording( - entries: [], - state: .inProgress, - measurementTypes: types - ) - - await activeRecordingManager.startRecording(newRecording) + if !hasEnoughMemory { return } await MainActor.run { if disableIdleTimer { UIApplication.shared.isIdleTimerDisabled = true } - activeRecording = newRecording + + guard !recordingInProgress, !types.isEmpty else { return } + activeRecording = Recording( + entries: [], + state: .inProgress, + measurementTypes: types + ) + types.forEach(subscribeForChanges) objectWillChange.send() @@ -73,31 +71,22 @@ class Recorder: ObservableObject { func stopRecording() { Task { - let completedRecording = await activeRecordingManager.stopRecording() - - if let recording = completedRecording { - - #if (DEBUG) - print("--- Recording stopped ---") - for measurementType in recording.sortedMeasurementTypes { - let count = recording.entries?.filter { $0.measurementType == measurementType }.count ?? 0 - print("\(measurementType.name): \(count) entries") - } - let totalCount = recording.entries?.count ?? 0 - print("Total entries: \(totalCount)") - print("-------------------------") - #endif - - await repository.save([recording]) - await repository.updateMetadata() - await refreshRecordings() - } - await MainActor.run { if disableIdleTimer { UIApplication.shared.isIdleTimerDisabled = false } - activeRecording = nil + } + + guard var activeRecording = activeRecording else { return } + activeRecording.state = .completed + activeRecording.end = Date.now + + await repository.save([activeRecording]) + await repository.updateMetadata() + await refreshRecordings() + + await MainActor.run { + self.activeRecording = nil subscriptions.removeAll() objectWillChange.send() } @@ -131,9 +120,11 @@ class Recorder: ObservableObject { private func appendEntry(for type: MeasurementType) { Task { - guard hasEnoughMemory else { return } + if !hasEnoughMemory { return } - guard let axes = measurer.observableAxes[type]?.axes else { return } + guard let axes = measurer.observableAxes[type]?.axes, + var current = activeRecording + else { return } let entry = Recording.Entry( measurementType: type, @@ -141,10 +132,13 @@ class Recorder: ObservableObject { axes: axes ) - let updatedRecording = await activeRecordingManager.appendEntry(entry) + if current.entries == nil { + current.entries = [] + } + current.entries?.append(entry) await MainActor.run { - activeRecording = updatedRecording + activeRecording = current objectWillChange.send() } } From 85c12b343adf4778d89c85d33e6b478a7c38bd1c Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Mon, 29 Sep 2025 13:14:28 +0300 Subject: [PATCH 26/40] Store active recording in deque --- Accelerometer.xcodeproj/project.pbxproj | 23 +++++++++ .../xcshareddata/swiftpm/Package.resolved | 15 ++++++ Accelerometer/Recordings/Recorder.swift | 51 +++++++++++-------- 3 files changed, 67 insertions(+), 22 deletions(-) create mode 100644 Accelerometer.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/Accelerometer.xcodeproj/project.pbxproj b/Accelerometer.xcodeproj/project.pbxproj index 7676f3c..b429f93 100644 --- a/Accelerometer.xcodeproj/project.pbxproj +++ b/Accelerometer.xcodeproj/project.pbxproj @@ -48,6 +48,7 @@ 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 */; }; + 0B96B73F2E8A91510007E730 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 0B96B73E2E8A91510007E730 /* DequeModule */; }; 0B9BDC132899A6F900842161 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B9BDC122899A6F900842161 /* Array.swift */; }; 0BA5B7BC2AD319AC004F1808 /* AttitudeAxesSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BA5B7BB2AD319AC004F1808 /* AttitudeAxesSummaryView.swift */; }; 0BA619CF2AADC9E7005DABD3 /* ObservableAxesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BA619CE2AADC9E7005DABD3 /* ObservableAxesTests.swift */; }; @@ -184,6 +185,7 @@ buildActionMask = 2147483647; files = ( A844F53C6B72222DA87BA782 /* Pods_Accelerometer.framework in Frameworks */, + 0B96B73F2E8A91510007E730 /* DequeModule in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -454,6 +456,7 @@ ); name = Accelerometer; packageProductDependencies = ( + 0B96B73E2E8A91510007E730 /* DequeModule */, ); productName = Accelerometer; productReference = 0B24EC0428621BBE00AD3EF1 /* Acc.elerometer.app */; @@ -531,6 +534,7 @@ ); mainGroup = 0B24EBFB28621BBE00AD3EF1; packageReferences = ( + 0B96B73D2E8A91510007E730 /* XCRemoteSwiftPackageReference "swift-collections" */, ); productRefGroup = 0B24EC0528621BBE00AD3EF1 /* Products */; projectDirPath = ""; @@ -1085,6 +1089,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 0B96B73D2E8A91510007E730 /* XCRemoteSwiftPackageReference "swift-collections" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-collections.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.2.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 0B96B73E2E8A91510007E730 /* DequeModule */ = { + isa = XCSwiftPackageProductDependency; + package = 0B96B73D2E8A91510007E730 /* XCRemoteSwiftPackageReference "swift-collections" */; + productName = DequeModule; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 0B24EBFC28621BBE00AD3EF1 /* Project object */; } diff --git a/Accelerometer.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Accelerometer.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..a95f96a --- /dev/null +++ b/Accelerometer.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "51f90653b2c9f9f7064c0d52159b40bf7d222e5f314be23e62fe28520fec03db", + "pins" : [ + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341", + "version" : "1.2.1" + } + } + ], + "version" : 3 +} diff --git a/Accelerometer/Recordings/Recorder.swift b/Accelerometer/Recordings/Recorder.swift index ac83553..1ad1e64 100644 --- a/Accelerometer/Recordings/Recorder.swift +++ b/Accelerometer/Recordings/Recorder.swift @@ -5,6 +5,7 @@ // Created by Andrey on 29.07.2022. // +import DequeModule import Combine import SwiftUI @@ -18,6 +19,7 @@ class Recorder: ObservableObject { @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 @@ -61,6 +63,7 @@ class Recorder: ObservableObject { state: .inProgress, measurementTypes: types ) + activeRecordingEntries = [] types.forEach(subscribeForChanges) @@ -80,6 +83,20 @@ class Recorder: ObservableObject { 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() @@ -119,29 +136,19 @@ class Recorder: ObservableObject { } private func appendEntry(for type: MeasurementType) { - Task { - if !hasEnoughMemory { return } - - guard let axes = measurer.observableAxes[type]?.axes, - var current = activeRecording - else { return } - - let entry = Recording.Entry( + guard + let axes = measurer.observableAxes[type]?.axes, + activeRecording != nil, + hasEnoughMemory + else { return } + + activeRecordingEntries.append( + Recording.Entry( measurementType: type, date: Date(), axes: axes ) - - if current.entries == nil { - current.entries = [] - } - current.entries?.append(entry) - - await MainActor.run { - activeRecording = current - objectWillChange.send() - } - } + ) } private func refreshRecordings() async { @@ -181,15 +188,15 @@ class Recorder: ObservableObject { } private func checkMemory() async { - #if (DEBUG) +#if (DEBUG) let hasEnoughMemory = if settings.alwaysNotEnoughMemory { false } else { await hasEnoughMemory() } - #else +#else let hasEnoughMemory = await hasEnoughMemory() - #endif +#endif if !hasEnoughMemory { await MainActor.run { stopRecording() } From d843a46e6fd54ef917621ad204d3262675ff48ee Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Mon, 29 Sep 2025 14:29:17 +0300 Subject: [PATCH 27/40] Active recording timer --- .../Recordings/RecordingPreview.swift | 45 ++++++++++++++----- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/Accelerometer/Recordings/RecordingPreview.swift b/Accelerometer/Recordings/RecordingPreview.swift index 695f520..cfd891c 100644 --- a/Accelerometer/Recordings/RecordingPreview.swift +++ b/Accelerometer/Recordings/RecordingPreview.swift @@ -6,32 +6,36 @@ // import SwiftUI +import Combine struct RecordingPreview: View { let recording: Recording + + @State private var timerPublisher = Timer.publish( + every: 1, on: .main, in: .common + ) + @State private var timerCancellable: Cancellable? - var startString: String { - DateFormatter.Recordings.string( - from: recording.start - ) - } + @State private var durationStringState: String = "" - var durationString: String { - let durationString: String - if let duration = recording.duration { - durationString = duration.durationString + private var durationString: String { + return if let duration = recording.duration { + 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) + Text("Duration: " + durationStringState) .padding([.bottom], 5) Text("Measurements:") ForEach(Array(recording.sortedMeasurementTypes).sorted(by: { lhs, rhs in @@ -43,6 +47,23 @@ struct RecordingPreview: View { } } } + .onAppear { + durationStringState = durationString + updateTimer() + } + .onChange(of: recording.state) { + updateTimer() + } + .onReceive(timerPublisher) { _ in + durationStringState = durationString + } + } + + private func updateTimer() { + timerCancellable?.cancel() + if recording.state == .inProgress { + timerCancellable = timerPublisher.connect() + } } } From d99f80c52b71bdcaf5e5d01e6df961e315f0b754 Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Mon, 29 Sep 2025 15:36:21 +0300 Subject: [PATCH 28/40] Refactor cache --- .../Database/RecordingsRepository.swift | 39 ++++++------------- Accelerometer/Recordings/Recorder.swift | 24 +++--------- .../Recordings/RecordingSummaryView.swift | 31 +++++++-------- 3 files changed, 29 insertions(+), 65 deletions(-) diff --git a/Accelerometer/Recordings/Database/RecordingsRepository.swift b/Accelerometer/Recordings/Database/RecordingsRepository.swift index 710a6b5..67d6f20 100644 --- a/Accelerometer/Recordings/Database/RecordingsRepository.swift +++ b/Accelerometer/Recordings/Database/RecordingsRepository.swift @@ -13,16 +13,13 @@ actor RecordingsRepository { configuration: .defaultConfiguration ) - private(set) var recordingsMetadata: [Recording] = [] - private var recordings: [String: Recording] = [:] + private(set) var recordingsMetadata: [String: Recording] = [:] func save(_ items: [Recording]) { let realms = items.map { RecordingRealm(recording: $0) } databaseManager.write(realms) - items.forEach { - recordings.removeValue(forKey: $0.id) - } + updateMetadata() } func delete(recordingID: String) { @@ -31,7 +28,7 @@ actor RecordingsRepository { databaseManager.delete([recording]) } - recordings.removeValue(forKey: recordingID) + recordingsMetadata.removeValue(forKey: recordingID) } func delete(recordingIDs: [String]) { @@ -40,7 +37,7 @@ actor RecordingsRepository { databaseManager.delete(toDelete) recordingIDs.forEach { - recordings.removeValue(forKey: $0) + recordingsMetadata.removeValue(forKey: $0) } } @@ -49,34 +46,20 @@ actor RecordingsRepository { let metas = realmRecs .map(\.recordingMetadata) .reversed() - recordingsMetadata = Array(metas) + + recordingsMetadata = Dictionary( + uniqueKeysWithValues: metas.map { metadata in + (metadata.id, metadata) + } + ) } func loadFullRecording(id: String) -> Recording? { - if let cached = recordings[id] { - return cached - } - let results: Results = databaseManager.read() guard let realm = results.first(where: { $0.id == id }) else { return nil } - let recording = realm.recording - recordings[id] = recording - - return recording - } - - func clearCache(for id: String) { - recordings.removeValue(forKey: id) - } - - func clearAllCache() { - recordings.removeAll() - } - - var cacheSize: Int { - recordings.count + return realm.recording } } diff --git a/Accelerometer/Recordings/Recorder.swift b/Accelerometer/Recordings/Recorder.swift index 1ad1e64..46af76a 100644 --- a/Accelerometer/Recordings/Recorder.swift +++ b/Accelerometer/Recordings/Recorder.swift @@ -126,6 +126,10 @@ class Recorder: ObservableObject { } } + 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 } @@ -154,25 +158,7 @@ class Recorder: ObservableObject { private func refreshRecordings() async { await repository.updateMetadata() let stored = await repository.recordingsMetadata - await MainActor.run { recordingsMetadata = stored } - } - - // MARK: - Recordings cache menagement - - func loadFullRecording(id: String) async -> Recording? { - return await repository.loadFullRecording(id: id) - } - - func clearRecordingCache(id: String) async { - await repository.clearCache(for: id) - } - - func clearAllRecordingCache() async { - await repository.clearAllCache() - } - - func getCacheSize() async -> Int { - return await repository.cacheSize + await MainActor.run { recordingsMetadata = Array(stored.values) } } // MARK: - Memory control diff --git a/Accelerometer/Recordings/RecordingSummaryView.swift b/Accelerometer/Recordings/RecordingSummaryView.swift index 57503a9..8d3e71a 100644 --- a/Accelerometer/Recordings/RecordingSummaryView.swift +++ b/Accelerometer/Recordings/RecordingSummaryView.swift @@ -58,13 +58,13 @@ struct RecordingSummaryView: View { } } // TODO: export -// ToolbarItem(placement: .primaryAction) { -// Button { -// } label: { -// Label("Export", systemImage: "square.and.arrow.up") -// } -// .disabled(true) -// } + // ToolbarItem(placement: .primaryAction) { + // Button { + // } label: { + // Label("Export", systemImage: "square.and.arrow.up") + // } + // .disabled(true) + // } } .fileExporter( isPresented: $isPresentingExporter, @@ -72,18 +72,13 @@ struct RecordingSummaryView: View { contentType: UTType.plainText, defaultFilename: defaultFilename() ) { _ in } - .onDisappear { - Task { - await recorder.clearRecordingCache(id: recordingMetadata.id) - } - } - .task { - if recordingMetadata.entries == nil { - isLoading = true - fullRecording = await recorder.loadFullRecording(id: recordingMetadata.id) - isLoading = false - } + .task { + if recordingMetadata.entries == nil { + isLoading = true + fullRecording = await recorder.loadFullRecording(id: recordingMetadata.id) + isLoading = false } + } } private func deleteRecording() { From bf7cae87d82143fa510758e4ed1c9f4bb31baeb9 Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Mon, 29 Sep 2025 17:20:50 +0300 Subject: [PATCH 29/40] Recording export refactoring --- .../RecordingMeasurementChartView.swift | 84 +++++++++---------- .../Recordings/RecordingSummaryView.swift | 79 ++++++++++++----- 2 files changed, 98 insertions(+), 65 deletions(-) diff --git a/Accelerometer/Recordings/RecordingMeasurementChartView.swift b/Accelerometer/Recordings/RecordingMeasurementChartView.swift index 1da8ca8..33f47de 100644 --- a/Accelerometer/Recordings/RecordingMeasurementChartView.swift +++ b/Accelerometer/Recordings/RecordingMeasurementChartView.swift @@ -12,11 +12,15 @@ struct RecordingMeasurementChartView: View { let recording: Recording let measurementType: MeasurementType - let export: () -> Void @State private var chartEntries: [Recording.Entry] = [] @State private var isLoading = true + @State private var isPresentingExporter = false + @State private var exportURL: URL? + + @EnvironmentObject private var settings: Settings + private let processor = RecordingProcessor() private var startDate: Date { @@ -70,55 +74,52 @@ struct RecordingMeasurementChartView: View { } } + // MARK: - body var body: some View { - HStack { - VStack() { - Text(title) - .font(.headline) - .padding(.top) - - if isLoading { - ProgressView() - .frame(height: 200) - } else { - Chart(chartEntries) { entry in - CustomChartContent(entry: entry, startDate: startDate) - } - .chartXScale(domain: 0...totalDuration) - .chartXAxis { - AxisMarks(values: .stride(by: chartXAxisStride)) { value in - AxisGridLine() - AxisTick() - if let seconds = value.as(Double.self) { - AxisValueLabel { - Text(formatElapsedTime(seconds)) - } - } - } - } + VStack { + Text(title) + .font(.headline) + + if isLoading { + ProgressView() .frame(height: 200) + } else { + Chart(chartEntries) { entry in + CustomChartContent(entry: entry, startDate: startDate) } - } - VStack { - Menu { - Section { - Button(action: { - export() - }) { - Label("Export to .csv", systemImage: "folder") - } + .frame(height: 200) + .contextMenu { + Button("Export \(measurementType.name) to .csv") { + export() } - } label: { - Image(systemName: "square.and.arrow.up") } - .padding([.vertical, .leading]) - .buttonStyle(BorderlessButtonStyle()) - Spacer() } } .task { await loadChartData() } + .fileExporter( + isPresented: $isPresentingExporter, + document: exportURL.map { FileDocumentWrapper(url: $0) }, + contentType: .plainText, + defaultFilename: "\(measurementType.rawValue).csv" + ) { _ in } + } + + private func export() { + Task { + do { + let url = try await processor.generateCSV( + from: recording, + for: measurementType, + dateFormat: settings.exportDateFormat + ) + exportURL = url + isPresentingExporter = true + } catch { + print("Export failed:", error) + } + } } private func loadChartData() async { @@ -267,8 +268,7 @@ private struct PreviewChart: View { state: .completed, measurementTypes: [.acceleration] ), - measurementType: .acceleration, - export: {} + measurementType: .acceleration ) .frame(height: 200) } diff --git a/Accelerometer/Recordings/RecordingSummaryView.swift b/Accelerometer/Recordings/RecordingSummaryView.swift index 8d3e71a..895dd23 100644 --- a/Accelerometer/Recordings/RecordingSummaryView.swift +++ b/Accelerometer/Recordings/RecordingSummaryView.swift @@ -19,6 +19,9 @@ struct RecordingSummaryView: View { @State private var fullRecording: Recording? @State private var isLoading = false + @State private var isShowingExportMenu = false + @State private var selectedTypeForExport: MeasurementType? + @State private var isPresentingExporter = false @State private var exportURL: URL? @State private var exportLoading = false @@ -27,6 +30,7 @@ struct RecordingSummaryView: View { var body: some View { let recording = fullRecording ?? recordingMetadata + List { Section("Info") { RecordingPreview(recording: recording) @@ -41,9 +45,7 @@ struct RecordingSummaryView: View { RecordingMeasurementChartView( recording: recording, measurementType: type - ) { - export(type: type) - } + ) } } } @@ -51,39 +53,65 @@ struct RecordingSummaryView: View { .navigationTitle("Recording") .toolbar { ToolbarItem(placement: .secondaryAction) { - Button(role: .destructive) { - deleteRecording() - } label: { + Button( + role: .destructive, + action: deleteRecording + ) { Label("Delete", systemImage: "trash") } + .disabled(fullRecording == nil) + } + + ToolbarItem(placement: .primaryAction) { + Menu { + Text("Export") + .font(.headline) + .padding(.vertical, 4) + Divider() + ForEach( + fullRecording?.sortedMeasurementTypes ?? [], + id: \.self + ) { type in + Button(type.name) { + export(type: type) + } + } + } label: { + Label( + "Export", + systemImage: "square.and.arrow.up" + ) + } + .disabled(fullRecording == nil) } - // TODO: export - // ToolbarItem(placement: .primaryAction) { - // Button { - // } label: { - // Label("Export", systemImage: "square.and.arrow.up") - // } - // .disabled(true) - // } } + .fileExporter( isPresented: $isPresentingExporter, document: exportURL.map { FileDocumentWrapper(url: $0) }, contentType: UTType.plainText, defaultFilename: defaultFilename() ) { _ in } - .task { - if recordingMetadata.entries == nil { - isLoading = true - fullRecording = await recorder.loadFullRecording(id: recordingMetadata.id) - isLoading = false + .task { + if recordingMetadata.entries == nil { + isLoading = true + fullRecording = await recorder.loadFullRecording(id: recordingMetadata.id) + isLoading = false + } } - } } - private func deleteRecording() { - recorder.delete(recordingID: recordingMetadata.id) - presentationMode.wrappedValue.dismiss() + private func exportButtons() -> [ActionSheet.Button] { + guard let recording = fullRecording else { + return [.cancel()] + } + var buttons: [ActionSheet.Button] = recording.sortedMeasurementTypes.map { type in + .default(Text(type.name)) { + export(type: type) + } + } + buttons.append(.cancel()) + return buttons } private func export(type: MeasurementType) { @@ -108,6 +136,11 @@ struct RecordingSummaryView: View { private func defaultFilename() -> String { (exportURL?.lastPathComponent ?? recordingMetadata.id) + ".csv" } + + private func deleteRecording() { + recorder.delete(recordingID: recordingMetadata.id) + presentationMode.wrappedValue.dismiss() + } } // Debug-only initializer for previews From 32666112c7c92dbc4a516c4d16f989a19fc7aa8d Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Mon, 29 Sep 2025 17:40:55 +0300 Subject: [PATCH 30/40] Marks and icons for measurement types --- .../Measurements/MeasurementType.swift | 27 ++++++++ .../Recordings/RecordingSummaryView.swift | 68 ++++++++++++------- 2 files changed, 69 insertions(+), 26 deletions(-) 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/Recordings/RecordingSummaryView.swift b/Accelerometer/Recordings/RecordingSummaryView.swift index 895dd23..1da8253 100644 --- a/Accelerometer/Recordings/RecordingSummaryView.swift +++ b/Accelerometer/Recordings/RecordingSummaryView.swift @@ -10,6 +10,8 @@ import UniformTypeIdentifiers struct RecordingSummaryView: View { + // MARK: - Properties + let recordingMetadata: Recording @EnvironmentObject private var settings: Settings @@ -19,15 +21,14 @@ struct RecordingSummaryView: View { @State private var fullRecording: Recording? @State private var isLoading = false - @State private var isShowingExportMenu = false - @State private var selectedTypeForExport: MeasurementType? - @State private var isPresentingExporter = false @State private var exportURL: URL? @State private var exportLoading = false private let processor = RecordingProcessor() + // MARK: - Body + var body: some View { let recording = fullRecording ?? recordingMetadata @@ -40,7 +41,8 @@ struct RecordingSummaryView: View { ProgressView() } else { ForEach( - recording.sortedMeasurementTypes, id: \.self + recording.sortedMeasurementTypes, + id: \.self ) { type in RecordingMeasurementChartView( recording: recording, @@ -52,6 +54,8 @@ struct RecordingSummaryView: View { } .navigationTitle("Recording") .toolbar { + // MARK: Toolbar Items + ToolbarItem(placement: .secondaryAction) { Button( role: .destructive, @@ -72,8 +76,13 @@ struct RecordingSummaryView: View { fullRecording?.sortedMeasurementTypes ?? [], id: \.self ) { type in - Button(type.name) { + Button { export(type: type) + } label: { + Label( + type.name, + systemImage: type.iconName + ) } } } label: { @@ -92,28 +101,23 @@ struct RecordingSummaryView: View { contentType: UTType.plainText, defaultFilename: defaultFilename() ) { _ in } - .task { - if recordingMetadata.entries == nil { - isLoading = true - fullRecording = await recorder.loadFullRecording(id: recordingMetadata.id) - isLoading = false - } - } + .task { + await loadFullRecordingIfNeeded() + } } - private func exportButtons() -> [ActionSheet.Button] { - guard let recording = fullRecording else { - return [.cancel()] - } - var buttons: [ActionSheet.Button] = recording.sortedMeasurementTypes.map { type in - .default(Text(type.name)) { - export(type: type) - } + // MARK: - Private Methods + + private func loadFullRecordingIfNeeded() async { + if recordingMetadata.entries == nil { + isLoading = true + fullRecording = await recorder.loadFullRecording( + id: recordingMetadata.id + ) + isLoading = false } - buttons.append(.cancel()) - return buttons } - + private func export(type: MeasurementType) { guard !exportLoading, let recording = fullRecording else { return } exportLoading = true @@ -143,7 +147,8 @@ struct RecordingSummaryView: View { } } -// Debug-only initializer for previews +// MARK: - Debug Preview + #if DEBUG extension RecordingSummaryView { @@ -163,6 +168,7 @@ extension RecordingSummaryView { } #endif +// MARK: - FileDocument Wrapper struct FileDocumentWrapper: FileDocument { @@ -181,6 +187,8 @@ struct FileDocumentWrapper: FileDocument { } } +// MARK: - Previews + struct RecordingSummaryView_Previews: PreviewProvider { static let settings = Settings() @@ -200,8 +208,16 @@ struct RecordingSummaryView_Previews: PreviewProvider { start: Date().addingTimeInterval(-60), end: Date(), entries: [ - .init(measurementType: .acceleration, date: Date().addingTimeInterval(-60), axes: axes), - .init(measurementType: .acceleration, date: Date(), axes: axes) + .init( + measurementType: .acceleration, + date: Date().addingTimeInterval(-60), + axes: axes + ), + .init( + measurementType: .acceleration, + date: Date(), + axes: axes + ) ], state: .completed, measurementTypes: [.acceleration] From b2e832f0bdce99284d0f2918c26fcf55e5ad25f7 Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Fri, 3 Oct 2025 02:20:38 +0300 Subject: [PATCH 31/40] Fullscreen charts --- Accelerometer.xcodeproj/project.pbxproj | 32 +- Accelerometer/Other/PreviewUtils.swift | 213 ++++++++++++++ .../Charts/RecordingBigChartView.swift | 46 +++ .../Charts/RecordingChartContainerView.swift | 217 ++++++++++++++ .../Charts/RecordingChartContent.swift | 39 +++ .../Charts/RecordingSmallChartView.swift | 58 ++++ .../RecordingMeasurementChartView.swift | 276 ------------------ .../Recordings/RecordingSummaryView.swift | 16 +- 8 files changed, 613 insertions(+), 284 deletions(-) create mode 100644 Accelerometer/Other/PreviewUtils.swift create mode 100644 Accelerometer/Recordings/Charts/RecordingBigChartView.swift create mode 100644 Accelerometer/Recordings/Charts/RecordingChartContainerView.swift create mode 100644 Accelerometer/Recordings/Charts/RecordingChartContent.swift create mode 100644 Accelerometer/Recordings/Charts/RecordingSmallChartView.swift delete mode 100644 Accelerometer/Recordings/RecordingMeasurementChartView.swift diff --git a/Accelerometer.xcodeproj/project.pbxproj b/Accelerometer.xcodeproj/project.pbxproj index b429f93..a0f6fff 100644 --- a/Accelerometer.xcodeproj/project.pbxproj +++ b/Accelerometer.xcodeproj/project.pbxproj @@ -35,7 +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 /* RecordingMeasurementChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B45FF952D8EC357009F5CDF /* RecordingMeasurementChartView.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 */; }; @@ -49,7 +49,11 @@ 0B8C3B2A2A99F9FF00488B1E /* Axis.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B8C3B292A99F9FF00488B1E /* Axis.swift */; }; 0B8C3B2C2A99FC9600488B1E /* Axes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B8C3B2B2A99FC9600488B1E /* Axes.swift */; }; 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 */; }; 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 */; }; @@ -128,7 +132,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 /* RecordingMeasurementChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingMeasurementChartView.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 = ""; }; @@ -141,7 +145,11 @@ 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 = ""; }; 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 = ""; }; @@ -208,6 +216,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 = ( @@ -379,6 +398,7 @@ children = ( 0B801285286DA9C000F4AA85 /* AnimatableArray.swift */, 0B1845532E7DCB90004F5474 /* MemoryMonitor.swift */, + 0B9FB1522E8F365B00D70505 /* PreviewUtils.swift */, ); path = Other; sourceTree = ""; @@ -400,6 +420,7 @@ 0BE138EC2893FF8A0026D0DC /* Recordings */ = { isa = PBXGroup; children = ( + 0B041D792E8F3096007BBFCF /* Charts */, 0B508CD9289C7EEB00326C6E /* Database */, 0BE138F5289409820026D0DC /* Recorder.swift */, 0BE138F1289400E70026D0DC /* Recording.swift */, @@ -408,7 +429,6 @@ 0BE138ED2893FFA10026D0DC /* RecordingsView.swift */, 0BA63B712897FBCE0040B63D /* RecordingPreview.swift */, 0BBAD4AC2895564C00251262 /* RecordingSummaryView.swift */, - 0B45FF952D8EC357009F5CDF /* RecordingMeasurementChartView.swift */, 0BA63B6B289598B40040B63D /* NewRecordingView.swift */, ); path = Recordings; @@ -698,13 +718,15 @@ 0B36DFCA289A9E85007130D9 /* Settings.swift in Sources */, 0BA5B7BC2AD319AC004F1808 /* AttitudeAxesSummaryView.swift in Sources */, 0B24EC3428621E1C00AD3EF1 /* AppDelegate.swift in Sources */, - 0B45FF962D8EC361009F5CDF /* RecordingMeasurementChartView.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 */, @@ -736,6 +758,7 @@ 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 */, @@ -743,6 +766,7 @@ 0B38C323288DC56D00A4541A /* AxesSummaryViewExtended.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 */, diff --git a/Accelerometer/Other/PreviewUtils.swift b/Accelerometer/Other/PreviewUtils.swift new file mode 100644 index 0000000..4ce0c96 --- /dev/null +++ b/Accelerometer/Other/PreviewUtils.swift @@ -0,0 +1,213 @@ +// +// 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 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..850e30b --- /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 { + ChartContainerView( + recording: recording, + measurementType: measurementType, + style: .big + ) + .padding() + .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..0137216 --- /dev/null +++ b/Accelerometer/Recordings/Charts/RecordingChartContainerView.swift @@ -0,0 +1,217 @@ +// +// ChartContainerView.swift +// Accelerometer +// +// Created by Andrey Pantyuhin on 22.03.2025. +// + +import SwiftUI +import Charts +import UniformTypeIdentifiers + +struct ChartContainerView: 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) + 1 + if tickCount <= 5 { + return interval + } + } + + return totalDuration / 4.0 + } + + 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 { + let minutes = seconds / 60 + return String(format: "%.0fm", 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 + .fileExporter( + isPresented: $isPresentingExporter, + document: exportURL.map { FileDocumentWrapper(url: $0) }, + contentType: .plainText, + defaultFilename: "\(measurementType.rawValue).csv" + ) { _ in } + .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, + startDate: recording.start + ) + } + .chartXScale(domain: 0...totalDuration) + .chartXAxis { + switch style { + case .big: + AxisMarks(values: .automatic) { value in + AxisGridLine() + AxisTick() + if let seconds = value.as(Double.self) { + AxisValueLabel { + Text(formatElapsedTime(seconds)) + } + } + } + case .small: + 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 + } + } + } +} + +// MARK: - Previews + +struct ChartContainerView_Previews: PreviewProvider { + static let recording = PreviewUtils.mediumRecording + static let type = measurementType(from: recording) + + static var previews: some View { + Group { + ChartContainerView( + recording: recording, + measurementType: type, + style: .small + ) + .frame(height: 200) + .padding() + .previewDisplayName("Small") + + ChartContainerView( + 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..381e7cf --- /dev/null +++ b/Accelerometer/Recordings/Charts/RecordingSmallChartView.swift @@ -0,0 +1,58 @@ +// +// 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 + )) { + ChartContainerView( + recording: recording, + measurementType: measurementType, + style: .small + ) + } + } +} + +// MARK: - Previews + +struct RecordingSmallChartView_Previews: PreviewProvider { + + // Reuse PreviewUtils for sample recordings + 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/RecordingMeasurementChartView.swift b/Accelerometer/Recordings/RecordingMeasurementChartView.swift deleted file mode 100644 index 33f47de..0000000 --- a/Accelerometer/Recordings/RecordingMeasurementChartView.swift +++ /dev/null @@ -1,276 +0,0 @@ -// -// RecordingMeasurementChartView.swift -// Accelerometer -// -// Created by Andrey Pantyuhin on 22.03.2025. -// - -import Charts -import SwiftUI - -struct RecordingMeasurementChartView: View { - - let recording: Recording - let measurementType: MeasurementType - - @State private var chartEntries: [Recording.Entry] = [] - @State private var isLoading = true - - @State private var isPresentingExporter = false - @State private var exportURL: URL? - - @EnvironmentObject private var settings: Settings - - 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) - } - - var title: String { measurementType.name.capitalizingFirstLetter() } - - 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) + 1 - if tickCount <= 5 { - return interval - } - } - - return totalDuration / 4.0 - } - - 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 { - let minutes = seconds / 60 - return String(format: "%.0fm", minutes) - } else { - let hours = seconds / 3600 - if hours.truncatingRemainder(dividingBy: 1) == 0 { - return String(format: "%.0fh", hours) - } else { - return String(format: "%.1fh", hours) - } - } - } - - // MARK: - body - var body: some View { - VStack { - Text(title) - .font(.headline) - - if isLoading { - ProgressView() - .frame(height: 200) - } else { - Chart(chartEntries) { entry in - CustomChartContent(entry: entry, startDate: startDate) - } - .frame(height: 200) - .contextMenu { - Button("Export \(measurementType.name) to .csv") { - export() - } - } - } - } - .task { - await loadChartData() - } - .fileExporter( - isPresented: $isPresentingExporter, - document: exportURL.map { FileDocumentWrapper(url: $0) }, - contentType: .plainText, - defaultFilename: "\(measurementType.rawValue).csv" - ) { _ in } - } - - private func export() { - Task { - do { - let url = try await processor.generateCSV( - from: recording, - for: measurementType, - dateFormat: settings.exportDateFormat - ) - exportURL = url - isPresentingExporter = true - } catch { - print("Export failed:", error) - } - } - } - - private func loadChartData() async { - isLoading = true - let entries = await processor.sampledEntries( - from: recording, - for: measurementType, - maxCount: 50 - ) - await MainActor.run { - chartEntries = entries - isLoading = false - } - } -} - - -private struct CustomChartContent: 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)) - } - } - } -} - -// MARK: - Previews - -struct RecordingMeasurementChartView_Previews: PreviewProvider { - - // MARK: Test Data Configuration - - struct PreviewData { - let title: String - let totalPoints: Int - let samplingRate: Double - let interval: Double - - var entries: [Recording.Entry] { - let startDate = Date() - return (0.. Date: Fri, 3 Oct 2025 03:02:51 +0300 Subject: [PATCH 32/40] Fullscreen charts styling --- .../Charts/RecordingChartContainerView.swift | 42 +++++++++---------- .../Charts/RecordingSmallChartView.swift | 1 - .../Recordings/RecordingSummaryView.swift | 1 + 3 files changed, 20 insertions(+), 24 deletions(-) diff --git a/Accelerometer/Recordings/Charts/RecordingChartContainerView.swift b/Accelerometer/Recordings/Charts/RecordingChartContainerView.swift index 0137216..4d3d23a 100644 --- a/Accelerometer/Recordings/Charts/RecordingChartContainerView.swift +++ b/Accelerometer/Recordings/Charts/RecordingChartContainerView.swift @@ -43,13 +43,13 @@ struct ChartContainerView: View { ] for interval in niceIntervals { - let tickCount = Int(totalDuration / interval) + 1 - if tickCount <= 5 { + let tickCount = Int(totalDuration / interval) + if tickCount <= style.tickCount { return interval } } - return totalDuration / 4.0 + return totalDuration / Double(style.tickCount) } private func formatElapsedTime(_ seconds: TimeInterval) -> String { @@ -104,26 +104,13 @@ struct ChartContainerView: View { } .chartXScale(domain: 0...totalDuration) .chartXAxis { - switch style { - case .big: - AxisMarks(values: .automatic) { value in - AxisGridLine() - AxisTick() - if let seconds = value.as(Double.self) { - AxisValueLabel { - Text(formatElapsedTime(seconds)) - } - } - } - case .small: - let values = AxisMarkValues.stride(by: chartXAxisStride) - AxisMarks(values: values) { value in - AxisGridLine() - AxisTick() - if let seconds = value.as(Double.self) { - AxisValueLabel { - Text(formatElapsedTime(seconds)) - } + let values = AxisMarkValues.stride(by: chartXAxisStride) + AxisMarks(values: values) { value in + AxisGridLine() + AxisTick() + if let seconds = value.as(Double.self) { + AxisValueLabel { + Text(formatElapsedTime(seconds)) } } } @@ -181,6 +168,15 @@ struct ChartContainerView: View { 100 } } + + var tickCount: Int { + switch self { + case .big: + 16 + case .small: + 4 + } + } } } diff --git a/Accelerometer/Recordings/Charts/RecordingSmallChartView.swift b/Accelerometer/Recordings/Charts/RecordingSmallChartView.swift index 381e7cf..bd0f4ac 100644 --- a/Accelerometer/Recordings/Charts/RecordingSmallChartView.swift +++ b/Accelerometer/Recordings/Charts/RecordingSmallChartView.swift @@ -30,7 +30,6 @@ struct RecordingSmallChartView: View { struct RecordingSmallChartView_Previews: PreviewProvider { - // Reuse PreviewUtils for sample recordings static var sampleRecordings: [Recording] = [ PreviewUtils.shortRecording, PreviewUtils.mediumRecording, diff --git a/Accelerometer/Recordings/RecordingSummaryView.swift b/Accelerometer/Recordings/RecordingSummaryView.swift index 2a03f9e..5f2f954 100644 --- a/Accelerometer/Recordings/RecordingSummaryView.swift +++ b/Accelerometer/Recordings/RecordingSummaryView.swift @@ -57,6 +57,7 @@ struct RecordingSummaryView: View { ) } } + .navigationLinkIndicatorVisibility(.hidden) } } } From baaa5c1827dda1694efee98aae008e12d35d4581 Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Fri, 3 Oct 2025 03:26:15 +0300 Subject: [PATCH 33/40] Fullscreen charts time format --- .../Charts/RecordingChartContainerView.swift | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/Accelerometer/Recordings/Charts/RecordingChartContainerView.swift b/Accelerometer/Recordings/Charts/RecordingChartContainerView.swift index 4d3d23a..a6d4649 100644 --- a/Accelerometer/Recordings/Charts/RecordingChartContainerView.swift +++ b/Accelerometer/Recordings/Charts/RecordingChartContainerView.swift @@ -60,14 +60,28 @@ struct ChartContainerView: View { } else if absSeconds < 60 { return String(format: "%.0fs", seconds) } else if absSeconds < 3600 { - let minutes = seconds / 60 - return String(format: "%.0fm", minutes) + // 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 { - let hours = seconds / 3600 - if hours.truncatingRemainder(dividingBy: 1) == 0 { - return String(format: "%.0fh", hours) + if chartXAxisStride < 3600 { + let hours = Int(seconds / 3600) + let minutes = Int((seconds.truncatingRemainder(dividingBy: 3600)) / 60) + return String(format: "%dh%02dm", hours, minutes) } else { - return String(format: "%.1fh", hours) + let hours = seconds / 3600 + if hours.truncatingRemainder(dividingBy: 1) == 0 { + return String(format: "%.0fh", hours) + } else { + return String(format: "%.1fh", hours) + } } } } From 7b7fddd8df33fc2ce98a50fccf8132a4e5510b8d Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Fri, 3 Oct 2025 03:58:32 +0300 Subject: [PATCH 34/40] Debug samples, buttons improvements --- Accelerometer.xcodeproj/project.pbxproj | 4 +++ .../Measurements/MeasurementSummaryView.swift | 4 +-- Accelerometer/Other/PreviewUtils.swift | 3 ++ .../Charts/RecordingBigChartView.swift | 2 +- .../Recordings/NewRecordingView.swift | 8 ++--- Accelerometer/Recordings/Recorder.swift | 14 ++++++++ Accelerometer/Settings/DebugSamplesView.swift | 36 +++++++++++++++++++ Accelerometer/Settings/SettingsView.swift | 1 + 8 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 Accelerometer/Settings/DebugSamplesView.swift diff --git a/Accelerometer.xcodeproj/project.pbxproj b/Accelerometer.xcodeproj/project.pbxproj index a0f6fff..ec6abe9 100644 --- a/Accelerometer.xcodeproj/project.pbxproj +++ b/Accelerometer.xcodeproj/project.pbxproj @@ -54,6 +54,7 @@ 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 */; }; 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 */; }; @@ -150,6 +151,7 @@ 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 = ""; }; 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 = ""; }; @@ -307,6 +309,7 @@ 0B36DFCB289AA096007130D9 /* ExportDateFormatView.swift */, 0B4BE8522D74BB2F0065D938 /* AnimationsView.swift */, 0B7A815F2E816FE900399013 /* AlwaysNotEnoughMemoryView.swift */, + 0B9FB1542E8F52CA00D70505 /* DebugSamplesView.swift */, ); path = Settings; sourceTree = ""; @@ -713,6 +716,7 @@ 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 */, diff --git a/Accelerometer/Measurements/MeasurementSummaryView.swift b/Accelerometer/Measurements/MeasurementSummaryView.swift index 15bd072..79434f1 100644 --- a/Accelerometer/Measurements/MeasurementSummaryView.swift +++ b/Accelerometer/Measurements/MeasurementSummaryView.swift @@ -26,7 +26,7 @@ struct MeasurementSummaryView: View { let enabled = !recorder.recordingInProgress && recorder.hasEnoughMemory let text = if !recorder.hasEnoughMemory { - "Not enought memory" + "Not enough memory" } else if recorder.recordingInProgress { "Recording is already started" } else { @@ -41,7 +41,7 @@ struct MeasurementSummaryView: View { .foregroundColor(.background) } ) - .buttonStyle(.borderedProminent) + .buttonStyle(.glassProminent) .controlSize(.large) .disabled(!enabled) .padding(.defaultPadding) diff --git a/Accelerometer/Other/PreviewUtils.swift b/Accelerometer/Other/PreviewUtils.swift index 4ce0c96..2f0c543 100644 --- a/Accelerometer/Other/PreviewUtils.swift +++ b/Accelerometer/Other/PreviewUtils.swift @@ -205,6 +205,9 @@ struct PreviewUtils { emptyRecording ] + static let allSampleCompletedRecordings: [Recording] = + allSampleRecordings.filter { $0.end != nil } + static let magneticFieldTestRecording = recordingWithType(.magneticField, duration: 120) static let gravityTestRecording = recordingWithType(.gravity, duration: 60) diff --git a/Accelerometer/Recordings/Charts/RecordingBigChartView.swift b/Accelerometer/Recordings/Charts/RecordingBigChartView.swift index 850e30b..737ece0 100644 --- a/Accelerometer/Recordings/Charts/RecordingBigChartView.swift +++ b/Accelerometer/Recordings/Charts/RecordingBigChartView.swift @@ -17,7 +17,7 @@ struct RecordingBigChartView: View { measurementType: measurementType, style: .big ) - .padding() + .padding(.vertical) .navigationTitle(measurementType.name.capitalized) .navigationBarTitleDisplayMode(.inline) } diff --git a/Accelerometer/Recordings/NewRecordingView.swift b/Accelerometer/Recordings/NewRecordingView.swift index afb5f87..ee85aa1 100644 --- a/Accelerometer/Recordings/NewRecordingView.swift +++ b/Accelerometer/Recordings/NewRecordingView.swift @@ -43,7 +43,7 @@ struct NewRecordingView: View { HStack { Spacer() - Button(allSelected ? "Unselect All" : "Select All") { + Button(allSelected ? "Deselect all" : "Select all") { setAll(!allSelected) } .buttonStyle(.bordered) @@ -67,17 +67,17 @@ struct NewRecordingView: View { .listStyle(.plain) .animation(.easeInOut, value: measurementTypes) - Button("Start Recording") { + Button("Start") { startRecording() } - .buttonStyle(.borderedProminent) + .buttonStyle(.glassProminent) .controlSize(.large) .tint(noneSelected ? .gray : .accentColor) .disabled(noneSelected) .padding(.horizontal) .animation(.easeInOut, value: noneSelected) } - .navigationTitle("New Recording") + .navigationTitle("New recording") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { diff --git a/Accelerometer/Recordings/Recorder.swift b/Accelerometer/Recordings/Recorder.swift index 46af76a..32a2067 100644 --- a/Accelerometer/Recordings/Recorder.swift +++ b/Accelerometer/Recordings/Recorder.swift @@ -197,4 +197,18 @@ class Recorder: ObservableObject { 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/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/SettingsView.swift b/Accelerometer/Settings/SettingsView.swift index 26adc42..00bd3b7 100644 --- a/Accelerometer/Settings/SettingsView.swift +++ b/Accelerometer/Settings/SettingsView.swift @@ -33,6 +33,7 @@ struct SettingsView: View { ) { AnimationsView() AlwaysNotEnoughMemoryView() + DebugSamplesView() } #endif From 90353feca1a8b9ee93923bb11fde450b1f9f99a3 Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Fri, 3 Oct 2025 04:17:48 +0300 Subject: [PATCH 35/40] Recording preview timer fix --- .../Recordings/RecordingPreview.swift | 60 +++++++++---------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/Accelerometer/Recordings/RecordingPreview.swift b/Accelerometer/Recordings/RecordingPreview.swift index cfd891c..00fc002 100644 --- a/Accelerometer/Recordings/RecordingPreview.swift +++ b/Accelerometer/Recordings/RecordingPreview.swift @@ -12,19 +12,13 @@ struct RecordingPreview: View { let recording: Recording - @State private var timerPublisher = Timer.publish( - every: 1, on: .main, in: .common - ) + @State private var timerPublisher: Timer.TimerPublisher? @State private var timerCancellable: Cancellable? @State private var durationStringState: String = "" private var durationString: String { - return if let duration = recording.duration { - duration.durationString - } else { - "???" - } + recording.duration?.durationString ?? "???" } private var startString: String { @@ -32,38 +26,42 @@ struct RecordingPreview: View { } var body: some View { - VStack { - 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) - } + 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 - updateTimer() + setupTimerIfNeeded() } .onChange(of: recording.state) { - updateTimer() - } - .onReceive(timerPublisher) { _ in - durationStringState = durationString + setupTimerIfNeeded() } } - - private func updateTimer() { + + private func setupTimerIfNeeded() { timerCancellable?.cancel() - if recording.state == .inProgress { - timerCancellable = timerPublisher.connect() - } + 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 + } } } From 821018f658b32f14e6c59b33f353974dc8b5075f Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Fri, 3 Oct 2025 04:49:06 +0300 Subject: [PATCH 36/40] Chart leading delay fix --- .../Recordings/Charts/RecordingChartContainerView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Accelerometer/Recordings/Charts/RecordingChartContainerView.swift b/Accelerometer/Recordings/Charts/RecordingChartContainerView.swift index a6d4649..c561c65 100644 --- a/Accelerometer/Recordings/Charts/RecordingChartContainerView.swift +++ b/Accelerometer/Recordings/Charts/RecordingChartContainerView.swift @@ -113,7 +113,8 @@ struct ChartContainerView: View { Chart(chartEntries) { entry in RecordingChartContent( entry: entry, - startDate: recording.start + // Can be delay between start and first entry + startDate: chartEntries.first?.date ?? recording.start ) } .chartXScale(domain: 0...totalDuration) From 2cc555c76846b495b8e6e10ab2c8eff42899ac34 Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Fri, 3 Oct 2025 04:49:58 +0300 Subject: [PATCH 37/40] Recordings sort --- Accelerometer/Recordings/Recorder.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Accelerometer/Recordings/Recorder.swift b/Accelerometer/Recordings/Recorder.swift index 32a2067..82676cf 100644 --- a/Accelerometer/Recordings/Recorder.swift +++ b/Accelerometer/Recordings/Recorder.swift @@ -158,7 +158,10 @@ class Recorder: ObservableObject { private func refreshRecordings() async { await repository.updateMetadata() let stored = await repository.recordingsMetadata - await MainActor.run { recordingsMetadata = Array(stored.values) } + await MainActor.run { + recordingsMetadata = Array(stored.values) + .sorted { $0.start > $1.start } + } } // MARK: - Memory control From 443bf4259f12c6e5099b64b938aaf35af569e413 Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Fri, 3 Oct 2025 05:17:16 +0300 Subject: [PATCH 38/40] Export modifier --- Accelerometer.xcodeproj/project.pbxproj | 4 ++ Accelerometer/Extensions/View.swift | 30 ++++++++++-- .../Charts/RecordingBigChartView.swift | 2 +- .../Charts/RecordingChartContainerView.swift | 19 ++++---- .../Charts/RecordingSmallChartView.swift | 2 +- Accelerometer/Recordings/ExportModifier.swift | 46 +++++++++++++++++++ .../Recordings/RecordingSummaryView.swift | 21 +++++---- 7 files changed, 100 insertions(+), 24 deletions(-) create mode 100644 Accelerometer/Recordings/ExportModifier.swift diff --git a/Accelerometer.xcodeproj/project.pbxproj b/Accelerometer.xcodeproj/project.pbxproj index ec6abe9..74fdc76 100644 --- a/Accelerometer.xcodeproj/project.pbxproj +++ b/Accelerometer.xcodeproj/project.pbxproj @@ -55,6 +55,7 @@ 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 */; }; @@ -152,6 +153,7 @@ 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 = ""; }; @@ -433,6 +435,7 @@ 0BA63B712897FBCE0040B63D /* RecordingPreview.swift */, 0BBAD4AC2895564C00251262 /* RecordingSummaryView.swift */, 0BA63B6B289598B40040B63D /* NewRecordingView.swift */, + 0B9FB1562E8F653300D70505 /* ExportModifier.swift */, ); path = Recordings; sourceTree = ""; @@ -768,6 +771,7 @@ 0B4CC363287C30FF000F0C94 /* DiagramAxesNamesStyle.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 */, 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/Recordings/Charts/RecordingBigChartView.swift b/Accelerometer/Recordings/Charts/RecordingBigChartView.swift index 737ece0..c74ede5 100644 --- a/Accelerometer/Recordings/Charts/RecordingBigChartView.swift +++ b/Accelerometer/Recordings/Charts/RecordingBigChartView.swift @@ -12,7 +12,7 @@ struct RecordingBigChartView: View { let measurementType: MeasurementType var body: some View { - ChartContainerView( + RecordingChartContainerView( recording: recording, measurementType: measurementType, style: .big diff --git a/Accelerometer/Recordings/Charts/RecordingChartContainerView.swift b/Accelerometer/Recordings/Charts/RecordingChartContainerView.swift index c561c65..bab8d14 100644 --- a/Accelerometer/Recordings/Charts/RecordingChartContainerView.swift +++ b/Accelerometer/Recordings/Charts/RecordingChartContainerView.swift @@ -1,5 +1,5 @@ // -// ChartContainerView.swift +// RecordingChartContainerView.swift // Accelerometer // // Created by Andrey Pantyuhin on 22.03.2025. @@ -9,7 +9,7 @@ import SwiftUI import Charts import UniformTypeIdentifiers -struct ChartContainerView: View { +struct RecordingChartContainerView: View { let recording: Recording let measurementType: MeasurementType @@ -88,12 +88,11 @@ struct ChartContainerView: View { var body: some View { chart - .fileExporter( + .exportable( isPresented: $isPresentingExporter, - document: exportURL.map { FileDocumentWrapper(url: $0) }, - contentType: .plainText, - defaultFilename: "\(measurementType.rawValue).csv" - ) { _ in } + url: $exportURL, + measurementType: measurementType + ) .task { await loadData() } @@ -197,13 +196,13 @@ struct ChartContainerView: View { // MARK: - Previews -struct ChartContainerView_Previews: PreviewProvider { +struct RecordingChartContainerView_Previews: PreviewProvider { static let recording = PreviewUtils.mediumRecording static let type = measurementType(from: recording) static var previews: some View { Group { - ChartContainerView( + RecordingChartContainerView( recording: recording, measurementType: type, style: .small @@ -212,7 +211,7 @@ struct ChartContainerView_Previews: PreviewProvider { .padding() .previewDisplayName("Small") - ChartContainerView( + RecordingChartContainerView( recording: recording, measurementType: type, style: .big diff --git a/Accelerometer/Recordings/Charts/RecordingSmallChartView.swift b/Accelerometer/Recordings/Charts/RecordingSmallChartView.swift index bd0f4ac..cc0074b 100644 --- a/Accelerometer/Recordings/Charts/RecordingSmallChartView.swift +++ b/Accelerometer/Recordings/Charts/RecordingSmallChartView.swift @@ -17,7 +17,7 @@ struct RecordingSmallChartView: View { recording: recording, measurementType: measurementType )) { - ChartContainerView( + RecordingChartContainerView( recording: recording, measurementType: measurementType, style: .small 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/RecordingSummaryView.swift b/Accelerometer/Recordings/RecordingSummaryView.swift index 5f2f954..45807b3 100644 --- a/Accelerometer/Recordings/RecordingSummaryView.swift +++ b/Accelerometer/Recordings/RecordingSummaryView.swift @@ -21,7 +21,7 @@ struct RecordingSummaryView: View { @State private var fullRecording: Recording? @State private var isLoading = false - @State private var isPresentingExporter = false + @State private var exportingMeasurement: MeasurementType? = nil @State private var exportURL: URL? @State private var exportLoading = false @@ -103,13 +103,16 @@ struct RecordingSummaryView: View { .disabled(fullRecording == nil) } } - - .fileExporter( - isPresented: $isPresentingExporter, - document: exportURL.map { FileDocumentWrapper(url: $0) }, - contentType: UTType.plainText, - defaultFilename: defaultFilename() - ) { _ in } + .exportable( + isPresented: .init( + get: { exportingMeasurement != nil }, + set: { presented in + if !presented { exportingMeasurement = nil } + } + ), + url: $exportURL, + measurementType: exportingMeasurement ?? recording.measurementTypes.first! + ) .task { await loadFullRecordingIfNeeded() } @@ -139,7 +142,7 @@ struct RecordingSummaryView: View { dateFormat: settings.exportDateFormat ) exportURL = url - isPresentingExporter = true + exportingMeasurement = type } catch { print("Export failed:", error) } From 2828e335ffc1ca9dd654cf76e964284b1089581b Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Fri, 3 Oct 2025 05:20:14 +0300 Subject: [PATCH 39/40] Description --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 61d4cf25ca76ff14347e8aaa57442393df914fac Mon Sep 17 00:00:00 2001 From: Andrey Pantyuhin Date: Fri, 3 Oct 2025 05:22:10 +0300 Subject: [PATCH 40/40] Version 0.2.0 --- Accelerometer.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Accelerometer.xcodeproj/project.pbxproj b/Accelerometer.xcodeproj/project.pbxproj index 74fdc76..0abacfb 100644 --- a/Accelerometer.xcodeproj/project.pbxproj +++ b/Accelerometer.xcodeproj/project.pbxproj @@ -958,7 +958,7 @@ "$(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; @@ -992,7 +992,7 @@ "$(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;