iOS 2.5.0 - Apple Music Integration
This commit is contained in:
@@ -465,7 +465,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 36;
|
CURRENT_PROJECT_VERSION = 37;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -487,7 +487,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
||||||
MARKETING_VERSION = 2.4.2;
|
MARKETING_VERSION = 2.5.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@@ -513,7 +513,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 36;
|
CURRENT_PROJECT_VERSION = 37;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -535,7 +535,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
||||||
MARKETING_VERSION = 2.4.2;
|
MARKETING_VERSION = 2.5.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@@ -602,7 +602,7 @@
|
|||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 36;
|
CURRENT_PROJECT_VERSION = 37;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||||
@@ -613,7 +613,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2.4.2;
|
MARKETING_VERSION = 2.5.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -632,7 +632,7 @@
|
|||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 36;
|
CURRENT_PROJECT_VERSION = 37;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||||
@@ -643,7 +643,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2.4.2;
|
MARKETING_VERSION = 2.5.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
|
|||||||
Binary file not shown.
@@ -44,6 +44,7 @@ struct ContentView: View {
|
|||||||
.tag(4)
|
.tag(4)
|
||||||
}
|
}
|
||||||
.environmentObject(dataManager)
|
.environmentObject(dataManager)
|
||||||
|
.environmentObject(MusicService.shared)
|
||||||
.onChange(of: scenePhase) { oldPhase, newPhase in
|
.onChange(of: scenePhase) { oldPhase, newPhase in
|
||||||
if newPhase == .active {
|
if newPhase == .active {
|
||||||
// Add slight delay to ensure app is fully loaded
|
// Add slight delay to ensure app is fully loaded
|
||||||
|
|||||||
@@ -15,5 +15,7 @@
|
|||||||
<string>This app needs access to save your climbing workouts to Apple Health.</string>
|
<string>This app needs access to save your climbing workouts to Apple Health.</string>
|
||||||
<key>NSHealthUpdateUsageDescription</key>
|
<key>NSHealthUpdateUsageDescription</key>
|
||||||
<string>This app needs access to save your climbing workouts to Apple Health.</string>
|
<string>This app needs access to save your climbing workouts to Apple Health.</string>
|
||||||
|
<key>NSAppleMusicUsageDescription</key>
|
||||||
|
<string>This app (optionally) needs access to your music library to play your selected playlist during climbing sessions.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
197
ios/Ascently/Services/MusicService.swift
Normal file
197
ios/Ascently/Services/MusicService.swift
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import MusicKit
|
||||||
|
import AVFoundation
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class MusicService: ObservableObject {
|
||||||
|
static let shared = MusicService()
|
||||||
|
|
||||||
|
@Published var isAuthorized = false
|
||||||
|
@Published var playlists: MusicItemCollection<Playlist> = []
|
||||||
|
@Published var selectedPlaylistId: String? {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(selectedPlaylistId, forKey: "ascently_selected_playlist_id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Published var isMusicEnabled: Bool {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(isMusicEnabled, forKey: "ascently_music_enabled")
|
||||||
|
if !isMusicEnabled {
|
||||||
|
// Genuinely unsure what I want to do with this but we should account for it at some point
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Published var isAutoPlayEnabled: Bool {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(isAutoPlayEnabled, forKey: "ascently_music_autoplay_enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Published var isAutoStopEnabled: Bool {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(isAutoStopEnabled, forKey: "ascently_music_autostop_enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Published var isPlaying = false
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private var hasStartedSessionPlayback = false
|
||||||
|
private var currentPlaylistTrackIds: Set<MusicItemID> = []
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
self.selectedPlaylistId = UserDefaults.standard.string(forKey: "ascently_selected_playlist_id")
|
||||||
|
self.isMusicEnabled = UserDefaults.standard.bool(forKey: "ascently_music_enabled")
|
||||||
|
self.isAutoPlayEnabled = UserDefaults.standard.bool(forKey: "ascently_music_autoplay_enabled")
|
||||||
|
self.isAutoStopEnabled = UserDefaults.standard.bool(forKey: "ascently_music_autostop_enabled")
|
||||||
|
|
||||||
|
if isMusicEnabled {
|
||||||
|
Task {
|
||||||
|
await checkAuthorizationStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupObservers()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupObservers() {
|
||||||
|
SystemMusicPlayer.shared.state.objectWillChange
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.updatePlaybackStatus()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
SystemMusicPlayer.shared.queue.objectWillChange
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.checkQueueConsistency()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updatePlaybackStatus() {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.isPlaying = SystemMusicPlayer.shared.state.playbackStatus == .playing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkQueueConsistency() {
|
||||||
|
guard hasStartedSessionPlayback else { return }
|
||||||
|
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
if let currentEntry = SystemMusicPlayer.shared.queue.currentEntry,
|
||||||
|
let item = currentEntry.item {
|
||||||
|
if !self.currentPlaylistTrackIds.isEmpty && !self.currentPlaylistTrackIds.contains(item.id) {
|
||||||
|
self.hasStartedSessionPlayback = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleMusicEnabled(_ enabled: Bool) {
|
||||||
|
isMusicEnabled = enabled
|
||||||
|
if enabled {
|
||||||
|
Task {
|
||||||
|
await checkAuthorizationStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkAuthorizationStatus() async {
|
||||||
|
let status = await MusicAuthorization.request()
|
||||||
|
self.isAuthorized = status == .authorized
|
||||||
|
if isAuthorized {
|
||||||
|
await fetchPlaylists()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchPlaylists() async {
|
||||||
|
guard isAuthorized else { return }
|
||||||
|
do {
|
||||||
|
var request = MusicLibraryRequest<Playlist>()
|
||||||
|
request.sort(by: \.name, ascending: true)
|
||||||
|
let response = try await request.response()
|
||||||
|
self.playlists = response.items
|
||||||
|
} catch {
|
||||||
|
print("Error fetching playlists: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func playSelectedPlaylistIfHeadphonesConnected() {
|
||||||
|
guard isMusicEnabled, isAutoPlayEnabled, let playlistId = selectedPlaylistId else { return }
|
||||||
|
|
||||||
|
if isHeadphonesConnected() {
|
||||||
|
playPlaylist(id: playlistId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetSessionPlaybackState() {
|
||||||
|
hasStartedSessionPlayback = false
|
||||||
|
currentPlaylistTrackIds.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func playPlaylist(id: String) {
|
||||||
|
print("Attempting to play playlist \(id)")
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
if playlists.isEmpty {
|
||||||
|
await fetchPlaylists()
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetPlaylist: Playlist?
|
||||||
|
|
||||||
|
if let playlist = playlists.first(where: { $0.id.rawValue == id }) {
|
||||||
|
targetPlaylist = playlist
|
||||||
|
} else {
|
||||||
|
var request = MusicLibraryRequest<Playlist>()
|
||||||
|
request.filter(matching: \.id, equalTo: MusicItemID(id))
|
||||||
|
let response = try await request.response()
|
||||||
|
targetPlaylist = response.items.first
|
||||||
|
}
|
||||||
|
|
||||||
|
if let playlist = targetPlaylist {
|
||||||
|
let detailedPlaylist = try await playlist.with([.tracks])
|
||||||
|
if let tracks = detailedPlaylist.tracks {
|
||||||
|
self.currentPlaylistTrackIds = Set(tracks.map { $0.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
SystemMusicPlayer.shared.queue = [playlist]
|
||||||
|
try await SystemMusicPlayer.shared.play()
|
||||||
|
hasStartedSessionPlayback = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Error playing playlist: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopPlaybackIfEnabled() {
|
||||||
|
guard isMusicEnabled, isAutoStopEnabled else { return }
|
||||||
|
SystemMusicPlayer.shared.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func togglePlayback() {
|
||||||
|
Task {
|
||||||
|
if isPlaying {
|
||||||
|
SystemMusicPlayer.shared.pause()
|
||||||
|
} else {
|
||||||
|
if let playlistId = selectedPlaylistId, !hasStartedSessionPlayback {
|
||||||
|
playPlaylist(id: playlistId)
|
||||||
|
} else {
|
||||||
|
try? await SystemMusicPlayer.shared.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isHeadphonesConnected() -> Bool {
|
||||||
|
let route = AVAudioSession.sharedInstance().currentRoute
|
||||||
|
return route.outputs.contains { port in
|
||||||
|
port.portType == .headphones ||
|
||||||
|
port.portType == .bluetoothA2DP ||
|
||||||
|
port.portType == .bluetoothLE ||
|
||||||
|
port.portType == .bluetoothHFP ||
|
||||||
|
port.portType == .usbAudio ||
|
||||||
|
port.portType == .airPlay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
|
|
||||||
let syncService = SyncService()
|
let syncService = SyncService()
|
||||||
let healthKitService = HealthKitService.shared
|
let healthKitService = HealthKitService.shared
|
||||||
|
let musicService = MusicService.shared
|
||||||
|
|
||||||
@Published var isSyncing = false
|
@Published var isSyncing = false
|
||||||
private enum Keys {
|
private enum Keys {
|
||||||
@@ -437,13 +438,15 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
saveSessions()
|
saveSessions()
|
||||||
DataStateManager.shared.updateDataState()
|
DataStateManager.shared.updateDataState()
|
||||||
|
|
||||||
// MARK: - Start Live Activity for new session
|
|
||||||
if let gym = gym(withId: gymId) {
|
if let gym = gym(withId: gymId) {
|
||||||
await LiveActivityManager.shared.startLiveActivity(
|
await LiveActivityManager.shared.startLiveActivity(
|
||||||
for: newSession,
|
for: newSession,
|
||||||
gymName: gym.name)
|
gymName: gym.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
musicService.resetSessionPlaybackState()
|
||||||
|
musicService.playSelectedPlaylistIfHeadphonesConnected()
|
||||||
|
|
||||||
if healthKitService.isEnabled {
|
if healthKitService.isEnabled {
|
||||||
do {
|
do {
|
||||||
try await healthKitService.startWorkout(
|
try await healthKitService.startWorkout(
|
||||||
@@ -488,8 +491,12 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
// Trigger auto-sync if enabled
|
// Trigger auto-sync if enabled
|
||||||
syncService.triggerAutoSync(dataManager: self)
|
syncService.triggerAutoSync(dataManager: self)
|
||||||
|
|
||||||
// MARK: - End Live Activity after session ends
|
|
||||||
await LiveActivityManager.shared.endLiveActivity()
|
await LiveActivityManager.shared.endLiveActivity()
|
||||||
|
|
||||||
|
if UserDefaults.standard.bool(forKey: "isAutoStopMusicEnabled") {
|
||||||
|
musicService.stopPlaybackIfEnabled()
|
||||||
|
}
|
||||||
|
musicService.stopPlaybackIfEnabled()
|
||||||
|
|
||||||
if healthKitService.isEnabled {
|
if healthKitService.isEnabled {
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ final class LiveActivityManager {
|
|||||||
func startHealthChecks() {
|
func startHealthChecks() {
|
||||||
stopHealthChecks() // Stop any existing timer
|
stopHealthChecks() // Stop any existing timer
|
||||||
|
|
||||||
AppLogger.debug("🩺 Starting Live Activity health checks", tag: Self.logTag)
|
AppLogger.debug("Starting Live Activity health checks", tag: Self.logTag)
|
||||||
healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) {
|
healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) {
|
||||||
[weak self] _ in
|
[weak self] _ in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
@@ -233,7 +233,7 @@ final class LiveActivityManager {
|
|||||||
// Only perform health check if it's been at least 25 seconds
|
// Only perform health check if it's been at least 25 seconds
|
||||||
guard timeSinceLastCheck >= 25 else { return }
|
guard timeSinceLastCheck >= 25 else { return }
|
||||||
|
|
||||||
AppLogger.debug("🩺 Performing Live Activity health check", tag: Self.logTag)
|
AppLogger.debug("Performing Live Activity health check", tag: Self.logTag)
|
||||||
lastHealthCheck = now
|
lastHealthCheck = now
|
||||||
|
|
||||||
let activities = Activity<SessionActivityAttributes>.activities
|
let activities = Activity<SessionActivityAttributes>.activities
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ struct CalendarView: View {
|
|||||||
let gym = dataManager.gym(withId: activeSession.gymId)
|
let gym = dataManager.gym(withId: activeSession.gymId)
|
||||||
{
|
{
|
||||||
ActiveSessionBanner(session: activeSession, gym: gym)
|
ActiveSessionBanner(session: activeSession, gym: gym)
|
||||||
|
.environmentObject(MusicService.shared)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
.padding(.bottom, 16)
|
.padding(.bottom, 16)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import Combine
|
import Combine
|
||||||
|
import MusicKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SessionDetailView: View {
|
struct SessionDetailView: View {
|
||||||
let sessionId: UUID
|
let sessionId: UUID
|
||||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||||
@EnvironmentObject var themeManager: ThemeManager
|
@EnvironmentObject var themeManager: ThemeManager
|
||||||
|
@EnvironmentObject var musicService: MusicService
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var showingDeleteAlert = false
|
@State private var showingDeleteAlert = false
|
||||||
@State private var showingAddAttempt = false
|
@State private var showingAddAttempt = false
|
||||||
@@ -41,13 +43,19 @@ struct SessionDetailView: View {
|
|||||||
Section {
|
Section {
|
||||||
SessionHeaderCard(
|
SessionHeaderCard(
|
||||||
session: session, gym: gym, stats: sessionStats)
|
session: session, gym: gym, stats: sessionStats)
|
||||||
.listRowInsets(EdgeInsets())
|
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.padding(.bottom, 8)
|
|
||||||
|
if session.status == .active && musicService.isMusicEnabled && musicService.isAuthorized {
|
||||||
|
MusicControlCard()
|
||||||
|
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
}
|
||||||
|
|
||||||
SessionStatsCard(stats: sessionStats)
|
SessionStatsCard(stats: sessionStats)
|
||||||
.listRowInsets(EdgeInsets())
|
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
}
|
}
|
||||||
@@ -76,6 +84,7 @@ struct SessionDetailView: View {
|
|||||||
)
|
)
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
|
||||||
} else {
|
} else {
|
||||||
ForEach(attemptsWithProblems.indices, id: \.self) { index in
|
ForEach(attemptsWithProblems.indices, id: \.self) { index in
|
||||||
let (attempt, problem) = attemptsWithProblems[index]
|
let (attempt, problem) = attemptsWithProblems[index]
|
||||||
@@ -442,6 +451,53 @@ struct SessionStats {
|
|||||||
let uniqueProblemsCompleted: Int
|
let uniqueProblemsCompleted: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct MusicControlCard: View {
|
||||||
|
@EnvironmentObject var musicService: MusicService
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "music.note")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(.pink)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(Color.pink.opacity(0.1))
|
||||||
|
.clipShape(Circle())
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Music")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
if let playlistId = musicService.selectedPlaylistId,
|
||||||
|
let playlist = musicService.playlists.first(where: { $0.id.rawValue == playlistId }) {
|
||||||
|
Text(playlist.name)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("No playlist selected")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
musicService.togglePlayback()
|
||||||
|
}) {
|
||||||
|
Image(systemName: musicService.isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||||
|
.font(.system(size: 32))
|
||||||
|
.foregroundColor(.pink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.fill(Color(.secondarySystemGroupedBackground))
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 5, x: 0, y: 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
SessionDetailView(sessionId: UUID())
|
SessionDetailView(sessionId: UUID())
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ struct SessionsList: View {
|
|||||||
{
|
{
|
||||||
Section {
|
Section {
|
||||||
ActiveSessionBanner(session: activeSession, gym: gym)
|
ActiveSessionBanner(session: activeSession, gym: gym)
|
||||||
|
.environmentObject(MusicService.shared)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.listRowInsets(EdgeInsets(top: 16, leading: 0, bottom: 24, trailing: 0))
|
.listRowInsets(EdgeInsets(top: 16, leading: 0, bottom: 24, trailing: 0))
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
@@ -178,6 +179,17 @@ struct ActiveSessionBanner: View {
|
|||||||
let gym: Gym
|
let gym: Gym
|
||||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||||
@State private var navigateToDetail = false
|
@State private var navigateToDetail = false
|
||||||
|
|
||||||
|
// Access MusicService via DataManager if possible, or EnvironmentObject if injected
|
||||||
|
// Since DataManager holds MusicService, we can access it through there if we expose it or inject it.
|
||||||
|
// In SettingsView we saw .environmentObject(dataManager.musicService).
|
||||||
|
// We should probably inject it here too or access via dataManager if it's public.
|
||||||
|
// Let's check ClimbingDataManager again. It has `let musicService = MusicService.shared`.
|
||||||
|
// But it's not @Published so it won't trigger updates unless we observe the service itself.
|
||||||
|
// The best way is to use @EnvironmentObject var musicService: MusicService
|
||||||
|
// and ensure it's injected in the parent view.
|
||||||
|
|
||||||
|
@EnvironmentObject var musicService: MusicService
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -201,6 +213,19 @@ struct ActiveSessionBanner: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if musicService.isMusicEnabled && musicService.isAuthorized {
|
||||||
|
Button(action: {
|
||||||
|
musicService.togglePlayback()
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: musicService.isPlaying ? "pause.fill" : "play.fill")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.foregroundColor(.pink)
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import HealthKit
|
import HealthKit
|
||||||
|
import MusicKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
@@ -21,6 +22,9 @@ struct SettingsView: View {
|
|||||||
HealthKitSection()
|
HealthKitSection()
|
||||||
.environmentObject(dataManager.healthKitService)
|
.environmentObject(dataManager.healthKitService)
|
||||||
|
|
||||||
|
MusicSection()
|
||||||
|
.environmentObject(dataManager.musicService)
|
||||||
|
|
||||||
AppearanceSection()
|
AppearanceSection()
|
||||||
|
|
||||||
DataManagementSection(
|
DataManagementSection(
|
||||||
@@ -1099,6 +1103,53 @@ struct HealthKitSection: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct MusicSection: View {
|
||||||
|
@EnvironmentObject var musicService: MusicService
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section {
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { musicService.isMusicEnabled },
|
||||||
|
set: { musicService.toggleMusicEnabled($0) }
|
||||||
|
)) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "music.note")
|
||||||
|
.foregroundColor(.pink)
|
||||||
|
Text("Apple Music Integration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if musicService.isMusicEnabled {
|
||||||
|
if !musicService.isAuthorized {
|
||||||
|
Button("Connect Apple Music") {
|
||||||
|
Task {
|
||||||
|
await musicService.checkAuthorizationStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Toggle("Auto-Play on Session Start", isOn: $musicService.isAutoPlayEnabled)
|
||||||
|
Toggle("Stop Music on Session End", isOn: $musicService.isAutoStopEnabled)
|
||||||
|
|
||||||
|
Picker("Playlist", selection: $musicService.selectedPlaylistId) {
|
||||||
|
Text("None").tag(nil as String?)
|
||||||
|
ForEach(musicService.playlists, id: \.id) { playlist in
|
||||||
|
Text(playlist.name).tag(playlist.id.rawValue as String?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if musicService.isAutoPlayEnabled {
|
||||||
|
Text("Music will only auto-play if headphones are connected when you start a session.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Music")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
.environmentObject(ClimbingDataManager.preview)
|
.environmentObject(ClimbingDataManager.preview)
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ struct SessionStatusLiveLiveActivity: Widget {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
.padding(.leading, 8)
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.trailing) {
|
DynamicIslandExpandedRegion(.trailing) {
|
||||||
VStack(alignment: .trailing, spacing: 4) {
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
@@ -61,6 +62,7 @@ struct SessionStatusLiveLiveActivity: Widget {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.trailing, 8)
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.bottom) {
|
DynamicIslandExpandedRegion(.bottom) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -72,6 +74,8 @@ struct SessionStatusLiveLiveActivity: Widget {
|
|||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.bottom, 4)
|
||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
Image(systemName: "figure.climbing")
|
Image(systemName: "figure.climbing")
|
||||||
|
|||||||
Reference in New Issue
Block a user