diff --git a/README.md b/README.md index 4931f5f..5acee12 100644 --- a/README.md +++ b/README.md @@ -11,19 +11,21 @@ An iOS app for trimming and managing HealthKit workouts. Built for the common pr ## Features ### Workout List +- Animated gradient header with icon colors (cyan, lime green, hot pink) - Workouts grouped by month/year with section headers - **Search** by activity type name (case-insensitive) - **Sort** by date (default), longest duration, or shortest duration — useful for quickly finding forgot-to-stop workouts or accidental recordings - Paginated loading (50 at a time) with infinite scroll -- Pull-to-refresh +- Pull-to-refresh and automatic refresh via HealthKit observer (updates when workouts are added or deleted externally) -### Workout Detail & Trimming -- **Activity graph** showing heart rate, active energy, or distance over time (auto-selects the best available metric) -- **Range slider** to visually select the portion of the workout to keep -- Dimmed overlay on the graph showing which portions will be trimmed -- Live-updating start time, end time, and duration display -- **Activity type picker** with 20 supported workout types -- **Delete workout** with confirmation +### Guided Trim Flow +The workout detail view walks you through a step-by-step process: + +1. **Trim and save** — Activity graph showing heart rate, active energy, or distance over time. Range slider to select the portion to keep, with live-updating start/end times, duration, and activity type picker. Explainer text describes what data is preserved and what is lost. +2. **Upload to Strava** (optional) — Upload the trimmed workout directly to Strava via OAuth. Shown after saving, with connect/upload/status inline. +3. **Remove the original** — Instructions to delete the original in Apple Fitness (swipe left → "Delete Workout & Data"), with an "Open Fitness" button. If the original was created by Overrun, it is automatically deleted. + +Steps 2 and 3 are visible but dimmed before step 1 is complete, so you know the full process before you begin. ### Data-Preserving Trim When you trim a workout, Overrun creates a new workout and migrates all associated data within the trimmed time range: @@ -37,7 +39,11 @@ When you trim a workout, Overrun creates a new workout and migrates all associat - **Metadata** (indoor/outdoor flag, weather, timezone, METs, etc.) - **Configuration** — activity type, indoor/outdoor location type, swimming config -If the original workout was created by our app, it is automatically deleted after the copy is saved. If it was created by another app (e.g., Apple Watch), you'll be prompted to delete it manually in Apple Fitness. +### Strava Integration +- OAuth authentication via the Strava app (falls back to Safari if not installed) +- Direct upload of workouts as TCX files with heart rate and GPS data +- Upload status tracking with polling (uploading → processing → success) +- Connect/disconnect from anywhere in the trim flow ## Known Limitations @@ -57,7 +63,7 @@ If the original workout was created by our app, it is automatically deleted afte - **Strava only imports workouts from Apple's native Workout app.** Strava checks the `sourceRevision` bundle identifier on each HealthKit workout and only accepts workouts from Apple's Workout app and Apple Fitness+. Workouts written by any third-party app (including Overrun) are intentionally excluded from Strava's HealthKit import list, regardless of how complete the data is. - **This is a Strava policy, not a data issue.** The workout data we write is fully valid and appears correctly in Apple Health and Apple Fitness. -- **Workarounds:** Export as .FIT/.TCX/.GPX and upload to Strava via file upload, use bridge apps like HealthFit or RunGap, or use direct Strava API integration. +- **Direct upload works.** Overrun bypasses the HealthKit import limitation by uploading directly to Strava's API via OAuth + TCX file upload. ## Requirements @@ -69,4 +75,29 @@ If the original workout was created by our app, it is automatically deleted afte Open `WorkoutEditor.xcodeproj` in Xcode and run on a device or simulator. The app requires HealthKit authorization to read and write workouts and associated samples. +### Strava Setup + +1. Register an API application at [strava.com/settings/api](https://www.strava.com/settings/api) +2. Set the Authorization Callback Domain to `localhost` +3. Copy `Secrets.xcconfig.template` to `Secrets.xcconfig` and fill in your Client ID and Client Secret +4. `Secrets.xcconfig` is gitignored and will not be committed + In debug builds, 200 sample workouts are auto-generated on first launch for testing. + +## Localization + +The app is set up for internationalization using Xcode String Catalogs (`.xcstrings`). All user-facing strings in SwiftUI views are automatically extracted by Xcode. + +### Contributing a Translation + +1. Open the project in Xcode +2. Go to **Project Settings → Info → Localizations** and add your language +3. Xcode will create localized entries in `Localizable.xcstrings` and `InfoPlist.xcstrings` +4. Open `Localizable.xcstrings` in Xcode's String Catalog editor and translate each string +5. `InfoPlist.xcstrings` contains the app name and HealthKit usage descriptions — translate those too +6. Activity type names (Running, Cycling, etc.) and all step/button labels are included in the catalog + +### Files + +- `WorkoutEditor/Localizable.xcstrings` — all app UI strings +- `WorkoutEditor/InfoPlist.xcstrings` — Info.plist strings (app name, permission descriptions) diff --git a/WorkoutEditor.xcodeproj/project.pbxproj b/WorkoutEditor.xcodeproj/project.pbxproj index 97d50e5..2d42e9f 100644 --- a/WorkoutEditor.xcodeproj/project.pbxproj +++ b/WorkoutEditor.xcodeproj/project.pbxproj @@ -17,6 +17,8 @@ AA000001AAAA000100000001 /* ActivityGraphView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000001AAAA000100000002 /* ActivityGraphView.swift */; }; AA000002AAAA000200000001 /* RangeSliderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002AAAA000200000002 /* RangeSliderView.swift */; }; AA000003AAAA000300000001 /* StravaManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000003AAAA000300000002 /* StravaManager.swift */; }; + AA000005AAAA000500000001 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = AA000005AAAA000500000002 /* Localizable.xcstrings */; }; + AA000006AAAA000600000001 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = AA000006AAAA000600000002 /* InfoPlist.xcstrings */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -35,6 +37,8 @@ AA000002AAAA000200000002 /* RangeSliderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RangeSliderView.swift; sourceTree = ""; }; AA000003AAAA000300000002 /* StravaManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StravaManager.swift; sourceTree = ""; }; AA000004AAAA000400000001 /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = ""; }; + AA000005AAAA000500000002 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + AA000006AAAA000600000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -87,6 +91,8 @@ 06E7DFC42B3654500025260F /* WorkoutListView.swift */, 06E7DFC22B3653F70025260F /* HealthKitManager.swift */, AA000003AAAA000300000002 /* StravaManager.swift */, + AA000005AAAA000500000002 /* Localizable.xcstrings */, + AA000006AAAA000600000002 /* InfoPlist.xcstrings */, 06E7DFAF2B3608E00025260F /* Assets.xcassets */, 06E7DFB12B3608E00025260F /* Preview Content */, ); @@ -161,6 +167,8 @@ files = ( 06E7DFB32B3608E00025260F /* Preview Assets.xcassets in Resources */, 224660472F4F47C700A98FB5 /* Overrun.icon in Resources */, + AA000005AAAA000500000001 /* Localizable.xcstrings in Resources */, + AA000006AAAA000600000001 /* InfoPlist.xcstrings in Resources */, 06E7DFB02B3608E00025260F /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -304,7 +312,7 @@ 06E7DFB72B3608E00025260F /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = Overrun; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = WorkoutEditor/WorkoutEditor.entitlements; CODE_SIGN_STYLE = Automatic; @@ -335,7 +343,7 @@ 06E7DFB82B3608E00025260F /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = Overrun; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = WorkoutEditor/WorkoutEditor.entitlements; CODE_SIGN_STYLE = Automatic; diff --git a/WorkoutEditor/HealthKitManager.swift b/WorkoutEditor/HealthKitManager.swift index 092e554..8b42d92 100644 --- a/WorkoutEditor/HealthKitManager.swift +++ b/WorkoutEditor/HealthKitManager.swift @@ -46,6 +46,17 @@ class HealthKitManager { var canLoadMore = false private let healthStore = HKHealthStore() private let pageSize = 50 + private var observerQuery: HKObserverQuery? + + func startObservingWorkouts() { + guard observerQuery == nil else { return } + let query = HKObserverQuery(sampleType: HKObjectType.workoutType(), predicate: nil) { [weak self] _, _, _ in + guard let self else { return } + Task { await self.loadWorkouts() } + } + healthStore.execute(query) + observerQuery = query + } func requestAuthorization() async throws { guard HKHealthStore.isHealthDataAvailable() else { diff --git a/WorkoutEditor/InfoPlist.xcstrings b/WorkoutEditor/InfoPlist.xcstrings new file mode 100644 index 0000000..46d72e5 --- /dev/null +++ b/WorkoutEditor/InfoPlist.xcstrings @@ -0,0 +1,51 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "CFBundleDisplayName" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overrun" + } + } + } + }, + "CFBundleName" : { + "comment" : "Bundle name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "WorkoutEditor" + } + } + } + }, + "NSHealthShareUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overrun needs to read your workouts so you can view and edit them." + } + } + } + }, + "NSHealthUpdateUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overrun needs to save changes when you edit or trim your workouts." + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/WorkoutEditor/Localizable.xcstrings b/WorkoutEditor/Localizable.xcstrings new file mode 100644 index 0000000..490a8a9 --- /dev/null +++ b/WorkoutEditor/Localizable.xcstrings @@ -0,0 +1,232 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "" : { + + }, + "%lld" : { + "comment" : "A y-axis label in the activity graph view, showing an integer value.", + "isCommentAutoGenerated" : true + }, + "Activity Type" : { + "comment" : "A label displayed next to a picker for selecting the type of an activity.", + "isCommentAutoGenerated" : true + }, + "Boxing" : { + "comment" : "Name of a workout activity type.", + "isCommentAutoGenerated" : true + }, + "Climbing" : { + "comment" : "Name for a workout activity that is \"Climbing\".", + "isCommentAutoGenerated" : true + }, + "Complete step 1 first." : { + "comment" : "A message displayed within the \"Remove the original\" step, instructing the user to complete the first step before they can access this one.", + "isCommentAutoGenerated" : true + }, + "Connect Strava" : { + "comment" : "A button label that prompts the user to connect their Strava account.", + "isCommentAutoGenerated" : true + }, + "Cooldown" : { + "comment" : "Name for a workout activity that is a cooldown.", + "isCommentAutoGenerated" : true + }, + "Core Training" : { + "comment" : "Name of an activity type.", + "isCommentAutoGenerated" : true + }, + "Creates a copy with all data (heart rate, route, etc.) within the selected range. Splits and estimated effort cannot be preserved." : { + + }, + "Cross Training" : { + "comment" : "Localized string resource for the activity type \"Cross Training\".", + "isCommentAutoGenerated" : true + }, + "Cycling" : { + "comment" : "Description of a workout activity type when it is cycling.", + "isCommentAutoGenerated" : true + }, + "Dance" : { + "comment" : "Localized string resource for \"Dance\" activity type.", + "isCommentAutoGenerated" : true + }, + "Date" : { + "comment" : "Label for the \"Date\" option in the workout sort picker.", + "isCommentAutoGenerated" : true + }, + "Disconnect Strava" : { + "comment" : "A button that disconnects the app from a user's Strava account.", + "isCommentAutoGenerated" : true + }, + "Done" : { + "comment" : "A button label that says \"Done\".", + "isCommentAutoGenerated" : true + }, + "Duration" : { + + }, + "Elliptical" : { + "comment" : "Localized string resource describing the workout activity type \"Elliptical\".", + "isCommentAutoGenerated" : true + }, + "End" : { + "comment" : "A label displayed alongside the end time of a time interval.", + "isCommentAutoGenerated" : true + }, + "Functional Strength" : { + "comment" : "Description of a workout activity type that is part of the \"Functional Strength\" category.", + "isCommentAutoGenerated" : true + }, + "HIIT" : { + "comment" : "Name of a workout activity type.", + "isCommentAutoGenerated" : true + }, + "Hiking" : { + "comment" : "Description of a workout activity type when it is Hiking.", + "isCommentAutoGenerated" : true + }, + "Kickboxing" : { + "comment" : "Name of an activity type.", + "isCommentAutoGenerated" : true + }, + "Loading activity data..." : { + + }, + "Longest" : { + "comment" : "Label for the \"Longest\" option in the workout sort picker.", + "isCommentAutoGenerated" : true + }, + "No Activity Data" : { + "comment" : "A message indicating that no heart rate or activity samples are available for a workout.", + "isCommentAutoGenerated" : true + }, + "No heart rate or activity samples found for this workout." : { + "comment" : "A description text displayed when no heart rate or activity samples are found for a workout.", + "isCommentAutoGenerated" : true + }, + "No Workouts" : { + "comment" : "A message indicating that there are no workouts available.", + "isCommentAutoGenerated" : true + }, + "Open Fitness" : { + "comment" : "A button label that opens the Apple Fitness app.", + "isCommentAutoGenerated" : true + }, + "Optional. Connect Strava below to upload." : { + "comment" : "A description below the Connect Strava button, indicating that it is optional.", + "isCommentAutoGenerated" : true + }, + "Optional. Upload the trimmed workout directly to Strava." : { + + }, + "Other" : { + "comment" : "Description of a workout activity type that is not explicitly listed.", + "isCommentAutoGenerated" : true + }, + "Overrun" : { + "comment" : "A title for a screen that lists the user's workouts", + "isCommentAutoGenerated" : true + }, + "Pilates" : { + "comment" : "Name of a workout activity type.", + "isCommentAutoGenerated" : true + }, + "Processing on Strava..." : { + + }, + "Retry" : { + "comment" : "A button that lets the user try uploading the workout again if it fails.", + "isCommentAutoGenerated" : true + }, + "Rowing" : { + "comment" : "Description of a workout activity type when the workout activity type is \"Rowing\".", + "isCommentAutoGenerated" : true + }, + "Running" : { + "comment" : "Localized string resource for a workout activity type called \"Running\".", + "isCommentAutoGenerated" : true + }, + "Save Trimmed Copy" : { + "comment" : "A button label that saves a trimmed workout copy.", + "isCommentAutoGenerated" : true + }, + "Search by activity type" : { + "comment" : "A prompt for searching workouts by activity type in the workout list view.", + "isCommentAutoGenerated" : true + }, + "Select a workout to fix..." : { + "comment" : "A description text displayed when there are no workouts to display in the list.", + "isCommentAutoGenerated" : true + }, + "Shortest" : { + "comment" : "Label for the \"Shortest\" option in the workout sort picker.", + "isCommentAutoGenerated" : true + }, + "Sort" : { + "comment" : "A label for a segmented control used to sort workouts.", + "isCommentAutoGenerated" : true + }, + "Sort by" : { + "comment" : "A label displayed above the picker that lets the user sort workouts.", + "isCommentAutoGenerated" : true + }, + "Stair Climbing" : { + "comment" : "Description of a workout activity type when the user is stair climbing.", + "isCommentAutoGenerated" : true + }, + "Start" : { + "comment" : "A label displayed next to the start time in the \"Start\" and \"End\" time section of the workout edit view.", + "isCommentAutoGenerated" : true + }, + "Strength Training" : { + "comment" : "Description of a workout activity type that is classified as strength training.", + "isCommentAutoGenerated" : true + }, + "Swimming" : { + "comment" : "Localized string resource for \"Swimming\".", + "isCommentAutoGenerated" : true + }, + "Swipe left on the original in Apple Fitness and choose \"Delete Workout & Data\". This is safe — the trimmed copy keeps its own data." : { + "comment" : "A description of how to delete the original workout from Apple Fitness.", + "isCommentAutoGenerated" : true + }, + "Time" : { + "comment" : "The area below the line representing a sample intensity reading.", + "isCommentAutoGenerated" : true + }, + "Upload failed" : { + "comment" : "A label indicating that an upload attempt failed.", + "isCommentAutoGenerated" : true + }, + "Upload to Strava" : { + "comment" : "A button that triggers the upload of a workout to Strava.", + "isCommentAutoGenerated" : true + }, + "Uploaded (ID: %lld)" : { + "comment" : "A label indicating that a workout has been successfully uploaded to Strava. The text inside the label changes to show the Strava activity ID if it's available.", + "isCommentAutoGenerated" : true + }, + "Uploaded successfully" : { + "comment" : "A label that appears when a workout is successfully uploaded to Strava.", + "isCommentAutoGenerated" : true + }, + "Uploading..." : { + "comment" : "A label indicating that a workout is currently uploading to Strava.", + "isCommentAutoGenerated" : true + }, + "Walking" : { + "comment" : "Localized string resource for \"Walking\" activity type.", + "isCommentAutoGenerated" : true + }, + "Yoga" : { + "comment" : "Localized string resource for the workout activity type \"Yoga\".", + "isCommentAutoGenerated" : true + }, + "Your workouts will appear here." : { + "comment" : "A description text displayed when there are no workouts available.", + "isCommentAutoGenerated" : true + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/WorkoutEditor/StravaManager.swift b/WorkoutEditor/StravaManager.swift index 4abd314..1798f54 100644 --- a/WorkoutEditor/StravaManager.swift +++ b/WorkoutEditor/StravaManager.swift @@ -3,7 +3,6 @@ // WorkoutEditor // -import AuthenticationServices import CoreLocation import Foundation import HealthKit @@ -14,7 +13,7 @@ import UIKit enum StravaConfig { static let clientID = Bundle.main.object(forInfoDictionaryKey: "StravaClientID") as? String ?? "" static let clientSecret = Bundle.main.object(forInfoDictionaryKey: "StravaClientSecret") as? String ?? "" - static let redirectURI = "overrun://strava/callback" + static let redirectURI = "overrun://localhost" static let authorizeURL = "https://www.strava.com/oauth/mobile/authorize" static let tokenURL = "https://www.strava.com/oauth/token" static let uploadURL = "https://www.strava.com/api/v3/uploads" @@ -100,39 +99,20 @@ class StravaManager: ObservableObject { URLQueryItem(name: "redirect_uri", value: StravaConfig.redirectURI), URLQueryItem(name: "response_type", value: "code"), URLQueryItem(name: "approval_prompt", value: "auto"), - URLQueryItem(name: "scope", value: "activity:write,activity:read_all") + URLQueryItem(name: "scope", value: "activity:write") ] guard let url = components.url else { return } - let session = ASWebAuthenticationSession( - url: url, - callbackURLScheme: "overrun" - ) { [weak self] callbackURL, error in - Task { @MainActor in - guard let self, let callbackURL, error == nil else { return } - guard let code = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)? - .queryItems?.first(where: { $0.name == "code" })?.value else { return } - await self.exchangeToken(code: code) - } - } - session.prefersEphemeralWebBrowserSession = false - - // Find the current window scene for the presentation context - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first { - let provider = WebAuthPresentationContext(anchor: window) - session.presentationContextProvider = provider - // Store the session and provider to prevent deallocation - self.activeAuthSession = session - self.authContextProvider = provider - } - - session.start() + // Open in Strava app if installed, otherwise falls back to Safari + UIApplication.shared.open(url) } - private var activeAuthSession: ASWebAuthenticationSession? - private var authContextProvider: WebAuthPresentationContext? + func handleCallback(_ url: URL) { + guard let code = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "code" })?.value else { return } + Task { await exchangeToken(code: code) } + } private func exchangeToken(code: String) async { guard let url = URL(string: StravaConfig.tokenURL) else { return } @@ -157,8 +137,6 @@ class StravaManager: ObservableObject { print("Token exchange failed: \(error)") } - activeAuthSession = nil - authContextProvider = nil } private func refreshAccessToken() async -> Bool { @@ -539,14 +517,6 @@ class StravaManager: ObservableObject { } } -// MARK: - ASWebAuthenticationSession Presentation - -private class WebAuthPresentationContext: NSObject, ASWebAuthenticationPresentationContextProviding { - let anchor: ASPresentationAnchor - init(anchor: ASPresentationAnchor) { self.anchor = anchor } - func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { anchor } -} - // MARK: - Multipart Helpers private extension Data { diff --git a/WorkoutEditor/WorkoutEditView.swift b/WorkoutEditor/WorkoutEditView.swift index 18aeb09..b03adb7 100644 --- a/WorkoutEditor/WorkoutEditView.swift +++ b/WorkoutEditor/WorkoutEditView.swift @@ -1,6 +1,16 @@ import SwiftUI import HealthKit +// MARK: - Flow State + +private enum EditFlowStep: Equatable { + case trimming + case saving + case saved(originalDeleted: Bool) +} + +// MARK: - Workout Edit View + struct WorkoutEditView: View { let workout: HKWorkout @Environment(\.dismiss) private var dismiss @@ -11,10 +21,7 @@ struct WorkoutEditView: View { @State private var intensitySamples: [IntensitySample] = [] @State private var intensityMetric: IntensityMetric = .heartRate @State private var isLoadingSamples = true - @State private var showDeleteAlert = false - @State private var showCopyAlert = false - @State private var copyAlertSource = "" - @State private var isSaving = false + @State private var flowStep: EditFlowStep = .trimming @State private var errorMessage: String? @StateObject private var stravaManager = StravaManager.shared @@ -43,9 +50,102 @@ struct WorkoutEditView: View { trimmedDuration > 0 } + private var isSaved: Bool { + if case .saved = flowStep { return true } + return false + } + + private var stravaUploadComplete: Bool { + if case .success = stravaManager.uploadStatus { return true } + return false + } + var body: some View { ScrollView { VStack(spacing: 20) { + // Step 1: Trim + step1TrimView + + Divider() + .padding(.horizontal) + + // Step 2: Upload to Strava (optional) + step2StravaView + + Divider() + .padding(.horizontal) + + // Step 3: Remove original + step3CleanupView + + Divider() + .padding(.horizontal) + + if isSaved { + Button { + dismiss() + } label: { + Text("Done") + .padding(.vertical, 4) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding(.horizontal) + } + + if let errorMessage { + Label(errorMessage, systemImage: "xmark.circle") + .foregroundStyle(.red) + .padding(.horizontal) + } + + // Strava connection + Divider() + .padding(.horizontal) + + if stravaManager.isAuthenticated { + Button("Disconnect Strava", role: .destructive) { + stravaManager.disconnect() + } + .font(.caption) + .padding(.horizontal) + } else { + Button { + stravaManager.authenticate() + } label: { + Text("Connect Strava") + .font(.caption) + } + .padding(.horizontal) + } + } + .padding(.vertical) + } + .navigationTitle(workout.workoutActivityType.activityTypeDescription) + .navigationBarTitleDisplayMode(.inline) + .task { + let result = await HealthKitManager.shared.fetchIntensitySamples(for: workout) + intensitySamples = result.samples + intensityMetric = result.metric + isLoadingSamples = false + } + } + + // MARK: - Step 1: Trim & Save + + @ViewBuilder + private var step1TrimView: some View { + VStack(alignment: .leading, spacing: 16) { + stepRow(number: 1, title: "Trim and save", isComplete: isSaved) + .padding(.horizontal) + + Text("Creates a copy with all data (heart rate, route, etc.) within the selected range. Splits and estimated effort cannot be preserved.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal) + .padding(.leading, 24) + + if !isSaved { // Activity graph Group { if isLoadingSamples { @@ -106,9 +206,6 @@ struct WorkoutEditView: View { } .padding(.horizontal) - Divider() - .padding(.horizontal) - // Activity type picker HStack { Text("Activity Type") @@ -122,98 +219,106 @@ struct WorkoutEditView: View { } .padding(.horizontal) - Divider() - .padding(.horizontal) - - // Save explanation and buttons - VStack(spacing: 12) { - Text("Saving creates a trimmed copy with all workout data in Apple Health. To remove the original, swipe left on it in Apple Fitness and choose \"Delete Workout & Data\".") - .font(.caption) - .foregroundStyle(.secondary) - - Button { - saveChanges() - } label: { - if isSaving { - ProgressView() - .frame(maxWidth: .infinity) - } else { - Text("Save Trimmed Copy") - .frame(maxWidth: .infinity) - } - } - .buttonStyle(.borderedProminent) - .disabled(!isValid || isSaving) - - Button(role: .destructive) { - showDeleteAlert = true - } label: { - Text("Delete Workout") + // Save button + Button { + saveChanges() + } label: { + if flowStep == .saving { + ProgressView() + .padding(.vertical, 4) + .frame(maxWidth: .infinity) + } else { + Text("Save Trimmed Copy") + .padding(.vertical, 4) .frame(maxWidth: .infinity) } - .buttonStyle(.bordered) - .disabled(isSaving) } + .buttonStyle(.borderedProminent) + .disabled(!isValid || flowStep == .saving) .padding(.horizontal) + } + } + } - if let errorMessage { - Label(errorMessage, systemImage: "xmark.circle") - .foregroundStyle(.red) - .padding(.horizontal) + // MARK: - Step 2: Strava + + @ViewBuilder + private var step2StravaView: some View { + VStack(alignment: .leading, spacing: 12) { + stepRow(number: 2, title: "Upload to Strava", isComplete: stravaUploadComplete) + .padding(.horizontal) + + Group { + if !isSaved { + Text("Optional. Upload the trimmed workout directly to Strava.") + .font(.caption) + .foregroundStyle(.tertiary) + } else if !stravaManager.isAuthenticated { + Text("Optional. Connect Strava below to upload.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + stravaUploadView } + } + .padding(.horizontal) + .padding(.leading, 24) + } + .opacity(isSaved ? 1 : 0.4) + .allowsHitTesting(isSaved) + } - Divider() + // MARK: - Step 3: Remove Original + + @ViewBuilder + private var step3CleanupView: some View { + let originalDeleted: Bool = { + if case .saved(let deleted) = flowStep { return deleted } + return false + }() + + VStack(alignment: .leading, spacing: 12) { + if originalDeleted { + stepRow(number: 3, title: "Original removed", isComplete: true) + .padding(.horizontal) + } else { + stepRow(number: 3, title: "Remove the original", isComplete: false) .padding(.horizontal) - // Strava - VStack(spacing: 12) { - if stravaManager.isAuthenticated { - stravaUploadView - Button("Disconnect Strava", role: .destructive) { - stravaManager.disconnect() - } - .font(.caption) + Group { + if !isSaved { + Text("Complete step 1 first.") + .font(.caption) + .foregroundStyle(.tertiary) } else { - Button { - stravaManager.authenticate() - } label: { - Label("Connect Strava", systemImage: "link") - .frame(maxWidth: .infinity) + VStack(alignment: .leading, spacing: 8) { + Text("Swipe left on the original in Apple Fitness and choose \"Delete Workout & Data\". This is safe — the trimmed copy keeps its own data.") + .font(.caption) + .foregroundStyle(.secondary) + + Button { + if let url = URL(string: "activitytoday://") { + UIApplication.shared.open(url) + } + } label: { + Label("Open Fitness", systemImage: "heart.circle") + .padding(.vertical, 4) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) } - .buttonStyle(.bordered) } } .padding(.horizontal) + .padding(.leading, 24) } - .padding(.vertical) - } - .navigationTitle(workout.workoutActivityType.activityTypeDescription) - .navigationBarTitleDisplayMode(.inline) - .task { - let result = await HealthKitManager.shared.fetchIntensitySamples(for: workout) - intensitySamples = result.samples - intensityMetric = result.metric - isLoadingSamples = false - } - .alert("Delete Workout", isPresented: $showDeleteAlert) { - Button("Delete", role: .destructive) { deleteWorkout() } - Button("Cancel", role: .cancel) {} - } message: { - Text("This action cannot be undone.") - } - .alert("Trimmed Copy Created", isPresented: $showCopyAlert) { - Button("Open Fitness") { - if let url = URL(string: "activitytoday://") { - UIApplication.shared.open(url) - } - dismiss() - } - Button("OK") { dismiss() } - } message: { - Text("A trimmed copy with all workout data has been saved to Apple Health.\n\nTo remove the original, swipe left on it in Apple Fitness and choose \"Delete Workout & Data\". This is safe — the trimmed copy keeps its own data.") } + .opacity(isSaved && !originalDeleted ? 1 : originalDeleted ? 1 : 0.4) + .allowsHitTesting(isSaved) } + // MARK: - Strava Upload Status + @ViewBuilder private var stravaUploadView: some View { switch stravaManager.uploadStatus { @@ -222,6 +327,7 @@ struct WorkoutEditView: View { Task { await stravaManager.uploadWorkout(workout) } } label: { Label("Upload to Strava", systemImage: "arrow.up.circle") + .padding(.vertical, 4) .frame(maxWidth: .infinity) } .buttonStyle(.bordered) @@ -255,6 +361,22 @@ struct WorkoutEditView: View { } } + // MARK: - Step Row + + @ViewBuilder + private func stepRow(number: Int, title: String, isComplete: Bool) -> some View { + HStack(spacing: 12) { + Image(systemName: isComplete ? "checkmark.circle.fill" : "\(number).circle") + .font(.title2) + .foregroundStyle(isComplete ? .green : .secondary) + Text(title) + .font(.headline) + Spacer() + } + } + + // MARK: - Actions + private func formattedDuration(_ duration: TimeInterval) -> String { let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute, .second] @@ -263,9 +385,8 @@ struct WorkoutEditView: View { } private func saveChanges() { - isSaving = true + flowStep = .saving Task { - defer { isSaving = false } do { let result = try await HealthKitManager.shared.saveTrimmedWorkout( original: workout, @@ -274,29 +395,16 @@ struct WorkoutEditView: View { end: trimmedEndDate ) await HealthKitManager.shared.loadWorkouts() - if result.originalDeleted { - dismiss() - } else { - copyAlertSource = result.originalSource - showCopyAlert = true + withAnimation { + flowStep = .saved(originalDeleted: result.originalDeleted) } } catch { errorMessage = error.localizedDescription + flowStep = .trimming } } } - private func deleteWorkout() { - Task { - do { - try await HealthKitManager.shared.deleteWorkout(workout) - await HealthKitManager.shared.loadWorkouts() - dismiss() - } catch { - errorMessage = error.localizedDescription - } - } - } } // MARK: - Activity Type Helpers @@ -310,7 +418,11 @@ extension HKWorkoutActivityType { .coreTraining, .pilates, .kickboxing, .boxing, .climbing ] - var activityTypeDescription: String { + var activityTypeName: String { + String(localized: activityTypeDescription) + } + + var activityTypeDescription: LocalizedStringResource { switch self { case .running: "Running" case .walking: "Walking" diff --git a/WorkoutEditor/WorkoutEditorApp.swift b/WorkoutEditor/WorkoutEditorApp.swift index ab1a0c1..9278210 100644 --- a/WorkoutEditor/WorkoutEditorApp.swift +++ b/WorkoutEditor/WorkoutEditorApp.swift @@ -8,11 +8,17 @@ struct WorkoutEditorApp: App { var body: some Scene { WindowGroup { WorkoutListView() + .onOpenURL { url in + if url.scheme == "overrun" { + StravaManager.shared.handleCallback(url) + } + } .task { do { try await HealthKitManager.shared.requestAuthorization() logger.notice("Auth succeeded") await HealthKitManager.shared.loadWorkouts() + HealthKitManager.shared.startObservingWorkouts() logger.notice("Loaded \(HealthKitManager.shared.loadedWorkouts.count) workouts") #if DEBUG if HealthKitManager.shared.loadedWorkouts.count < 200 { diff --git a/WorkoutEditor/WorkoutListView.swift b/WorkoutEditor/WorkoutListView.swift index c88f508..24d8a04 100644 --- a/WorkoutEditor/WorkoutListView.swift +++ b/WorkoutEditor/WorkoutListView.swift @@ -1,10 +1,69 @@ import SwiftUI import HealthKit -enum WorkoutSort: String, CaseIterable { - case date = "Date" - case longest = "Longest" - case shortest = "Shortest" +// MARK: - Animated Gradient Header + +private struct AnimatedGradientText: View { + let text: String + + private static let iconColors: [(r: Double, g: Double, b: Double)] = [ + (0, 0.83, 1), // Cyan + (0.72, 0.90, 0), // Lime green + (1, 0, 0.33), // Hot pink + ] + + private static let cycleDuration: TimeInterval = 6 + + private static func gradientColors(at date: Date) -> [Color] { + let t = date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: cycleDuration) / cycleDuration + let count = Double(iconColors.count) + // Generate 3 evenly-spaced gradient stops, each offset by t + return (0..<3).map { i in + let pos = (t + Double(i) / 3).truncatingRemainder(dividingBy: 1.0) * count + let idx = Int(pos) + let frac = pos - Double(idx) + let c0 = iconColors[idx % iconColors.count] + let c1 = iconColors[(idx + 1) % iconColors.count] + return Color( + red: c0.r + (c1.r - c0.r) * frac, + green: c0.g + (c1.g - c0.g) * frac, + blue: c0.b + (c1.b - c0.b) * frac + ) + } + } + + var body: some View { + TimelineView(.animation) { timeline in + let colors = Self.gradientColors(at: timeline.date) + Text(text) + .font(.largeTitle.bold()) + .overlay { + LinearGradient( + colors: colors, + startPoint: .leading, + endPoint: .trailing + ) + .mask { + Text(text) + .font(.largeTitle.bold()) + } + } + } + } +} + +enum WorkoutSort: CaseIterable { + case date + case longest + case shortest + + var label: LocalizedStringKey { + switch self { + case .date: "Date" + case .longest: "Longest" + case .shortest: "Shortest" + } + } } struct WorkoutListView: View { @@ -19,7 +78,7 @@ struct WorkoutListView: View { workouts = manager.loadedWorkouts } else { workouts = manager.loadedWorkouts.filter { - $0.workoutActivityType.activityTypeDescription + $0.workoutActivityType.activityTypeName .localizedCaseInsensitiveContains(searchText) } } @@ -71,17 +130,20 @@ struct WorkoutListView: View { Spacer() Picker("Sort", selection: $sortMode) { ForEach(WorkoutSort.allCases, id: \.self) { mode in - Text(mode.rawValue).tag(mode) + Text(mode.label).tag(mode) } } .pickerStyle(.segmented) .frame(width: 220) } } header: { - Text("Overrun") - .font(.largeTitle.bold()) - .foregroundStyle(.primary) - .textCase(nil) + VStack(alignment: .leading, spacing: 4) { + AnimatedGradientText(text: "Overrun") + Text("Select a workout to fix...") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .textCase(nil) } ForEach(groupedWorkouts, id: \.0) { section in