2.3.0 - Unified logging and app intents
All checks were successful
Ascently - Docs Deploy / build-and-push (pull_request) Successful in 8m4s
All checks were successful
Ascently - Docs Deploy / build-and-push (pull_request) Successful in 8m4s
This commit is contained in:
@@ -465,7 +465,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
CURRENT_PROJECT_VERSION = 32;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -487,7 +487,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
||||
MARKETING_VERSION = 2.2.1;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -513,7 +513,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
CURRENT_PROJECT_VERSION = 32;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -535,7 +535,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
||||
MARKETING_VERSION = 2.2.1;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -602,7 +602,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
CURRENT_PROJECT_VERSION = 32;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||
@@ -613,7 +613,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.2.1;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -632,7 +632,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
CURRENT_PROJECT_VERSION = 32;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||
@@ -643,7 +643,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.2.1;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
||||
Binary file not shown.
33
ios/Ascently/AppIntents/AscentlyShortcuts.swift
Normal file
33
ios/Ascently/AppIntents/AscentlyShortcuts.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
import AppIntents
|
||||
|
||||
/// Provides a curated list of the most useful Ascently shortcuts for Siri and the Shortcuts app.
|
||||
/// Surfaces intents that users can trigger hands-free to manage their climbing sessions.
|
||||
struct AscentlyShortcuts: AppShortcutsProvider {
|
||||
|
||||
static var shortcutTileColor: ShortcutTileColor {
|
||||
.teal
|
||||
}
|
||||
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
return [
|
||||
AppShortcut(
|
||||
intent: StartLastGymSessionIntent(),
|
||||
phrases: [
|
||||
"Start my climb in \(.applicationName)",
|
||||
"Begin my last gym session in \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Start Climb",
|
||||
systemImageName: "figure.climbing"
|
||||
),
|
||||
AppShortcut(
|
||||
intent: EndActiveSessionIntent(),
|
||||
phrases: [
|
||||
"Finish my climb in \(.applicationName)",
|
||||
"End my session in \(.applicationName)",
|
||||
],
|
||||
shortTitle: "End Climb",
|
||||
systemImageName: "flag.checkered"
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
40
ios/Ascently/AppIntents/EndActiveSessionIntent.swift
Normal file
40
ios/Ascently/AppIntents/EndActiveSessionIntent.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import AppIntents
|
||||
import Foundation
|
||||
|
||||
/// Ends the currently active climbing session so logging stays in sync across devices.
|
||||
/// Exposed to Shortcuts so users can wrap up a session without opening the app.
|
||||
struct EndActiveSessionIntent: AppIntent {
|
||||
|
||||
static var title: LocalizedStringResource {
|
||||
"End Active Session"
|
||||
}
|
||||
|
||||
static var description: IntentDescription {
|
||||
IntentDescription(
|
||||
"Stop the active climbing session and save its progress in Ascently."
|
||||
)
|
||||
}
|
||||
|
||||
static var openAppWhenRun: Bool {
|
||||
false
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult & ProvidesDialog {
|
||||
do {
|
||||
let summary = try await SessionIntentController().endActiveSession()
|
||||
let dialog = IntentDialog("Session at \(summary.gymName) ended. Nice work!")
|
||||
return .result(dialog: dialog)
|
||||
} catch SessionIntentError.noActiveSession {
|
||||
// No active session is fine - just return a friendly message
|
||||
let dialog = IntentDialog("No active session to end.")
|
||||
return .result(dialog: dialog)
|
||||
} catch {
|
||||
// Re-throw other errors
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("End my current climbing session")
|
||||
}
|
||||
}
|
||||
95
ios/Ascently/AppIntents/SessionIntentSupport.swift
Normal file
95
ios/Ascently/AppIntents/SessionIntentSupport.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
|
||||
/// User-visible errors that can arise while handling session-related intents.
|
||||
enum SessionIntentError: LocalizedError {
|
||||
case noRecentGym
|
||||
case noActiveSession
|
||||
case failedToStartSession
|
||||
case failedToEndSession
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noRecentGym:
|
||||
return "There's no recent gym to start a session with."
|
||||
case .noActiveSession:
|
||||
return "There isn't an active session to end right now."
|
||||
case .failedToStartSession:
|
||||
return "Ascently couldn't start a new session."
|
||||
case .failedToEndSession:
|
||||
return "Ascently couldn't finish the active session."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionIntentSummary: Sendable {
|
||||
let sessionId: UUID
|
||||
let gymName: String
|
||||
let status: SessionStatus
|
||||
}
|
||||
|
||||
/// Central controller that exposes the minimal climbing session operations used by App Intents and shortcuts.
|
||||
@MainActor
|
||||
final class SessionIntentController {
|
||||
|
||||
private let dataManager: ClimbingDataManager
|
||||
|
||||
init(dataManager: ClimbingDataManager = .shared) {
|
||||
self.dataManager = dataManager
|
||||
}
|
||||
|
||||
/// Starts a new session using the most recently visited gym.
|
||||
func startSessionWithLastUsedGym() async throws -> SessionIntentSummary {
|
||||
// Give a moment for data to be ready if app just launched
|
||||
if dataManager.gyms.isEmpty {
|
||||
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
|
||||
}
|
||||
|
||||
guard let lastGym = dataManager.getLastUsedGym() else {
|
||||
logFailure(.noRecentGym, context: "No recorded sessions available")
|
||||
throw SessionIntentError.noRecentGym
|
||||
}
|
||||
|
||||
guard let startedSession = await dataManager.startSessionAsync(gymId: lastGym.id) else {
|
||||
logFailure(.failedToStartSession, context: "Data manager failed to create new session")
|
||||
throw SessionIntentError.failedToStartSession
|
||||
}
|
||||
|
||||
return SessionIntentSummary(
|
||||
sessionId: startedSession.id,
|
||||
gymName: lastGym.name,
|
||||
status: startedSession.status
|
||||
)
|
||||
}
|
||||
|
||||
/// Ends the currently active climbing session, if one exists.
|
||||
func endActiveSession() async throws -> SessionIntentSummary {
|
||||
guard let activeSession = dataManager.activeSession else {
|
||||
logFailure(.noActiveSession, context: "No active session stored in data manager")
|
||||
throw SessionIntentError.noActiveSession
|
||||
}
|
||||
|
||||
guard let completedSession = await dataManager.endSessionAsync(activeSession.id) else {
|
||||
logFailure(
|
||||
.failedToEndSession, context: "Data manager failed to complete active session")
|
||||
throw SessionIntentError.failedToEndSession
|
||||
}
|
||||
|
||||
guard let gym = dataManager.gym(withId: completedSession.gymId) else {
|
||||
logFailure(
|
||||
.failedToEndSession,
|
||||
context: "Gym missing for completed session \(completedSession.id)")
|
||||
throw SessionIntentError.failedToEndSession
|
||||
}
|
||||
|
||||
return SessionIntentSummary(
|
||||
sessionId: completedSession.id,
|
||||
gymName: gym.name,
|
||||
status: completedSession.status
|
||||
)
|
||||
}
|
||||
|
||||
private func logFailure(_ error: SessionIntentError, context: String) {
|
||||
// Logging from intent context - errors are visible to user via dialog
|
||||
print("SessionIntentError: \(error). Context: \(context)")
|
||||
}
|
||||
}
|
||||
43
ios/Ascently/AppIntents/StartLastGymSessionIntent.swift
Normal file
43
ios/Ascently/AppIntents/StartLastGymSessionIntent.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
import AppIntents
|
||||
import Foundation
|
||||
|
||||
/// Starts a climbing session at the most recently visited gym.
|
||||
/// Exposed to Shortcuts so users can begin logging without opening the app.
|
||||
struct StartLastGymSessionIntent: AppIntent {
|
||||
|
||||
static var title: LocalizedStringResource {
|
||||
"Start Last Gym Session"
|
||||
}
|
||||
|
||||
static var description: IntentDescription {
|
||||
IntentDescription(
|
||||
"Begin a new climbing session using the most recent gym you visited in Ascently."
|
||||
)
|
||||
}
|
||||
|
||||
static var openAppWhenRun: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult & ProvidesDialog {
|
||||
// Delay to ensure app has time to fully initialize if just launched
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
|
||||
|
||||
let summary = try await SessionIntentController().startSessionWithLastUsedGym()
|
||||
|
||||
// Give Live Activity extra time to start
|
||||
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
|
||||
|
||||
return .result(
|
||||
dialog: Self.successDialog(for: summary.gymName)
|
||||
)
|
||||
}
|
||||
|
||||
private static func successDialog(for gymName: String) -> IntentDialog {
|
||||
IntentDialog("Session started at \(gymName). Have an awesome climb!")
|
||||
}
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Start a session at my last gym")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,19 @@
|
||||
import SwiftUI
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct AscentlyApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var dataManager = ClimbingDataManager()
|
||||
@StateObject private var dataManager = ClimbingDataManager.shared
|
||||
@State private var selectedTab = 0
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var notificationObservers: [NSObjectProtocol] = []
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<true/>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>This app needs access to your photo library to add photos to climbing problems.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
|
||||
@@ -15,6 +15,8 @@ import UniformTypeIdentifiers
|
||||
@MainActor
|
||||
class ClimbingDataManager: ObservableObject {
|
||||
|
||||
static let shared = ClimbingDataManager()
|
||||
|
||||
@Published var gyms: [Gym] = []
|
||||
@Published var problems: [Problem] = []
|
||||
@Published var sessions: [ClimbSession] = []
|
||||
@@ -78,7 +80,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
let name: String
|
||||
}
|
||||
|
||||
init() {
|
||||
fileprivate init() {
|
||||
_ = ImageManager.shared
|
||||
migrateFromOpenClimbIfNeeded()
|
||||
loadAllData()
|
||||
@@ -415,9 +417,16 @@ class ClimbingDataManager: ObservableObject {
|
||||
}
|
||||
|
||||
func startSession(gymId: UUID, notes: String? = nil) {
|
||||
// End any currently active session
|
||||
Task { @MainActor in
|
||||
await startSessionAsync(gymId: gymId, notes: notes)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func startSessionAsync(gymId: UUID, notes: String? = nil) async -> ClimbSession? {
|
||||
// End any currently active session before starting a new one
|
||||
if let currentActive = activeSession {
|
||||
endSession(currentActive.id)
|
||||
await endSessionAsync(currentActive.id)
|
||||
}
|
||||
|
||||
let newSession = ClimbSession(gymId: gymId, notes: notes)
|
||||
@@ -430,64 +439,70 @@ class ClimbingDataManager: ObservableObject {
|
||||
|
||||
// MARK: - Start Live Activity for new session
|
||||
if let gym = gym(withId: gymId) {
|
||||
Task {
|
||||
await LiveActivityManager.shared.startLiveActivity(
|
||||
for: newSession, gymName: gym.name)
|
||||
}
|
||||
await LiveActivityManager.shared.startLiveActivity(
|
||||
for: newSession,
|
||||
gymName: gym.name)
|
||||
}
|
||||
|
||||
if healthKitService.isEnabled {
|
||||
Task {
|
||||
do {
|
||||
try await healthKitService.startWorkout(
|
||||
startDate: newSession.startTime ?? Date(),
|
||||
sessionId: newSession.id)
|
||||
} catch {
|
||||
AppLogger.error(
|
||||
"Failed to start HealthKit workout: \(error.localizedDescription)",
|
||||
tag: LogTag.climbingData)
|
||||
}
|
||||
do {
|
||||
try await healthKitService.startWorkout(
|
||||
startDate: newSession.startTime ?? Date(),
|
||||
sessionId: newSession.id)
|
||||
} catch {
|
||||
AppLogger.error(
|
||||
"Failed to start HealthKit workout: \(error.localizedDescription)",
|
||||
tag: LogTag.climbingData)
|
||||
}
|
||||
}
|
||||
|
||||
return newSession
|
||||
}
|
||||
|
||||
func endSession(_ sessionId: UUID) {
|
||||
if let session = sessions.first(where: { $0.id == sessionId && $0.status == .active }),
|
||||
Task { @MainActor in
|
||||
await endSessionAsync(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func endSessionAsync(_ sessionId: UUID) async -> ClimbSession? {
|
||||
guard
|
||||
let session = sessions.first(where: { $0.id == sessionId && $0.status == .active }),
|
||||
let index = sessions.firstIndex(where: { $0.id == sessionId })
|
||||
{
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let completedSession = session.completed()
|
||||
sessions[index] = completedSession
|
||||
let completedSession = session.completed()
|
||||
sessions[index] = completedSession
|
||||
|
||||
if activeSession?.id == sessionId {
|
||||
activeSession = nil
|
||||
}
|
||||
if activeSession?.id == sessionId {
|
||||
activeSession = nil
|
||||
}
|
||||
|
||||
saveActiveSession()
|
||||
saveSessions()
|
||||
DataStateManager.shared.updateDataState()
|
||||
saveActiveSession()
|
||||
saveSessions()
|
||||
DataStateManager.shared.updateDataState()
|
||||
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
|
||||
// MARK: - End Live Activity after session ends
|
||||
Task {
|
||||
await LiveActivityManager.shared.endLiveActivity()
|
||||
}
|
||||
// MARK: - End Live Activity after session ends
|
||||
await LiveActivityManager.shared.endLiveActivity()
|
||||
|
||||
if healthKitService.isEnabled {
|
||||
Task {
|
||||
do {
|
||||
try await healthKitService.endWorkout(
|
||||
endDate: completedSession.endTime ?? Date())
|
||||
} catch {
|
||||
AppLogger.error(
|
||||
"Failed to end HealthKit workout: \(error.localizedDescription)",
|
||||
tag: LogTag.climbingData)
|
||||
}
|
||||
}
|
||||
if healthKitService.isEnabled {
|
||||
do {
|
||||
try await healthKitService.endWorkout(
|
||||
endDate: completedSession.endTime ?? Date())
|
||||
} catch {
|
||||
AppLogger.error(
|
||||
"Failed to end HealthKit workout: \(error.localizedDescription)",
|
||||
tag: LogTag.climbingData)
|
||||
}
|
||||
}
|
||||
|
||||
return completedSession
|
||||
}
|
||||
|
||||
func updateSession(_ session: ClimbSession) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import WidgetKit
|
||||
struct SessionStatusLiveBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
SessionStatusLive()
|
||||
SessionStatusLiveControl()
|
||||
SessionStatusLiveLiveActivity()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
//
|
||||
// SessionStatusLiveControl.swift
|
||||
|
||||
import AppIntents
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct SessionStatusLiveControl: ControlWidget {
|
||||
static let kind: String = "com.atridad.Ascently.SessionStatusLive"
|
||||
|
||||
var body: some ControlWidgetConfiguration {
|
||||
AppIntentControlConfiguration(
|
||||
kind: Self.kind,
|
||||
provider: Provider()
|
||||
) { value in
|
||||
ControlWidgetToggle(
|
||||
"Start Timer",
|
||||
isOn: value.isRunning,
|
||||
action: StartTimerIntent(value.name)
|
||||
) { isRunning in
|
||||
Label(isRunning ? "On" : "Off", systemImage: "timer")
|
||||
}
|
||||
}
|
||||
.displayName("Timer")
|
||||
.description("A an example control that runs a timer.")
|
||||
}
|
||||
}
|
||||
|
||||
extension SessionStatusLiveControl {
|
||||
struct Value {
|
||||
var isRunning: Bool
|
||||
var name: String
|
||||
}
|
||||
|
||||
struct Provider: AppIntentControlValueProvider {
|
||||
func previewValue(configuration: TimerConfiguration) -> Value {
|
||||
SessionStatusLiveControl.Value(isRunning: false, name: configuration.timerName)
|
||||
}
|
||||
|
||||
func currentValue(configuration: TimerConfiguration) async throws -> Value {
|
||||
let isRunning = true // Check if the timer is running
|
||||
return SessionStatusLiveControl.Value(
|
||||
isRunning: isRunning, name: configuration.timerName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TimerConfiguration: ControlConfigurationIntent {
|
||||
static let title: LocalizedStringResource = "Timer Name Configuration"
|
||||
|
||||
@Parameter(title: "Timer Name", default: "Timer")
|
||||
var timerName: String
|
||||
}
|
||||
|
||||
struct StartTimerIntent: SetValueIntent {
|
||||
static let title: LocalizedStringResource = "Start a timer"
|
||||
|
||||
@Parameter(title: "Timer Name")
|
||||
var name: String
|
||||
|
||||
@Parameter(title: "Timer is running")
|
||||
var value: Bool
|
||||
|
||||
init() {}
|
||||
|
||||
init(_ name: String) {
|
||||
self.name = name
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
// Start the timer…
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user