Add route files, show overview
This commit is contained in:
@@ -34,6 +34,13 @@
|
||||
E21A573C2D8C714000E9EBE3 /* Page+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E06DFF2CA4A8EB0019C2AF /* Page+Mock.swift */; };
|
||||
E21A573D2D8C714000E9EBE3 /* Content+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218500A2CEE02FA0090B18B /* Content+Mock.swift */; };
|
||||
E21A573E2D8C714000E9EBE3 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E2DD047D2C276F32003BFF1F /* Preview Assets.xcassets */; };
|
||||
E224E0D92E55075C0031C2B0 /* MapImageCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E224E0D82E55075C0031C2B0 /* MapImageCreator.swift */; };
|
||||
E224E0DE2E5651DB0031C2B0 /* Sequence+Median.swift in Sources */ = {isa = PBXBuildFile; fileRef = E224E0DD2E5651D70031C2B0 /* Sequence+Median.swift */; };
|
||||
E224E0E02E5652180031C2B0 /* Locations+Sampled.swift in Sources */ = {isa = PBXBuildFile; fileRef = E224E0DF2E5652120031C2B0 /* Locations+Sampled.swift */; };
|
||||
E224E0E22E5652680031C2B0 /* WorkoutData.swift in Sources */ = {isa = PBXBuildFile; fileRef = E224E0E12E5652680031C2B0 /* WorkoutData.swift */; };
|
||||
E224E0E52E56528F0031C2B0 /* BinaryCodable in Frameworks */ = {isa = PBXBuildFile; productRef = E224E0E42E56528F0031C2B0 /* BinaryCodable */; };
|
||||
E224E0E72E5664AF0031C2B0 /* RoutePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E224E0E62E5664A70031C2B0 /* RoutePreviewView.swift */; };
|
||||
E224E0E92E5668470031C2B0 /* Time+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = E224E0E82E5668470031C2B0 /* Time+String.swift */; };
|
||||
E22990152D0E2B7F009F8D77 /* ItemSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990142D0E2B74009F8D77 /* ItemSelectionView.swift */; };
|
||||
E22990192D0E3546009F8D77 /* ItemReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990182D0E3546009F8D77 /* ItemReference.swift */; };
|
||||
E229901E2D0E4364009F8D77 /* LocalizedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E229901D2D0E4362009F8D77 /* LocalizedItem.swift */; };
|
||||
@@ -174,6 +181,8 @@
|
||||
E2A37D2B2CED2CC30000979F /* TagDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A37D2A2CED2CC30000979F /* TagDetailView.swift */; };
|
||||
E2A37D2D2CED2EF10000979F /* OptionalTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A37D2C2CED2EEE0000979F /* OptionalTextField.swift */; };
|
||||
E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A9CB7D2C7BCF2A005C89CC /* Page.swift */; };
|
||||
E2ADC02A2E5794AB00B4FF88 /* RouteOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2ADC0292E5794AB00B4FF88 /* RouteOverview.swift */; };
|
||||
E2ADC02C2E5795F300B4FF88 /* ElevationGraph.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2ADC02B2E5795F000B4FF88 /* ElevationGraph.swift */; };
|
||||
E2B482002D5D1136005C309D /* Vapor in Frameworks */ = {isa = PBXBuildFile; productRef = E2B481FF2D5D1136005C309D /* Vapor */; };
|
||||
E2B482032D5D1331005C309D /* WebServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B482022D5D132D005C309D /* WebServer.swift */; };
|
||||
E2B482052D5E7D4A005C309D /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B482042D5E7D4A005C309D /* WebView.swift */; };
|
||||
@@ -323,6 +332,12 @@
|
||||
E21850322CFAFA200090B18B /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; };
|
||||
E21850362CFCA5580090B18B /* LocalizedPostSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPostSettings.swift; sourceTree = "<group>"; };
|
||||
E218503C2CFCFD8C0090B18B /* LocalizedPostFeedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPostFeedSettingsView.swift; sourceTree = "<group>"; };
|
||||
E224E0D82E55075C0031C2B0 /* MapImageCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapImageCreator.swift; sourceTree = "<group>"; };
|
||||
E224E0DD2E5651D70031C2B0 /* Sequence+Median.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sequence+Median.swift"; sourceTree = "<group>"; };
|
||||
E224E0DF2E5652120031C2B0 /* Locations+Sampled.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locations+Sampled.swift"; sourceTree = "<group>"; };
|
||||
E224E0E12E5652680031C2B0 /* WorkoutData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutData.swift; sourceTree = "<group>"; };
|
||||
E224E0E62E5664A70031C2B0 /* RoutePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutePreviewView.swift; sourceTree = "<group>"; };
|
||||
E224E0E82E5668470031C2B0 /* Time+String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Time+String.swift"; sourceTree = "<group>"; };
|
||||
E22990142D0E2B74009F8D77 /* ItemSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSelectionView.swift; sourceTree = "<group>"; };
|
||||
E22990182D0E3546009F8D77 /* ItemReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemReference.swift; sourceTree = "<group>"; };
|
||||
E229901D2D0E4362009F8D77 /* LocalizedItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedItem.swift; sourceTree = "<group>"; };
|
||||
@@ -460,6 +475,8 @@
|
||||
E2A37D2A2CED2CC30000979F /* TagDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetailView.swift; sourceTree = "<group>"; };
|
||||
E2A37D2C2CED2EEE0000979F /* OptionalTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionalTextField.swift; sourceTree = "<group>"; };
|
||||
E2A9CB7D2C7BCF2A005C89CC /* Page.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Page.swift; sourceTree = "<group>"; };
|
||||
E2ADC0292E5794AB00B4FF88 /* RouteOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteOverview.swift; sourceTree = "<group>"; };
|
||||
E2ADC02B2E5795F000B4FF88 /* ElevationGraph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElevationGraph.swift; sourceTree = "<group>"; };
|
||||
E2B482022D5D132D005C309D /* WebServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebServer.swift; sourceTree = "<group>"; };
|
||||
E2B482042D5E7D4A005C309D /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = "<group>"; };
|
||||
E2B482082D5E7F4C005C309D /* WebsitePreviewSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsitePreviewSheet.swift; sourceTree = "<group>"; };
|
||||
@@ -593,6 +610,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
E224E0E52E56528F0031C2B0 /* BinaryCodable in Frameworks */,
|
||||
E2B85F362C426BEE0047CD0C /* SFSafeSymbols in Frameworks */,
|
||||
E25DA52C2CFFC3EC00AEF16D /* SDWebImageAVIFCoder in Frameworks */,
|
||||
E2FD1D522D4644B400B48627 /* SVGView in Frameworks */,
|
||||
@@ -693,6 +711,21 @@
|
||||
path = Mock;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E224E0D72E55074E0031C2B0 /* Workouts */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E2ADC02B2E5795F000B4FF88 /* ElevationGraph.swift */,
|
||||
E2ADC0292E5794AB00B4FF88 /* RouteOverview.swift */,
|
||||
E224E0E82E5668470031C2B0 /* Time+String.swift */,
|
||||
E224E0E62E5664A70031C2B0 /* RoutePreviewView.swift */,
|
||||
E224E0E12E5652680031C2B0 /* WorkoutData.swift */,
|
||||
E224E0DF2E5652120031C2B0 /* Locations+Sampled.swift */,
|
||||
E224E0DD2E5651D70031C2B0 /* Sequence+Median.swift */,
|
||||
E224E0D82E55075C0031C2B0 /* MapImageCreator.swift */,
|
||||
);
|
||||
path = Workouts;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E229901A2D0E3F09009F8D77 /* Item */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -1077,6 +1110,7 @@
|
||||
E2DD04722C276F31003BFF1F /* CHDataManagement */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E224E0D72E55074E0031C2B0 /* Workouts */,
|
||||
E2B482162D63AF6F005C309D /* Notifications */,
|
||||
E2B4820E2D5E9FF0005C309D /* Push */,
|
||||
E2B482012D5D1325005C309D /* Server */,
|
||||
@@ -1277,6 +1311,7 @@
|
||||
E29D31A72D0CDC5D0051B7F4 /* SwiftSoup */,
|
||||
E2FD1D512D4644B400B48627 /* SVGView */,
|
||||
E2B481FF2D5D1136005C309D /* Vapor */,
|
||||
E224E0E42E56528F0031C2B0 /* BinaryCodable */,
|
||||
);
|
||||
productName = CHDataManagement;
|
||||
productReference = E2DD04702C276F31003BFF1F /* CHDataManagement.app */;
|
||||
@@ -1316,6 +1351,7 @@
|
||||
E29D31A62D0CDC5D0051B7F4 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
|
||||
E2FD1D502D4644B400B48627 /* XCRemoteSwiftPackageReference "SVGView" */,
|
||||
E2B481FE2D5D1136005C309D /* XCRemoteSwiftPackageReference "vapor" */,
|
||||
E224E0E32E56528F0031C2B0 /* XCRemoteSwiftPackageReference "BinaryCodable" */,
|
||||
);
|
||||
productRefGroup = E2DD04712C276F31003BFF1F /* Products */;
|
||||
projectDirPath = "";
|
||||
@@ -1418,6 +1454,7 @@
|
||||
E2FE0F4F2D2BCD80002963B7 /* TagLinkCommand.swift in Sources */,
|
||||
E2FD1D302D37196C00B48627 /* GeneralSettingsDetailView.swift in Sources */,
|
||||
E22990342D0F77E9009F8D77 /* PagePropertyView.swift in Sources */,
|
||||
E224E0E92E5668470031C2B0 /* Time+String.swift in Sources */,
|
||||
E2A37D0C2CE4036B0000979F /* LocalizedPage.swift in Sources */,
|
||||
E2A21C102CB18B3A0060935B /* FlowHStack.swift in Sources */,
|
||||
E25DA5992D02401E00AEF16D /* PageGenerator.swift in Sources */,
|
||||
@@ -1446,6 +1483,7 @@
|
||||
E21850272CF3B42D0090B18B /* PostDetailView.swift in Sources */,
|
||||
E22990242D0EDBD0009F8D77 /* HeaderElement.swift in Sources */,
|
||||
E2BF1BCA2D70EDF8003089F1 /* TagPropertyView.swift in Sources */,
|
||||
E2ADC02C2E5795F300B4FF88 /* ElevationGraph.swift in Sources */,
|
||||
E29D31BC2D0DB5120051B7F4 /* CommandProcessor.swift in Sources */,
|
||||
E2F3B39C2DC5542E00CFA712 /* LabelEditingView.swift in Sources */,
|
||||
E2FE0F662D2C3B3A002963B7 /* LabelsBlock.swift in Sources */,
|
||||
@@ -1462,8 +1500,10 @@
|
||||
E2FD1D462D46428100B48627 /* PageIconView.swift in Sources */,
|
||||
E2A37D252CEBD7A10000979F /* PageListView.swift in Sources */,
|
||||
E2FE0F172D2698D5002963B7 /* LocalizedPageId.swift in Sources */,
|
||||
E224E0E72E5664AF0031C2B0 /* RoutePreviewView.swift in Sources */,
|
||||
E2FD1D2E2D37180900B48627 /* GeneralSettings.swift in Sources */,
|
||||
E2FD1D542D46577700B48627 /* HtmlProducer.swift in Sources */,
|
||||
E224E0DE2E5651DB0031C2B0 /* Sequence+Median.swift in Sources */,
|
||||
E2FE0F0D2D268A09002963B7 /* PostListPageGeneratorSource.swift in Sources */,
|
||||
E2521E002D50BB6E00C56662 /* ItemLinkResults.swift in Sources */,
|
||||
E2FE0F402D2B45D3002963B7 /* SwiftBlock.swift in Sources */,
|
||||
@@ -1487,6 +1527,7 @@
|
||||
E2720B882DF38BB700FDB543 /* Insert+Video.swift in Sources */,
|
||||
E2FE0F022D266FCB002963B7 /* LocalizedNavigationSettings.swift in Sources */,
|
||||
E29D313F2D04822C0051B7F4 /* AddPostView.swift in Sources */,
|
||||
E224E0E22E5652680031C2B0 /* WorkoutData.swift in Sources */,
|
||||
E25DA5752D018B6100AEF16D /* FileDetailView.swift in Sources */,
|
||||
E25DA50B2CFD988100AEF16D /* PageTagAssignmentView.swift in Sources */,
|
||||
E2FD1D682D483CCF00B48627 /* Insert+Buttons.swift in Sources */,
|
||||
@@ -1508,6 +1549,7 @@
|
||||
E2FD1D5C2D47EEB800B48627 /* LinkedPageTagView.swift in Sources */,
|
||||
E22990382D0F7B32009F8D77 /* OptionalImagePropertyView.swift in Sources */,
|
||||
E2FE0F512D2BCDC8002963B7 /* ModelCommand.swift in Sources */,
|
||||
E224E0D92E55075C0031C2B0 /* MapImageCreator.swift in Sources */,
|
||||
E2FE0F592D2BCFE4002963B7 /* OrderedKeyBlockProcessor.swift in Sources */,
|
||||
E2FE0EEC2D1C1253002963B7 /* MultiFileSelectionView.swift in Sources */,
|
||||
E29D31B32D0DA6E80051B7F4 /* ButtonIcons.swift in Sources */,
|
||||
@@ -1518,6 +1560,7 @@
|
||||
E21A573C2D8C714000E9EBE3 /* Page+Mock.swift in Sources */,
|
||||
E21A573D2D8C714000E9EBE3 /* Content+Mock.swift in Sources */,
|
||||
E25DA5732D018AA100AEF16D /* FileContentView.swift in Sources */,
|
||||
E224E0E02E5652180031C2B0 /* Locations+Sampled.swift in Sources */,
|
||||
E2FD1D3F2D46405000B48627 /* PostLabelsView.swift in Sources */,
|
||||
E25DA5232CFF6C3700AEF16D /* ImageGenerator.swift in Sources */,
|
||||
E2F3B3832DC496CB00CFA712 /* GalleryBlock.swift in Sources */,
|
||||
@@ -1581,6 +1624,7 @@
|
||||
E2B85F432C4294F60047CD0C /* FeedEntry.swift in Sources */,
|
||||
E2F3B3A42DC7DC2400CFA712 /* GenerationIssuesView.swift in Sources */,
|
||||
E25DA56D2D00EBCF00AEF16D /* NavigationBarSettingsView.swift in Sources */,
|
||||
E2ADC02A2E5794AB00B4FF88 /* RouteOverview.swift in Sources */,
|
||||
E25DA5272CFF745700AEF16D /* URL+Extensions.swift in Sources */,
|
||||
E2FE0F642D2C2F4D002963B7 /* ButtonBlock.swift in Sources */,
|
||||
E2FD1D5E2D47EED200B48627 /* PostImageView.swift in Sources */,
|
||||
@@ -1775,7 +1819,7 @@
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 1.3;
|
||||
MARKETING_VERSION = 1.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.christophhagen.CHDataManagement;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
@@ -1814,7 +1858,7 @@
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 1.3;
|
||||
MARKETING_VERSION = 1.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.christophhagen.CHDataManagement;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
@@ -1849,6 +1893,14 @@
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
E224E0E32E56528F0031C2B0 /* XCRemoteSwiftPackageReference "BinaryCodable" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/christophhagen/BinaryCodable";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 3.1.0;
|
||||
};
|
||||
};
|
||||
E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/kyle-n/HighlightedTextEditor";
|
||||
@@ -1924,6 +1976,11 @@
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
E224E0E42E56528F0031C2B0 /* BinaryCodable */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = E224E0E32E56528F0031C2B0 /* XCRemoteSwiftPackageReference "BinaryCodable" */;
|
||||
productName = BinaryCodable;
|
||||
};
|
||||
E24252002C50E0A40029FF16 /* HighlightedTextEditor */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "747e13d88856438f8013440b6d706faa50b8e06e8a370d5c6bbfaf192255f3ff",
|
||||
"originHash" : "6a373ae0a2cc4ad97293e2b13e76aa783451436d6a17beb2295cd5e9b2067122",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "async-http-client",
|
||||
@@ -19,6 +19,15 @@
|
||||
"version" : "1.20.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "binarycodable",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/christophhagen/BinaryCodable",
|
||||
"state" : {
|
||||
"revision" : "4febea33ee5d813fd9c94c9158be6c85472480d2",
|
||||
"version" : "3.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "console-kit",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -125,6 +125,15 @@ final class FileResource: Item, LocalizedItem {
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func save(fileData: Foundation.Data) -> Bool {
|
||||
guard content.storage.save(fileData: fileData, for: identifier) else {
|
||||
return false
|
||||
}
|
||||
modifiedDate = .now
|
||||
return true
|
||||
}
|
||||
|
||||
func dataContent() -> Foundation.Data? {
|
||||
content.storage.fileData(for: identifier)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ enum FileTypeCategory: String, CaseIterable {
|
||||
case video
|
||||
case resource
|
||||
case audio
|
||||
case route
|
||||
|
||||
var text: String {
|
||||
switch self {
|
||||
@@ -19,6 +20,7 @@ enum FileTypeCategory: String, CaseIterable {
|
||||
case .video: return "Videos"
|
||||
case .resource: return "Other"
|
||||
case .audio: return "Audio"
|
||||
case .route: return "Route"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +33,7 @@ enum FileTypeCategory: String, CaseIterable {
|
||||
case .video: .video
|
||||
case .resource: .zipperPage
|
||||
case .audio: .speakerWave2CircleFill
|
||||
case .route: .map
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,6 +141,10 @@ enum FileType: String {
|
||||
|
||||
case psd
|
||||
|
||||
// MARK: Route
|
||||
|
||||
case route
|
||||
|
||||
// MARK: Unknown
|
||||
|
||||
case unknown
|
||||
@@ -174,6 +181,8 @@ enum FileType: String {
|
||||
return .model
|
||||
case .zip, .cddx, .pdf, .key, .psd, .ttf:
|
||||
return .resource
|
||||
case .route:
|
||||
return .route
|
||||
case .noExtension, .unknown:
|
||||
return .resource
|
||||
}
|
||||
|
||||
@@ -414,6 +414,12 @@ final class Storage: ObservableObject {
|
||||
return contentScope.readData(at: path)
|
||||
}
|
||||
|
||||
func save(fileData: Data, for fileId: String) -> Bool {
|
||||
guard let contentScope else { return false }
|
||||
let path = filePath(file: fileId)
|
||||
return contentScope.write(fileData, to: path)
|
||||
}
|
||||
|
||||
func save(fileContent: String, for fileId: String) -> Bool {
|
||||
guard let contentScope else { return false }
|
||||
let path = filePath(file: fileId)
|
||||
|
||||
@@ -77,6 +77,9 @@ struct FileContentView: View {
|
||||
.font(.title)
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
case .route:
|
||||
RoutePreviewView(file: file)
|
||||
}
|
||||
}
|
||||
}.padding()
|
||||
|
||||
49
CHDataManagement/Workouts/ElevationGraph.swift
Normal file
49
CHDataManagement/Workouts/ElevationGraph.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct ElevationSample: Identifiable {
|
||||
|
||||
let timestamp: Date
|
||||
|
||||
let altitude: Double
|
||||
|
||||
var id: Date {
|
||||
timestamp
|
||||
}
|
||||
}
|
||||
|
||||
struct ElevationGraph: View {
|
||||
|
||||
let samples: [ElevationSample]
|
||||
|
||||
var body: some View {
|
||||
Chart {
|
||||
// Active segments as area + line
|
||||
ForEach(samples) { sample in
|
||||
LineMark(
|
||||
x: .value("Time", sample.timestamp),
|
||||
y: .value("Altitude", sample.altitude)
|
||||
)
|
||||
.foregroundStyle(by: .value("Series", "Altitude"))
|
||||
//.interpolationMethod(.catmullRom)
|
||||
|
||||
AreaMark(
|
||||
x: .value("Time", sample.timestamp),
|
||||
y: .value("Altitude", sample.altitude)
|
||||
)
|
||||
//.interpolationMethod(.catmullRom)
|
||||
.foregroundStyle(LinearGradient(
|
||||
gradient: Gradient(colors: [.blue.opacity(0.8), .blue.opacity(0.2)]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading)
|
||||
}
|
||||
.chartXScale(domain: samples.first!.timestamp...samples.last!.timestamp)
|
||||
.frame(width: 700, height: 220)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
261
CHDataManagement/Workouts/Locations+Sampled.swift
Normal file
261
CHDataManagement/Workouts/Locations+Sampled.swift
Normal file
@@ -0,0 +1,261 @@
|
||||
import CoreLocation
|
||||
|
||||
extension Array where Element == CLLocation {
|
||||
|
||||
/**
|
||||
Sample the locations using a given time interval.
|
||||
*/
|
||||
func samplePeriodically(at interval: TimeInterval) -> [CLLocation] {
|
||||
guard interval > 0 else { return [] }
|
||||
guard let start = first, let end = last else { return self }
|
||||
let totalTime = end.timestamp.timeIntervalSince(start.timestamp)
|
||||
let numberOfSamples = Int((totalTime / interval).rounded(.up))
|
||||
return periodicSamples(interval: interval, numberOfSamples: numberOfSamples)
|
||||
}
|
||||
|
||||
/**
|
||||
Sample the locations at a fixed period determined by the number of desired sampels
|
||||
*/
|
||||
func samplePeriodically(numberOfSamples: Int) -> [CLLocation] {
|
||||
guard numberOfSamples > 0 else { return [] }
|
||||
guard let start = first, let end = last else { return self }
|
||||
let totalTime = end.timestamp.timeIntervalSince(start.timestamp)
|
||||
let timeInterval = totalTime / TimeInterval(count - 1)
|
||||
return periodicSamples(interval: timeInterval, numberOfSamples: numberOfSamples)
|
||||
}
|
||||
|
||||
private func periodicSamples(interval: TimeInterval, numberOfSamples: Int) -> [CLLocation] {
|
||||
guard let start = first else { return [] }
|
||||
var currentIndex = 0
|
||||
var currentTime = start.timestamp
|
||||
|
||||
var samples = [start]
|
||||
for _ in 1..<numberOfSamples {
|
||||
currentTime = currentTime.addingTimeInterval(interval)
|
||||
while true {
|
||||
let nextIndex = currentIndex + 1
|
||||
if nextIndex >= count { break }
|
||||
let nextTime = self[nextIndex].timestamp
|
||||
if nextTime > currentTime { break }
|
||||
currentIndex += 1
|
||||
}
|
||||
if currentIndex + 1 == count {
|
||||
samples.append(self[currentIndex])
|
||||
} else {
|
||||
let before = self[currentIndex]
|
||||
let after = self[currentIndex + 1]
|
||||
let interpolated = before.interpolate(currentTime, to: after)
|
||||
samples.append(interpolated)
|
||||
}
|
||||
}
|
||||
return samples
|
||||
}
|
||||
|
||||
/// Computes path length by moving along center-to-center lines, intersecting uncertainty spheres
|
||||
func minimumTraveledDistance3D() -> CLLocationDistance {
|
||||
guard count > 1 else { return 0 }
|
||||
|
||||
// Remove the uncertainty radius of the first location
|
||||
var current = self.first!
|
||||
var totalDistance: CLLocationDistance = -current.uncertaintyRadius3D
|
||||
for next in self[1...] {
|
||||
let (movement, point) = current.minimumDistance(to: next)
|
||||
current = point
|
||||
totalDistance += movement
|
||||
}
|
||||
return totalDistance
|
||||
}
|
||||
|
||||
/// Calculates the minimum possible ascended altitude (meters),
|
||||
/// considering vertical accuracy as an uncertainty interval.
|
||||
func minimumAscendedAltitude() -> CLLocationDistance {
|
||||
guard let first = self.first else { return 0 }
|
||||
|
||||
// Start with the highest possible value of the first point
|
||||
var currentAltitude = first.altitude + first.verticalAccuracy
|
||||
var ascended: CLLocationDistance = 0
|
||||
|
||||
for next in self.dropFirst() {
|
||||
let newMin = next.altitude - next.verticalAccuracy
|
||||
let newMax = next.altitude + next.verticalAccuracy
|
||||
|
||||
if newMin > currentAltitude {
|
||||
// Lower bound must be adjusted
|
||||
ascended += newMin - currentAltitude
|
||||
currentAltitude = newMin
|
||||
} else if newMax < currentAltitude {
|
||||
// Upper bound must be adjusted
|
||||
currentAltitude = newMax
|
||||
}
|
||||
}
|
||||
return ascended
|
||||
}
|
||||
|
||||
/// Calculates the minimum possible ascended altitude (meters),
|
||||
/// considering a given vertical accuracy threshold
|
||||
func minimumAscendedAltitude(threshold: CLLocationDistance) -> CLLocationDistance {
|
||||
guard let first = self.first else { return 0 }
|
||||
|
||||
// Start with the highest possible value of the first point
|
||||
var currentAltitude = first.altitude + threshold
|
||||
var ascended: CLLocationDistance = 0
|
||||
|
||||
for next in self.dropFirst() {
|
||||
let newMin = next.altitude - threshold
|
||||
let newMax = next.altitude + threshold
|
||||
|
||||
if newMin > currentAltitude {
|
||||
// Lower bound must be adjusted
|
||||
ascended += newMin - currentAltitude
|
||||
currentAltitude = newMin
|
||||
} else if newMax < currentAltitude {
|
||||
// Upper bound must be adjusted
|
||||
currentAltitude = newMax
|
||||
}
|
||||
}
|
||||
return ascended
|
||||
}
|
||||
|
||||
func interpolateAltitudes(
|
||||
from startDate: Date,
|
||||
to endDate: Date
|
||||
) -> [CLLocation] {
|
||||
|
||||
// Ensure valid range
|
||||
guard startDate < endDate else { return self }
|
||||
|
||||
// Find first and last locations in the window
|
||||
guard
|
||||
let startLocation = first(where: { $0.timestamp >= startDate }),
|
||||
let endLocation = last(where: { $0.timestamp <= endDate })
|
||||
else {
|
||||
return self // No valid range found
|
||||
}
|
||||
|
||||
let startAltitude = startLocation.altitude
|
||||
let endAltitude = endLocation.altitude
|
||||
let duration = endDate.timeIntervalSince(startDate)
|
||||
|
||||
return map { loc in
|
||||
let t = loc.timestamp.timeIntervalSince1970
|
||||
|
||||
if loc.timestamp >= startDate && loc.timestamp <= endDate {
|
||||
let progress = (loc.timestamp.timeIntervalSince(startDate)) / duration
|
||||
let newAltitude = startAltitude + progress * (endAltitude - startAltitude)
|
||||
|
||||
return CLLocation(
|
||||
coordinate: loc.coordinate,
|
||||
altitude: newAltitude,
|
||||
horizontalAccuracy: loc.horizontalAccuracy,
|
||||
verticalAccuracy: loc.verticalAccuracy,
|
||||
course: loc.course,
|
||||
speed: loc.speed,
|
||||
timestamp: loc.timestamp
|
||||
)
|
||||
} else {
|
||||
return loc // outside window, unchanged
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CLLocation {
|
||||
|
||||
/// Combined uncertainty sphere radius (meters) from horizontal+vertical accuracy
|
||||
var uncertaintyRadius3D: CLLocationDistance {
|
||||
let h = max(0, horizontalAccuracy)
|
||||
let v = max(0, verticalAccuracy)
|
||||
return sqrt(h * h + v * v)
|
||||
}
|
||||
|
||||
func verticalDistance(from other: CLLocation) -> CLLocationDistance {
|
||||
abs(self.altitude - other.altitude)
|
||||
}
|
||||
|
||||
func minimumDistance(to other: CLLocation) -> (distance: CLLocationDistance, point: CLLocation) {
|
||||
let horizontalDistance = distance(from: other)
|
||||
let horizontalMovement = Swift.max(0, horizontalDistance - Swift.max(0, other.horizontalAccuracy))
|
||||
|
||||
let latitude: CLLocationDegrees
|
||||
let longitude: CLLocationDegrees
|
||||
if horizontalDistance == 0 || horizontalMovement == 0 {
|
||||
latitude = coordinate.latitude
|
||||
longitude = coordinate.longitude
|
||||
} else {
|
||||
let horizontalRatio = horizontalMovement / horizontalDistance
|
||||
latitude = coordinate.latitude.move(horizontalRatio, to: other.coordinate.latitude)
|
||||
longitude = coordinate.longitude.move(horizontalRatio, to: other.coordinate.longitude)
|
||||
}
|
||||
|
||||
let verticalDistance = verticalDistance(from: other)
|
||||
let verticalMovement = Swift.max(0, verticalDistance - Swift.max(0, other.verticalAccuracy))
|
||||
|
||||
let altitude: CLLocationDistance
|
||||
if verticalDistance == 0 || verticalMovement == 0 {
|
||||
altitude = self.altitude
|
||||
} else {
|
||||
let verticalRatio = verticalMovement / verticalDistance
|
||||
altitude = self.altitude.move(verticalRatio, to: other.altitude)
|
||||
}
|
||||
|
||||
let movement = sqrt(horizontalMovement * horizontalMovement + verticalMovement * verticalMovement)
|
||||
let point = CLLocation(
|
||||
coordinate: .init(latitude: latitude, longitude: longitude),
|
||||
altitude: altitude,
|
||||
horizontalAccuracy: 0,
|
||||
verticalAccuracy: 0,
|
||||
timestamp: other.timestamp
|
||||
)
|
||||
return (movement, point)
|
||||
}
|
||||
|
||||
func interpolate(_ time: Date, to other: CLLocation) -> CLLocation {
|
||||
if self.timestamp > other.timestamp {
|
||||
return other.interpolate(time, to: self)
|
||||
}
|
||||
let totalDuration = other.timestamp.timeIntervalSince(self.timestamp)
|
||||
if totalDuration == 0 { return move(0.5, to: other) }
|
||||
let ratio = time.timeIntervalSince(self.timestamp) / totalDuration
|
||||
return move(ratio, to: other)
|
||||
}
|
||||
|
||||
func move(_ ratio: Double, to other: CLLocation) -> CLLocation {
|
||||
if ratio <= 0 { return self }
|
||||
if ratio >= 1 { return other }
|
||||
|
||||
let time = timestamp.addingTimeInterval(other.timestamp.timeIntervalSince(timestamp) * ratio)
|
||||
|
||||
return CLLocation(
|
||||
coordinate: .init(
|
||||
latitude: coordinate.latitude.move(ratio, to: other.coordinate.latitude),
|
||||
longitude: coordinate.longitude.move(ratio, to: other.coordinate.longitude)),
|
||||
altitude: altitude.move(ratio, to: other.altitude),
|
||||
horizontalAccuracy: move(from: horizontalAccuracy, to: other.horizontalAccuracy, by: ratio),
|
||||
verticalAccuracy: move(from: verticalAccuracy, to: other.verticalAccuracy, by: ratio),
|
||||
course: move(from: course, to: other.course, by: ratio),
|
||||
courseAccuracy: move(from: courseAccuracy, to: other.courseAccuracy, by: ratio),
|
||||
speed: move(from: speed, to: other.speed, by: ratio),
|
||||
speedAccuracy: move(from: speedAccuracy, to: other.speedAccuracy, by: ratio),
|
||||
timestamp: time)
|
||||
}
|
||||
|
||||
private func move(from source: Double, to other: Double, by ratio: Double) -> Double {
|
||||
if source == -1 {
|
||||
return other
|
||||
}
|
||||
if other == -1 {
|
||||
return source
|
||||
}
|
||||
return source.move(ratio, to: other)
|
||||
}
|
||||
}
|
||||
|
||||
extension Double {
|
||||
|
||||
/**
|
||||
Move to a different value by the given ratio of their distance.
|
||||
*/
|
||||
func move(_ ratio: Double, to other: Double) -> Double {
|
||||
self + (other - self) * ratio
|
||||
}
|
||||
}
|
||||
83
CHDataManagement/Workouts/MapImageCreator.swift
Normal file
83
CHDataManagement/Workouts/MapImageCreator.swift
Normal file
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import MapKit
|
||||
|
||||
struct MapImageCreator {
|
||||
|
||||
let locations: [CLLocation]
|
||||
|
||||
func createMapSnapshot(
|
||||
size layoutSize: CGSize,
|
||||
scale: CGFloat = 2.0,
|
||||
lineWidth: CGFloat = 5,
|
||||
paddingFactor: Double = 1.2,
|
||||
completion: @escaping ((image: NSImage, imagePoints: [CGPoint])?) -> Void
|
||||
) {
|
||||
guard !locations.isEmpty else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
let coordinates = locations.map { $0.coordinate }
|
||||
|
||||
let pixelSize = CGSize(width: layoutSize.width * scale, height: layoutSize.height * scale)
|
||||
|
||||
let options = MKMapSnapshotter.Options()
|
||||
options.size = pixelSize
|
||||
options.preferredConfiguration = MKHybridMapConfiguration(elevationStyle: .flat)
|
||||
|
||||
let polyline = MKPolyline(coordinates: coordinates, count: coordinates.count)
|
||||
let boundingMapRect = polyline.boundingMapRect
|
||||
let region = MKCoordinateRegion(boundingMapRect)
|
||||
|
||||
let latDelta = region.span.latitudeDelta * paddingFactor
|
||||
let lonDelta = region.span.longitudeDelta * paddingFactor
|
||||
let paddedRegion = MKCoordinateRegion(
|
||||
center: region.center,
|
||||
span: MKCoordinateSpan(latitudeDelta: latDelta, longitudeDelta: lonDelta)
|
||||
)
|
||||
|
||||
options.region = paddedRegion
|
||||
|
||||
let snapshotter = MKMapSnapshotter(options: options)
|
||||
snapshotter.start { snapshotOrNil, error in
|
||||
guard let snapshot = snapshotOrNil, error == nil else {
|
||||
print("Snapshot error: \(error?.localizedDescription ?? "unknown error")")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let image = NSImage(size: pixelSize)
|
||||
image.lockFocus()
|
||||
|
||||
snapshot.image.draw(in: CGRect(origin: .zero, size: pixelSize))
|
||||
|
||||
let path = NSBezierPath()
|
||||
path.lineJoinStyle = .round
|
||||
let imagePoints = coordinates.map { snapshot.point(for: $0) }
|
||||
|
||||
if let first = imagePoints.first {
|
||||
path.move(to: first)
|
||||
for point in imagePoints.dropFirst() {
|
||||
path.line(to: point)
|
||||
}
|
||||
|
||||
NSColor.systemBlue.setStroke()
|
||||
path.lineWidth = lineWidth * scale
|
||||
path.stroke()
|
||||
}
|
||||
|
||||
image.unlockFocus()
|
||||
|
||||
// Recalculate imagePoints since they were inside the drawing block
|
||||
let widthFactor = 1 / pixelSize.width
|
||||
let heightFactor = 1 / pixelSize.height
|
||||
let finalImagePoints = coordinates.map { coordinate in
|
||||
let point = snapshot.point(for: coordinate)
|
||||
return CGPoint(x: point.x * widthFactor,
|
||||
y: point.y * heightFactor)
|
||||
}
|
||||
|
||||
completion((image, finalImagePoints))
|
||||
}
|
||||
}
|
||||
}
|
||||
20
CHDataManagement/Workouts/RouteOverview.swift
Normal file
20
CHDataManagement/Workouts/RouteOverview.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
struct RouteOverview {
|
||||
|
||||
/// The total active energy in kcal
|
||||
let energy: Double
|
||||
|
||||
/// The total distance of the track in meters
|
||||
let distance: Double
|
||||
|
||||
/// The total duration in seconds
|
||||
let duration: TimeInterval
|
||||
|
||||
/// The total ascended altitude in meters
|
||||
let ascendedElevation: Double
|
||||
|
||||
let start: Date?
|
||||
|
||||
let end: Date?
|
||||
}
|
||||
87
CHDataManagement/Workouts/RoutePreviewView.swift
Normal file
87
CHDataManagement/Workouts/RoutePreviewView.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
import SwiftUI
|
||||
import CoreLocation
|
||||
|
||||
|
||||
struct RoutePreviewView: View {
|
||||
|
||||
private let iconSize: CGFloat = 150
|
||||
|
||||
@ObservedObject
|
||||
var file: FileResource
|
||||
|
||||
@State
|
||||
var overview: RouteOverview?
|
||||
|
||||
@State
|
||||
var message: String?
|
||||
|
||||
@State
|
||||
var elevation: [ElevationSample] = []
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Image(systemSymbol: .map)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode:.fit)
|
||||
.frame(width: iconSize)
|
||||
if let message {
|
||||
Text(message)
|
||||
.font(.title)
|
||||
} else if let overview {
|
||||
if let start = overview.start {
|
||||
if let end = overview.end {
|
||||
Text("\(start.formatted()) - \(end.formatted()) (\(overview.duration.timeString))")
|
||||
} else {
|
||||
Text(start.formatted())
|
||||
}
|
||||
}
|
||||
Text(String(format: "%.2f km (%.0f m ascended)", overview.distance / 1000, overview.ascendedElevation))
|
||||
Text("\(Int(overview.energy)) kcal")
|
||||
if !elevation.isEmpty {
|
||||
ElevationGraph(samples: elevation)
|
||||
.frame(width: 500, height: 200)
|
||||
.padding()
|
||||
}
|
||||
} else {
|
||||
Text("Loading route overview...")
|
||||
.font(.title)
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
.onAppear { loadOverview() }
|
||||
}
|
||||
|
||||
private func loadOverview() {
|
||||
guard overview == nil && message == nil else {
|
||||
return
|
||||
}
|
||||
Task {
|
||||
guard let data = file.dataContent() else {
|
||||
DispatchQueue.main.async {
|
||||
self.message = "Failed to get file data"
|
||||
}
|
||||
return
|
||||
}
|
||||
let route: WorkoutData
|
||||
do {
|
||||
route = try WorkoutData(data: data)
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
self.message = "Failed to decode route: \(error)"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let overview = route.overview
|
||||
|
||||
let elevations = route.locations.map { ElevationSample(timestamp: $0.timestamp, altitude: $0.altitude) }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.overview = overview
|
||||
self.elevation = elevations
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
102
CHDataManagement/Workouts/Sequence+Median.swift
Normal file
102
CHDataManagement/Workouts/Sequence+Median.swift
Normal file
@@ -0,0 +1,102 @@
|
||||
|
||||
// Store values with indices to handle duplicates uniquely
|
||||
private struct Entry<T: BinaryFloatingPoint>: Comparable {
|
||||
let index: Int
|
||||
let value: T
|
||||
static func < (lhs: Entry<T>, rhs: Entry<T>) -> Bool {
|
||||
lhs.value == rhs.value ? lhs.index < rhs.index : lhs.value < rhs.value
|
||||
}
|
||||
}
|
||||
|
||||
extension Sequence {
|
||||
/// Applies a centered median filter to the sequence.
|
||||
/// - Parameters:
|
||||
/// - windowSize: The number of samples in the median filter window (should be odd for symmetric centering).
|
||||
/// - transform: Closure to transform each element into a numeric value.
|
||||
/// - Returns: An array of filtered elements (same type as input).
|
||||
func medianFiltered<T: BinaryFloatingPoint>(windowSize: Int, transform: (Element) -> T) -> [Element] {
|
||||
precondition(windowSize > 0, "Window size must be greater than zero")
|
||||
let input = Array(self)
|
||||
guard !input.isEmpty else { return [] }
|
||||
|
||||
var result: [Element] = []
|
||||
result.reserveCapacity(input.count)
|
||||
|
||||
let halfWindow = windowSize / 2
|
||||
|
||||
for i in 0..<input.count {
|
||||
let start = Swift.max(0, i - halfWindow)
|
||||
let end = Swift.min(input.count - 1, i + halfWindow)
|
||||
var window: [Entry<T>] = []
|
||||
|
||||
for j in start...end {
|
||||
window.append(Entry(index: j, value: transform(input[j])))
|
||||
}
|
||||
|
||||
window.sort()
|
||||
|
||||
// Median position
|
||||
let medianIndex = window.count / 2
|
||||
let medianValue = window[medianIndex].value
|
||||
|
||||
// Choose the element closest to the median
|
||||
let closest = input[start...end]
|
||||
.min(by: { abs(Double(transform($0) - medianValue)) < abs(Double(transform($1) - medianValue)) })!
|
||||
|
||||
result.append(closest)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Default version when Element itself is BinaryFloatingPoint
|
||||
func medianFiltered(windowSize: Int) -> [Element] where Element: BinaryFloatingPoint {
|
||||
return self.medianFiltered(windowSize: windowSize, transform: { $0 })
|
||||
}
|
||||
|
||||
/// Iterate over adjacent pairs of elements in the sequence, applying a transform closure.
|
||||
/// - Parameter transform: A closure that takes two consecutive elements and returns a value of type T.
|
||||
/// - Returns: An array of transformed values.
|
||||
func adjacentPairs<T>(_ transform: (Element, Element) -> T) -> [T] {
|
||||
var result: [T] = []
|
||||
var iterator = self.makeIterator()
|
||||
guard var prev = iterator.next() else { return [] }
|
||||
while let current = iterator.next() {
|
||||
result.append(transform(prev, current))
|
||||
prev = current
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
extension Array where Element: Comparable {
|
||||
/// Binary search returning the index where `predicate` fails (insertion point).
|
||||
func binarySearch(predicate: (Element) -> Bool) -> Int {
|
||||
var low = 0
|
||||
var high = count
|
||||
while low < high {
|
||||
let mid = (low + high) / 2
|
||||
if predicate(self[mid]) {
|
||||
low = mid + 1
|
||||
} else {
|
||||
high = mid
|
||||
}
|
||||
}
|
||||
return low
|
||||
}
|
||||
|
||||
/// Binary search exact element index if present.
|
||||
func binarySearchExact(_ element: Element) -> Int? {
|
||||
var low = 0
|
||||
var high = count - 1
|
||||
while low <= high {
|
||||
let mid = (low + high) / 2
|
||||
if self[mid] == element { return mid }
|
||||
else if self[mid] < element { low = mid + 1 }
|
||||
else { high = mid - 1 }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
19
CHDataManagement/Workouts/Time+String.swift
Normal file
19
CHDataManagement/Workouts/Time+String.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
extension TimeInterval {
|
||||
|
||||
var timeString: String {
|
||||
let seconds = Int(rounded())
|
||||
guard seconds > 59 else {
|
||||
return "\(seconds) s"
|
||||
}
|
||||
let min = seconds / 60
|
||||
let secs = seconds % 60
|
||||
guard min > 59 else {
|
||||
return String(format: "%02d:%02d", min, secs)
|
||||
}
|
||||
let hours = min / 60
|
||||
let mins = min % 60
|
||||
return String(format: "%d:%02d:%02d", hours, mins, secs)
|
||||
}
|
||||
}
|
||||
221
CHDataManagement/Workouts/WorkoutData.swift
Normal file
221
CHDataManagement/Workouts/WorkoutData.swift
Normal file
@@ -0,0 +1,221 @@
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
import BinaryCodable
|
||||
|
||||
private struct TrackLocation {
|
||||
|
||||
let timestamp: TimeInterval
|
||||
|
||||
let latitude: Double
|
||||
|
||||
let longitude: Double
|
||||
|
||||
let speed: Double?
|
||||
|
||||
let speedAccuracy: Double?
|
||||
|
||||
let course: Double?
|
||||
|
||||
let courseAccuracy: Double?
|
||||
|
||||
let elevation: Double
|
||||
|
||||
let horizontalAccuracy: Double?
|
||||
|
||||
let verticalAccuracy: Double?
|
||||
|
||||
init(
|
||||
timestamp: TimeInterval,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
speed: Double,
|
||||
speedAccuracy: Double? = nil,
|
||||
course: Double,
|
||||
courseAccuracy: Double? = nil,
|
||||
elevation: Double,
|
||||
horizontalAccuracy: Double? = nil,
|
||||
verticalAccuracy: Double? = nil
|
||||
) {
|
||||
self.timestamp = timestamp
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
self.speed = speed
|
||||
self.speedAccuracy = speedAccuracy
|
||||
self.course = course
|
||||
self.courseAccuracy = courseAccuracy
|
||||
self.elevation = elevation
|
||||
self.horizontalAccuracy = horizontalAccuracy
|
||||
self.verticalAccuracy = verticalAccuracy
|
||||
}
|
||||
|
||||
init(location: CLLocation) {
|
||||
self.timestamp = location.timestamp.timeIntervalSince1970
|
||||
self.elevation = location.altitude
|
||||
self.latitude = location.coordinate.latitude
|
||||
self.longitude = location.coordinate.longitude
|
||||
self.speed = location.speed
|
||||
self.speedAccuracy = location.speedAccuracy
|
||||
self.course = location.course
|
||||
self.courseAccuracy = location.courseAccuracy
|
||||
self.horizontalAccuracy = location.horizontalAccuracy
|
||||
self.verticalAccuracy = location.verticalAccuracy
|
||||
}
|
||||
|
||||
var location: CLLocation {
|
||||
.init(
|
||||
coordinate: .init(
|
||||
latitude: latitude,
|
||||
longitude: longitude),
|
||||
altitude: elevation,
|
||||
horizontalAccuracy: horizontalAccuracy ?? -1,
|
||||
verticalAccuracy: verticalAccuracy ?? -1,
|
||||
course: course ?? -1,
|
||||
courseAccuracy: courseAccuracy ?? -1,
|
||||
speed: speed ?? -1,
|
||||
speedAccuracy: speedAccuracy ?? -1,
|
||||
timestamp: .init(timeIntervalSince1970: timestamp))
|
||||
}
|
||||
}
|
||||
|
||||
extension TrackLocation: Codable {
|
||||
|
||||
enum CodingKeys: Int, CodingKey {
|
||||
case timestamp = 1
|
||||
case latitude
|
||||
case longitude
|
||||
case speed
|
||||
case speedAccuracy
|
||||
case course
|
||||
case courseAccuracy
|
||||
case elevation
|
||||
case horizontalAccuracy
|
||||
case verticalAccuracy
|
||||
}
|
||||
}
|
||||
|
||||
extension WorkoutData {
|
||||
|
||||
struct Sample {
|
||||
|
||||
/// The unix time
|
||||
let timestamp: TimeInterval
|
||||
|
||||
let value: Double
|
||||
|
||||
init(timestamp: TimeInterval, value: Double) {
|
||||
self.timestamp = timestamp
|
||||
self.value = value
|
||||
}
|
||||
|
||||
var time: Date {
|
||||
.init(timeIntervalSince1970: timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WorkoutData.Sample: Codable {
|
||||
|
||||
enum CodingKeys: Int, CodingKey {
|
||||
case timestamp = 1
|
||||
case value
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkoutData {
|
||||
|
||||
let locations: [CLLocation]
|
||||
|
||||
let heartRates: [Sample]
|
||||
|
||||
/// The active energy in kcal
|
||||
let energy: [Sample]
|
||||
|
||||
init(locations: [CLLocation], heartRates: [Sample], energy: [Sample]) {
|
||||
self.locations = locations
|
||||
self.heartRates = heartRates
|
||||
self.energy = energy
|
||||
}
|
||||
|
||||
func encoded() throws -> Data {
|
||||
let encoder = BinaryEncoder()
|
||||
return try encoder.encode(self)
|
||||
}
|
||||
|
||||
init(url: URL) throws {
|
||||
let data = try Data(contentsOf: url)
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
init(data: Data) throws {
|
||||
let decoder = BinaryDecoder()
|
||||
self = try decoder.decode(WorkoutData.self, from: data)
|
||||
}
|
||||
|
||||
/// The total active energy in kcal
|
||||
var totalEnergy: Double {
|
||||
energy.reduce(0) { $0 + $1.value }
|
||||
}
|
||||
|
||||
/// The total distance of the track in meters
|
||||
var totalDistance: CLLocationDistance {
|
||||
locations.minimumTraveledDistance3D()
|
||||
}
|
||||
|
||||
/// The total duration in seconds
|
||||
var totalDuration: TimeInterval {
|
||||
guard let start, let end else {
|
||||
return 0
|
||||
}
|
||||
return end.timeIntervalSince(start)
|
||||
}
|
||||
|
||||
/// The total ascended altitude in meters
|
||||
var totalAscendedElevation: CLLocationDistance {
|
||||
locations.minimumAscendedAltitude(threshold: 15)
|
||||
}
|
||||
|
||||
var overview: RouteOverview {
|
||||
.init(energy: totalEnergy,
|
||||
distance: totalDistance,
|
||||
duration: totalDuration,
|
||||
ascendedElevation: totalAscendedElevation,
|
||||
start: start,
|
||||
end: end)
|
||||
}
|
||||
|
||||
var start: Date? {
|
||||
let starts: [Date?] = [
|
||||
locations.first?.timestamp,
|
||||
heartRates.first?.time,
|
||||
energy.first?.time]
|
||||
return starts.compactMap { $0 }.min()
|
||||
}
|
||||
|
||||
var end: Date? {
|
||||
let ends: [Date?] = [locations.last?.timestamp, heartRates.last?.time, energy.last?.time]
|
||||
return ends.compactMap { $0 }.max()
|
||||
}
|
||||
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(locations.map(TrackLocation.init), forKey: .locations)
|
||||
try container.encode(heartRates, forKey: .heartRates)
|
||||
try container.encode(energy, forKey: .energy)
|
||||
}
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.locations = try container.decode([TrackLocation].self, forKey: .locations).map { $0.location }
|
||||
self.heartRates = try container.decode([Sample].self, forKey: .heartRates)
|
||||
self.energy = try container.decode([Sample].self, forKey: .energy)
|
||||
}
|
||||
}
|
||||
|
||||
extension WorkoutData: Codable {
|
||||
|
||||
enum CodingKeys: Int, CodingKey {
|
||||
case locations = 1
|
||||
case heartRates
|
||||
case energy
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user