diff --git a/CHDataManagement.xcodeproj/project.pbxproj b/CHDataManagement.xcodeproj/project.pbxproj index 04c4f16..944e18c 100644 --- a/CHDataManagement.xcodeproj/project.pbxproj +++ b/CHDataManagement.xcodeproj/project.pbxproj @@ -183,6 +183,7 @@ 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 */; }; + E2ADC02E2E57CC6900B4FF88 /* Double+Rounded.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2ADC02D2E57CC6500B4FF88 /* Double+Rounded.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 */; }; @@ -477,6 +478,7 @@ E2A9CB7D2C7BCF2A005C89CC /* Page.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Page.swift; sourceTree = ""; }; E2ADC0292E5794AB00B4FF88 /* RouteOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteOverview.swift; sourceTree = ""; }; E2ADC02B2E5795F000B4FF88 /* ElevationGraph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElevationGraph.swift; sourceTree = ""; }; + E2ADC02D2E57CC6500B4FF88 /* Double+Rounded.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Rounded.swift"; sourceTree = ""; }; E2B482022D5D132D005C309D /* WebServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebServer.swift; sourceTree = ""; }; E2B482042D5E7D4A005C309D /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; E2B482082D5E7F4C005C309D /* WebsitePreviewSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsitePreviewSheet.swift; sourceTree = ""; }; @@ -1074,6 +1076,7 @@ E2B85F552C4BD0AD0047CD0C /* Extensions */ = { isa = PBXGroup; children = ( + E2ADC02D2E57CC6500B4FF88 /* Double+Rounded.swift */, E2FE0F1F2D29A709002963B7 /* Array+Remove.swift */, E2FE0EE72D16D4A3002963B7 /* ConvertThrowing.swift */, E25DA5182CFF035200AEF16D /* Array+Split.swift */, @@ -1381,6 +1384,7 @@ files = ( E2FD1D562D46CED900B48627 /* Insert+Labels.swift in Sources */, E29D31242D0366860051B7F4 /* TagList.swift in Sources */, + E2ADC02E2E57CC6900B4FF88 /* Double+Rounded.swift in Sources */, E22990282D0F596C009F8D77 /* IntegerPropertyView.swift in Sources */, E2FD1D1D2D2DE31800B48627 /* ItemType.swift in Sources */, E2FE0F482D2BC7D1002963B7 /* MarkdownProcessor.swift in Sources */, diff --git a/CHDataManagement/Extensions/Double+Rounded.swift b/CHDataManagement/Extensions/Double+Rounded.swift new file mode 100644 index 0000000..b2e7178 --- /dev/null +++ b/CHDataManagement/Extensions/Double+Rounded.swift @@ -0,0 +1,7 @@ + +extension Double { + + func rounded(to interval: Double) -> Double { + (self / interval).rounded() * interval + } +} diff --git a/CHDataManagement/Model/FileResource.swift b/CHDataManagement/Model/FileResource.swift index 2c39436..3ae3380 100644 --- a/CHDataManagement/Model/FileResource.swift +++ b/CHDataManagement/Model/FileResource.swift @@ -298,6 +298,18 @@ final class FileResource: Item, LocalizedItem { return content.settings.general.url + version.outputPath } + // MARK: Workout + + var routeOverview: RouteOverview? { + guard type == .route else { + return nil + } + guard let data = dataContent() else { + return nil + } + return try? WorkoutData(data: data).overview + } + // MARK: Video thumbnail func createVideoThumbnail() { diff --git a/CHDataManagement/Model/LocalizedPost.swift b/CHDataManagement/Model/LocalizedPost.swift index eeaa6b2..8536ef0 100644 --- a/CHDataManagement/Model/LocalizedPost.swift +++ b/CHDataManagement/Model/LocalizedPost.swift @@ -77,6 +77,45 @@ final class LocalizedPost: ChangeObservingItem { var hasVideos: Bool { images.contains { $0.type.isVideo } } + + func updateLabels(from workout: RouteOverview, locale: Locale) { + insertOrReplace(label: .init(icon: .statisticsDistance, value: String(format: "%.1f km", locale: locale, workout.distance / 1000))) + insertOrReplace(label: .init(icon: .statisticsTime, value: workout.duration.duration(locale: locale))) + insertOrReplace(label: .init(icon: .statisticsElevationUp, value: workout.ascendedElevation.length(roundingToNearest: 50))) + insertOrReplace(label: .init(icon: .statisticsEnergy, value: workout.energy.energy(roundingToNearest: 50))) + } + + func insertOrReplace(label: ContentLabel) { + if let index = labels.firstIndex(where: { $0.icon == label.icon }) { + labels[index] = label + } else { + labels.append(label) + } + } +} + +private extension TimeInterval { + + func duration(locale: Locale) -> String { + let totalMinutes = Int((self / 60).rounded(to: 5)) + let hours = totalMinutes / 60 + let minutes = totalMinutes % 60 + + let suffix = locale.identifier.hasPrefix("de") ? "Std" : "h" + + return String(format: "%d:%02d ", hours, minutes) + suffix + } + + func length(roundingToNearest interval: Double) -> String { + let rounded = Int(self.rounded(to: interval)) + return "\(rounded) m" + } + + func energy(roundingToNearest interval: Double) -> String { + let rounded = Int(self.rounded(to: interval)) + return "\(rounded) kcal" + } + } // MARK: Storage diff --git a/CHDataManagement/Model/Post.swift b/CHDataManagement/Model/Post.swift index 022d7a5..f988a47 100644 --- a/CHDataManagement/Model/Post.swift +++ b/CHDataManagement/Model/Post.swift @@ -38,6 +38,10 @@ final class Post: Item, DateItem, LocalizedItem { @Published var linkedPage: Page? + /// The workout associated with the post + @Published + var associatedWorkout: FileResource? + init(content: Content, id: String, isDraft: Bool, @@ -47,7 +51,8 @@ final class Post: Item, DateItem, LocalizedItem { tags: [Tag], german: LocalizedPost, english: LocalizedPost, - linkedPage: Page? = nil) { + linkedPage: Page? = nil, + associatedWorkout: FileResource? = nil) { self.isDraft = isDraft self.createdDate = createdDate self.startDate = startDate @@ -57,6 +62,7 @@ final class Post: Item, DateItem, LocalizedItem { self.german = german self.english = english self.linkedPage = linkedPage + self.associatedWorkout = associatedWorkout super.init(content: content, id: id) } @@ -174,6 +180,14 @@ final class Post: Item, DateItem, LocalizedItem { english: english, tags: tags) } + + func updateLabelsFromWorkout() { + guard let overview = associatedWorkout?.routeOverview else { + return + } + german.updateLabels(from: overview, locale: Locale(identifier: "de_DE")) + english.updateLabels(from: overview, locale: Locale(identifier: "en_US")) + } } extension Post: StorageItem { @@ -189,7 +203,8 @@ extension Post: StorageItem { tags: data.tags.compactMap(context.tag), german: .init(context: context, data: data.german), english: .init(context: context, data: data.english), - linkedPage: data.linkedPageId.map(context.page)) + linkedPage: data.linkedPageId.map(context.page), + associatedWorkout: data.associatedWorkoutId.map(context.file)) savedData = data } @@ -202,6 +217,7 @@ extension Post: StorageItem { let german: LocalizedPost.Data let english: LocalizedPost.Data let linkedPageId: String? + let associatedWorkoutId: String? } var data: Data { @@ -213,7 +229,8 @@ extension Post: StorageItem { tags: tags.map { $0.identifier }, german: german.data, english: english.data, - linkedPageId: linkedPage?.identifier) + linkedPageId: linkedPage?.identifier, + associatedWorkoutId: associatedWorkout?.identifier) } func saveToDisk(_ data: Data) -> Bool { diff --git a/CHDataManagement/Views/Posts/PostContentView.swift b/CHDataManagement/Views/Posts/PostContentView.swift index d863774..21600bb 100644 --- a/CHDataManagement/Views/Posts/PostContentView.swift +++ b/CHDataManagement/Views/Posts/PostContentView.swift @@ -28,7 +28,8 @@ struct PostContentView: View { TagDisplayView(tags: $post.tags) } PostLabelsView( - post: localized, + post: post, + localized: localized, other: other) PostTextView(post: localized) } diff --git a/CHDataManagement/Views/Posts/PostDetailView.swift b/CHDataManagement/Views/Posts/PostDetailView.swift index 12c3169..ce8ff4f 100644 --- a/CHDataManagement/Views/Posts/PostDetailView.swift +++ b/CHDataManagement/Views/Posts/PostDetailView.swift @@ -81,6 +81,12 @@ struct PostDetailView: View { } } + FilePropertyView( + title: "Associated workout", + footer: "The workout file to display with this post", + selectedFile: $post.associatedWorkout, + allowedType: .route) + LocalizedPostDetailView( post: post.localized(in: language), transferImage: transferImage) diff --git a/CHDataManagement/Views/Posts/PostLabelsView.swift b/CHDataManagement/Views/Posts/PostLabelsView.swift index b69186c..570ef2d 100644 --- a/CHDataManagement/Views/Posts/PostLabelsView.swift +++ b/CHDataManagement/Views/Posts/PostLabelsView.swift @@ -3,7 +3,10 @@ import SwiftUI struct PostLabelsView: View { @ObservedObject - var post: LocalizedPost + var post: Post + + @ObservedObject + var localized: LocalizedPost @ObservedObject var other: LocalizedPost @@ -17,11 +20,11 @@ struct PostLabelsView: View { var body: some View { ScrollView(.horizontal) { HStack(spacing: 5) { - if post.labels.isEmpty { + if localized.labels.isEmpty { Text("Labels") .font(.headline) } - ForEach(post.labels) { label in + ForEach(localized.labels) { label in HStack { PageIconView(icon: label.icon) .frame(maxWidth: 16, maxHeight: 16) @@ -45,16 +48,16 @@ struct PostLabelsView: View { }.buttonStyle(.plain) if !other.labels.isEmpty { Button("Transfer") { - post.labels = other.labels.map { + localized.labels = other.labels.map { // Copy instead of reference ContentLabel(icon: $0.icon, value: $0.value) } } } - if !post.labels.isEmpty { + if !localized.labels.isEmpty { Button("Copy") { var command = "```labels" - for label in post.labels { + for label in localized.labels { command += "\n\(label.icon.rawValue): \(label.value)" } command += "\n```" @@ -63,23 +66,28 @@ struct PostLabelsView: View { pasteboard.setString(command, forType: .string) } } + if let workout = post.associatedWorkout { + Button("From workout") { + post.updateLabelsFromWorkout() + } + } } .padding(.vertical, 2) } .sheet(isPresented: $showLabelEditor) { - LabelModificationView(labels: $post.labels) + LabelModificationView(labels: $localized.labels) } } func addLabel() { - post.labels.append(.init(icon: .clockFill, value: "Value")) + localized.labels.append(.init(icon: .clockFill, value: "Value")) } func remove(_ label: ContentLabel) { - guard let index = post.labels.firstIndex(of: label) else { + guard let index = localized.labels.firstIndex(of: label) else { return } - post.labels.remove(at: index) + localized.labels.remove(at: index) } }