Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
aa3ddfc7cb
|
|||
|
25688b0615
|
|||
|
3874703fcb
|
|||
| aa08892e75 |
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -75,3 +75,5 @@ pnpm-lock.yaml text -diff
|
||||
# Documentation
|
||||
LICENSE text eol=lf
|
||||
README.md text eol=lf
|
||||
|
||||
*.pxd linguist-vendored
|
||||
|
||||
@@ -25,9 +25,9 @@
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^9.5.1",
|
||||
"@astrojs/starlight": "^0.37.2",
|
||||
"astro": "^5.16.8",
|
||||
"@astrojs/node": "^9.5.2",
|
||||
"@astrojs/starlight": "^0.37.5",
|
||||
"astro": "^5.17.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
|
||||
644
docs/pnpm-lock.yaml
generated
644
docs/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -466,7 +466,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -491,7 +491,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
||||
MARKETING_VERSION = 2.7.2;
|
||||
MARKETING_VERSION = 2.7.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -518,7 +518,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -543,7 +543,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
||||
MARKETING_VERSION = 2.7.2;
|
||||
MARKETING_VERSION = 2.7.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -610,7 +610,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||
@@ -622,7 +622,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.7.2;
|
||||
MARKETING_VERSION = 2.7.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -641,7 +641,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
CURRENT_PROJECT_VERSION = 50;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||
@@ -653,7 +653,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.7.2;
|
||||
MARKETING_VERSION = 2.7.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
||||
Binary file not shown.
52
ios/Ascently/Components/ImagePicker.swift
Normal file
52
ios/Ascently/Components/ImagePicker.swift
Normal file
@@ -0,0 +1,52 @@
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
|
||||
struct ImagePicker: UIViewControllerRepresentable {
|
||||
@Binding var selectedImages: [Data]
|
||||
let sourceType: UIImagePickerController.SourceType
|
||||
let selectionLimit: Int
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||
let picker = UIImagePickerController()
|
||||
picker.delegate = context.coordinator
|
||||
picker.sourceType = sourceType
|
||||
picker.allowsEditing = false
|
||||
if sourceType == .photoLibrary {
|
||||
picker.modalPresentationStyle = .automatic
|
||||
}
|
||||
return picker
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {
|
||||
// No-op
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||
let parent: ImagePicker
|
||||
|
||||
init(_ parent: ImagePicker) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func imagePickerController(
|
||||
_ picker: UIImagePickerController,
|
||||
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
|
||||
) {
|
||||
if let image = info[.originalImage] as? UIImage,
|
||||
let data = image.jpegData(compressionQuality: 0.8) {
|
||||
parent.selectedImages.append(data)
|
||||
}
|
||||
parent.dismiss()
|
||||
}
|
||||
|
||||
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||
parent.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,6 @@ struct ContentView: View {
|
||||
.tag(4)
|
||||
}
|
||||
.environmentObject(dataManager)
|
||||
.environmentObject(MusicService.shared)
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
if newPhase == .active {
|
||||
// Add slight delay to ensure app is fully loaded
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import MusicKit
|
||||
import SwiftUI
|
||||
|
||||
@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() {
|
||||
isPlaying = SystemMusicPlayer.shared.state.playbackStatus == .playing
|
||||
}
|
||||
|
||||
private func checkQueueConsistency() {
|
||||
guard hasStartedSessionPlayback else { return }
|
||||
|
||||
if let currentEntry = SystemMusicPlayer.shared.queue.currentEntry,
|
||||
let item = currentEntry.item {
|
||||
if !currentPlaylistTrackIds.isEmpty && !currentPlaylistTrackIds.contains(item.id) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
99
ios/Ascently/Utils/AppExtensions.swift
Normal file
99
ios/Ascently/Utils/AppExtensions.swift
Normal file
@@ -0,0 +1,99 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum AppSettings {
|
||||
enum Keys {
|
||||
static let accentColor = "accentColorData"
|
||||
static let syncServerURL = "sync_server_url"
|
||||
static let syncAuthToken = "sync_auth_token"
|
||||
static let lastSyncTime = "last_sync_time"
|
||||
static let syncIsConnected = "is_connected"
|
||||
static let autoSyncEnabled = "auto_sync_enabled"
|
||||
static let offlineMode = "offline_mode"
|
||||
static let syncProviderType = "sync_provider_type"
|
||||
static let healthKitEnabled = "healthkit_enabled"
|
||||
static let autoBackupEnabled = "auto_backup_enabled"
|
||||
static let lastBackupTime = "last_backup_time"
|
||||
static let defaultClimbType = "default_climb_type"
|
||||
static let defaultDifficultySystem = "default_difficulty_system"
|
||||
}
|
||||
|
||||
static func set<T>(_ value: T, forKey key: String) {
|
||||
UserDefaults.standard.set(value, forKey: key)
|
||||
}
|
||||
|
||||
static func get<T>(_ type: T.Type, forKey key: String, defaultValue: T) -> T {
|
||||
guard let value = UserDefaults.standard.object(forKey: key) as? T else {
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
static func remove(forKey key: String) {
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
}
|
||||
|
||||
static func getString(forKey key: String, defaultValue: String = "") -> String {
|
||||
return UserDefaults.standard.string(forKey: key) ?? defaultValue
|
||||
}
|
||||
|
||||
static func setString(_ value: String, forKey key: String) {
|
||||
UserDefaults.standard.set(value, forKey: key)
|
||||
}
|
||||
|
||||
static func getBool(forKey key: String, defaultValue: Bool = false) -> Bool {
|
||||
return UserDefaults.standard.bool(forKey: key)
|
||||
}
|
||||
|
||||
static func setBool(_ value: Bool, forKey key: String) {
|
||||
UserDefaults.standard.set(value, forKey: key)
|
||||
}
|
||||
|
||||
static func getDate(forKey key: String) -> Date? {
|
||||
return UserDefaults.standard.object(forKey: key) as? Date
|
||||
}
|
||||
|
||||
static func setDate(_ value: Date?, forKey key: String) {
|
||||
if let date = value {
|
||||
UserDefaults.standard.set(date, forKey: key)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AppError: LocalizedError {
|
||||
case validationFailed(String)
|
||||
case dataCorruption(String)
|
||||
case syncFailed(String)
|
||||
case networkError(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .validationFailed(let message):
|
||||
return "Validation failed: \(message)"
|
||||
case .dataCorruption(let message):
|
||||
return "Data corruption: \(message)"
|
||||
case .syncFailed(let message):
|
||||
return "Sync failed: \(message)"
|
||||
case .networkError(let message):
|
||||
return "Network error: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func errorMessage(_ error: AppError?) -> some View {
|
||||
Group {
|
||||
if let error = error {
|
||||
Text(error.localizedDescription)
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.red.opacity(0.1))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
ios/Ascently/Utils/DataHelpers.swift
Normal file
18
ios/Ascently/Utils/DataHelpers.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
struct DataHelper {
|
||||
static func trimString(_ string: String) -> String {
|
||||
string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
static func isEmptyOrNil(_ string: String) -> String? {
|
||||
let trimmed = trimString(string)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
static func splitTags(_ tags: String) -> [String] {
|
||||
return tags.split(separator: ",")
|
||||
.compactMap { trimString(String($0)) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,6 @@ class ClimbingDataManager: ObservableObject {
|
||||
|
||||
let syncService = SyncService()
|
||||
let healthKitService = HealthKitService.shared
|
||||
let musicService = MusicService.shared
|
||||
|
||||
@Published var isSyncing = false
|
||||
private enum Keys {
|
||||
@@ -427,8 +426,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
gymName: gym.name)
|
||||
}
|
||||
|
||||
musicService.resetSessionPlaybackState()
|
||||
musicService.playSelectedPlaylistIfHeadphonesConnected()
|
||||
|
||||
|
||||
if healthKitService.isEnabled {
|
||||
do {
|
||||
@@ -470,10 +468,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
|
||||
await LiveActivityManager.shared.endLiveActivity()
|
||||
|
||||
if UserDefaults.standard.bool(forKey: "isAutoStopMusicEnabled") {
|
||||
musicService.stopPlaybackIfEnabled()
|
||||
}
|
||||
musicService.stopPlaybackIfEnabled()
|
||||
|
||||
|
||||
if healthKitService.isEnabled {
|
||||
do {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct AddEditProblemView: View {
|
||||
let problemId: UUID?
|
||||
let gymId: UUID?
|
||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||
@EnvironmentObject var themeManager: ThemeManager
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@@ -11,63 +11,40 @@ struct AddEditProblemView: View {
|
||||
@State private var selectedGym: Gym?
|
||||
@State private var name = ""
|
||||
@State private var description = ""
|
||||
@State private var selectedClimbType: ClimbType = .boulder
|
||||
@State private var selectedDifficultySystem: DifficultySystem = .vScale
|
||||
@State private var selectedClimbType: ClimbType
|
||||
@State private var selectedDifficultySystem: DifficultySystem
|
||||
@State private var difficultyGrade = ""
|
||||
@State private var availableDifficultySystems: [DifficultySystem] = []
|
||||
@State private var location = ""
|
||||
@State private var tags = ""
|
||||
@State private var notes = ""
|
||||
@State private var isActive = true
|
||||
@State private var dateSet = Date()
|
||||
@State private var imagePaths: [String] = []
|
||||
@State private var selectedPhotos: [PhotosPickerItem] = []
|
||||
@State private var imageData: [Data] = []
|
||||
@State private var showingPhotoOptions = false
|
||||
@State private var showingCamera = false
|
||||
@State private var showingImagePicker = false
|
||||
@State private var imageSource: UIImagePickerController.SourceType = .photoLibrary
|
||||
@State private var isEditing = false
|
||||
enum SheetType: Identifiable {
|
||||
case photoOptions
|
||||
|
||||
var id: Int {
|
||||
switch self {
|
||||
case .photoOptions: return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@State private var activeSheet: SheetType?
|
||||
@State private var showCamera = false
|
||||
@State private var showPhotoPicker = false
|
||||
@State private var isPhotoPickerActionPending = false
|
||||
@State private var isCameraActionPending = false
|
||||
|
||||
private var existingProblem: Problem? {
|
||||
guard let problemId = problemId else { return nil }
|
||||
return dataManager.problem(withId: problemId)
|
||||
}
|
||||
|
||||
private var availableClimbTypes: [ClimbType] {
|
||||
selectedGym?.supportedClimbTypes ?? ClimbType.allCases
|
||||
private var existingProblemGym: Gym? {
|
||||
guard let problem = existingProblem else { return nil }
|
||||
return dataManager.gym(withId: problem.gymId)
|
||||
}
|
||||
|
||||
var availableDifficultySystems: [DifficultySystem] {
|
||||
guard let gym = selectedGym else {
|
||||
return DifficultySystem.systemsForClimbType(selectedClimbType)
|
||||
}
|
||||
|
||||
let compatibleSystems = DifficultySystem.systemsForClimbType(selectedClimbType)
|
||||
let gymSupportedSystems = gym.difficultySystems.filter { system in
|
||||
compatibleSystems.contains(system)
|
||||
}
|
||||
|
||||
return gymSupportedSystems.isEmpty ? compatibleSystems : gymSupportedSystems
|
||||
private var gymId: UUID? {
|
||||
return selectedGym?.id ?? existingProblemGym?.id
|
||||
}
|
||||
|
||||
private var availableGrades: [String] {
|
||||
selectedDifficultySystem.availableGrades
|
||||
}
|
||||
|
||||
init(problemId: UUID? = nil, gymId: UUID? = nil) {
|
||||
init(problemId: UUID? = nil) {
|
||||
self.problemId = problemId
|
||||
self.gymId = gymId
|
||||
self._selectedClimbType = State(initialValue: .boulder)
|
||||
self._selectedDifficultySystem = State(initialValue: .vScale)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -75,14 +52,9 @@ struct AddEditProblemView: View {
|
||||
Form {
|
||||
GymSelectionSection()
|
||||
BasicInfoSection()
|
||||
PhotosSection()
|
||||
ClimbTypeSection()
|
||||
DifficultySection()
|
||||
LocationSection()
|
||||
TagsSection()
|
||||
AdditionalInfoSection()
|
||||
}
|
||||
.navigationTitle(isEditing ? "Edit Problem" : "Add Problem")
|
||||
.navigationTitle(isEditing ? "Edit Problem" : "New Problem")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
@@ -100,73 +72,41 @@ struct AddEditProblemView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
setupInitialClimbType()
|
||||
loadExistingProblem()
|
||||
setupInitialGym()
|
||||
}
|
||||
.onChange(of: dataManager.gyms) {
|
||||
if selectedGym == nil && !dataManager.gyms.isEmpty {
|
||||
selectedGym = dataManager.gyms.first
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedGym) {
|
||||
updateAvailableOptions()
|
||||
}
|
||||
.onChange(of: selectedClimbType) {
|
||||
updateDifficultySystem()
|
||||
}
|
||||
.onChange(of: selectedDifficultySystem) {
|
||||
resetGradeIfNeeded()
|
||||
}
|
||||
.sheet(
|
||||
item: $activeSheet,
|
||||
onDismiss: {
|
||||
if isCameraActionPending {
|
||||
showCamera = true
|
||||
isCameraActionPending = false
|
||||
return
|
||||
.sheet(isPresented: $showingPhotoOptions) {
|
||||
PhotoOptionSheet(
|
||||
selectedPhotos: .constant([]),
|
||||
imageData: $imageData,
|
||||
maxImages: 5,
|
||||
onCameraSelected: {
|
||||
showingCamera = true
|
||||
},
|
||||
onPhotoLibrarySelected: {
|
||||
showingImagePicker = true
|
||||
},
|
||||
onDismiss: {
|
||||
showingPhotoOptions = false
|
||||
}
|
||||
if isPhotoPickerActionPending {
|
||||
showPhotoPicker = true
|
||||
isPhotoPickerActionPending = false
|
||||
}
|
||||
}
|
||||
) { sheetType in
|
||||
switch sheetType {
|
||||
case .photoOptions:
|
||||
PhotoOptionSheet(
|
||||
selectedPhotos: $selectedPhotos,
|
||||
imageData: $imageData,
|
||||
maxImages: 5,
|
||||
onCameraSelected: {
|
||||
isCameraActionPending = true
|
||||
activeSheet = nil
|
||||
},
|
||||
onPhotoLibrarySelected: {
|
||||
isPhotoPickerActionPending = true
|
||||
},
|
||||
onDismiss: {
|
||||
activeSheet = nil
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
.fullScreenCover(isPresented: $showCamera) {
|
||||
CameraImagePicker { capturedImage in
|
||||
if let jpegData = capturedImage.jpegData(compressionQuality: 0.8) {
|
||||
imageData.append(jpegData)
|
||||
.sheet(isPresented: $showingCamera) {
|
||||
CameraImagePicker { image in
|
||||
if let data = image.jpegData(compressionQuality: 0.8) {
|
||||
imageData.append(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
.photosPicker(
|
||||
isPresented: $showPhotoPicker,
|
||||
selection: $selectedPhotos,
|
||||
maxSelectionCount: 5 - imageData.count,
|
||||
matching: .images
|
||||
)
|
||||
.onChange(of: selectedPhotos) {
|
||||
Task {
|
||||
await loadSelectedPhotos()
|
||||
}
|
||||
.sheet(isPresented: $showingImagePicker) {
|
||||
ImagePicker(
|
||||
selectedImages: Binding(
|
||||
get: { imageData },
|
||||
set: { imageData = $0 }
|
||||
),
|
||||
sourceType: imageSource,
|
||||
selectionLimit: 5
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,34 +118,38 @@ struct AddEditProblemView: View {
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(dataManager.gyms, id: \.id) { gym in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(gym.name)
|
||||
.font(.headline)
|
||||
|
||||
if let location = gym.location, !location.isEmpty {
|
||||
Text(location)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
gymRow(gym: gym, isSelected: selectedGym?.id == gym.id)
|
||||
.onTapGesture {
|
||||
selectedGym = gym
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if selectedGym?.id == gym.id {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(themeManager.accentColor)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
selectedGym = gym
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func gymRow(gym: Gym, isSelected: Bool) -> some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(gym.name)
|
||||
.font(.headline)
|
||||
|
||||
if let location = gym.location, !location.isEmpty {
|
||||
Text(location)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(themeManager.accentColor)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func BasicInfoSection() -> some View {
|
||||
Section("Problem Details") {
|
||||
@@ -226,217 +170,6 @@ struct AddEditProblemView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func ClimbTypeSection() -> some View {
|
||||
if selectedGym != nil {
|
||||
Section("Climb Type") {
|
||||
ForEach(availableClimbTypes, id: \.self) { climbType in
|
||||
HStack {
|
||||
Text(climbType.displayName)
|
||||
Spacer()
|
||||
if selectedClimbType == climbType {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(themeManager.accentColor)
|
||||
} else {
|
||||
Image(systemName: "circle")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
selectedClimbType = climbType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func DifficultySection() -> some View {
|
||||
Section("Difficulty") {
|
||||
// Difficulty System
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Difficulty System")
|
||||
.font(.headline)
|
||||
|
||||
ForEach(availableDifficultySystems, id: \.self) { system in
|
||||
HStack {
|
||||
Text(system.displayName)
|
||||
Spacer()
|
||||
if selectedDifficultySystem == system {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(themeManager.accentColor)
|
||||
} else {
|
||||
Image(systemName: "circle")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
selectedDifficultySystem = system
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Grade Selection
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Grade (Required)")
|
||||
.font(.headline)
|
||||
|
||||
if selectedDifficultySystem == .custom || availableGrades.isEmpty {
|
||||
TextField("Enter custom grade (numbers only)", text: $difficultyGrade)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.keyboardType(.numberPad)
|
||||
.onChange(of: difficultyGrade) {
|
||||
// Filter out non-numeric characters
|
||||
difficultyGrade = difficultyGrade.filter { $0.isNumber }
|
||||
}
|
||||
} else {
|
||||
Menu {
|
||||
if !difficultyGrade.isEmpty {
|
||||
Button("Clear Selection") {
|
||||
difficultyGrade = ""
|
||||
}
|
||||
|
||||
Divider()
|
||||
}
|
||||
|
||||
ForEach(availableGrades, id: \.self) { grade in
|
||||
Button(grade) {
|
||||
difficultyGrade = grade
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(difficultyGrade.isEmpty ? "Select Grade" : difficultyGrade)
|
||||
.foregroundColor(difficultyGrade.isEmpty ? .secondary : .primary)
|
||||
.fontWeight(difficultyGrade.isEmpty ? .regular : .semibold)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.down")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(.gray.opacity(0.1))
|
||||
.stroke(
|
||||
difficultyGrade.isEmpty
|
||||
? .red.opacity(0.5) : .gray.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if difficultyGrade.isEmpty {
|
||||
Text("Please select a grade to continue")
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
.italic()
|
||||
} else {
|
||||
Text("Selected: \(difficultyGrade)")
|
||||
.font(.caption)
|
||||
.foregroundColor(themeManager.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func LocationSection() -> some View {
|
||||
Section("Location & Details") {
|
||||
TextField(
|
||||
"Location (Optional)", text: $location, prompt: Text("e.g., 'Cave area', 'Wall 3'"))
|
||||
|
||||
DatePicker(
|
||||
"Date Set",
|
||||
selection: $dateSet,
|
||||
displayedComponents: [.date]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func TagsSection() -> some View {
|
||||
Section("Tags (Optional)") {
|
||||
TextField("Tags", text: $tags, prompt: Text("e.g., crimpy, dynamic (comma-separated)"))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func PhotosSection() -> some View {
|
||||
Section("Photos (Optional)") {
|
||||
Button(action: {
|
||||
activeSheet = .photoOptions
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "camera.fill")
|
||||
.foregroundColor(themeManager.accentColor)
|
||||
.font(.title2)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Add Photos")
|
||||
.font(.headline)
|
||||
.foregroundColor(themeManager.accentColor)
|
||||
Text("\(imageData.count) of 5 photos added")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.disabled(imageData.count >= 5)
|
||||
|
||||
if !imageData.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(imageData.indices, id: \.self) { index in
|
||||
if let uiImage = UIImage(data: imageData[index]) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 80, height: 80)
|
||||
.clipped()
|
||||
.cornerRadius(8)
|
||||
|
||||
Button(action: {
|
||||
imageData.remove(at: index)
|
||||
if index < imagePaths.count {
|
||||
imagePaths.remove(at: index)
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
.background(Circle().fill(.white))
|
||||
.font(.system(size: 18))
|
||||
}
|
||||
.offset(x: 4, y: -4)
|
||||
}
|
||||
.frame(width: 88, height: 88) // Extra space for button
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(.gray.opacity(0.3))
|
||||
.frame(width: 80, height: 80)
|
||||
.overlay {
|
||||
Image(systemName: "photo")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 1)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func AdditionalInfoSection() -> some View {
|
||||
Section("Additional Information") {
|
||||
@@ -458,11 +191,10 @@ struct AddEditProblemView: View {
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
selectedGym != nil
|
||||
&& !difficultyGrade.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
selectedGym != nil && difficultyGrade.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
}
|
||||
|
||||
private func setupInitialGym() {
|
||||
private func setupInitialClimbType() {
|
||||
if let gymId = gymId {
|
||||
selectedGym = dataManager.gym(withId: gymId)
|
||||
}
|
||||
@@ -496,138 +228,57 @@ struct AddEditProblemView: View {
|
||||
imageData.append(data)
|
||||
}
|
||||
}
|
||||
|
||||
if let dateSet = problem.dateSet {
|
||||
self.dateSet = dateSet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateAvailableOptions() {
|
||||
guard let gym = selectedGym else { return }
|
||||
|
||||
// Auto-select climb type if there's only one available
|
||||
if gym.supportedClimbTypes.count == 1, selectedClimbType != gym.supportedClimbTypes.first! {
|
||||
selectedClimbType = gym.supportedClimbTypes.first!
|
||||
}
|
||||
|
||||
updateDifficultySystem()
|
||||
}
|
||||
|
||||
private func updateDifficultySystem() {
|
||||
let available = availableDifficultySystems
|
||||
|
||||
if !available.contains(selectedDifficultySystem) {
|
||||
selectedDifficultySystem = available.first ?? .custom
|
||||
}
|
||||
|
||||
if available.count == 1, selectedDifficultySystem != available.first! {
|
||||
selectedDifficultySystem = available.first!
|
||||
}
|
||||
}
|
||||
|
||||
private func resetGradeIfNeeded() {
|
||||
let availableGrades = selectedDifficultySystem.availableGrades
|
||||
if !availableGrades.isEmpty && !availableGrades.contains(difficultyGrade) {
|
||||
difficultyGrade = ""
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSelectedPhotos() async {
|
||||
for item in selectedPhotos {
|
||||
if let data = try? await item.loadTransferable(type: Data.self) {
|
||||
imageData.append(data)
|
||||
}
|
||||
}
|
||||
selectedPhotos.removeAll()
|
||||
}
|
||||
|
||||
private func saveProblem() {
|
||||
guard let gym = selectedGym, canSave else { return }
|
||||
guard let gym = selectedGym else { return }
|
||||
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let trimmedLocation = location.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedTags = tags.split(separator: ",").map {
|
||||
$0.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}.filter { !$0.isEmpty }
|
||||
|
||||
let difficulty = DifficultyGrade(system: selectedDifficultySystem, grade: difficultyGrade)
|
||||
let tempImagePaths = imagePaths.filter { !$0.isEmpty && !imagePaths.contains($0) }
|
||||
for imagePath in tempImagePaths {
|
||||
_ = ImageManager.shared.deleteImage(atPath: imagePath)
|
||||
}
|
||||
|
||||
let newImagePaths = imagePaths.filter { !$0.isEmpty }
|
||||
|
||||
if isEditing, let problem = existingProblem {
|
||||
var allImagePaths = imagePaths
|
||||
|
||||
let newImagesStartIndex = imagePaths.count
|
||||
if imageData.count > newImagesStartIndex {
|
||||
for i in newImagesStartIndex..<imageData.count {
|
||||
let data = imageData[i]
|
||||
let imageIndex = allImagePaths.count
|
||||
let deterministicName = ImageNamingUtils.generateImageFilename(
|
||||
problemId: problem.id.uuidString, imageIndex: imageIndex)
|
||||
|
||||
if let relativePath = ImageManager.shared.saveImageData(
|
||||
data, withName: deterministicName)
|
||||
{
|
||||
allImagePaths.append(relativePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let updatedProblem = problem.updated(
|
||||
name: trimmedName.isEmpty ? nil : trimmedName,
|
||||
description: trimmedDescription.isEmpty ? nil : trimmedDescription,
|
||||
climbType: selectedClimbType,
|
||||
difficulty: difficulty,
|
||||
|
||||
difficulty: DifficultyGrade(system: selectedDifficultySystem, grade: difficultyGrade),
|
||||
tags: trimmedTags,
|
||||
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
|
||||
imagePaths: allImagePaths,
|
||||
imagePaths: newImagePaths.isEmpty ? [] : newImagePaths,
|
||||
isActive: isActive,
|
||||
dateSet: dateSet,
|
||||
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
|
||||
)
|
||||
dataManager.updateProblem(updatedProblem)
|
||||
dismiss()
|
||||
} else {
|
||||
let newProblem = Problem(
|
||||
let problem = Problem(
|
||||
gymId: gym.id,
|
||||
name: trimmedName.isEmpty ? nil : trimmedName,
|
||||
description: trimmedDescription.isEmpty ? nil : trimmedDescription,
|
||||
climbType: selectedClimbType,
|
||||
difficulty: difficulty,
|
||||
|
||||
difficulty: DifficultyGrade(system: selectedDifficultySystem, grade: difficultyGrade),
|
||||
tags: trimmedTags,
|
||||
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
|
||||
imagePaths: [],
|
||||
dateSet: dateSet,
|
||||
imagePaths: newImagePaths.isEmpty ? [] : newImagePaths,
|
||||
dateSet: Date(),
|
||||
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
|
||||
)
|
||||
|
||||
dataManager.addProblem(newProblem)
|
||||
|
||||
if !imageData.isEmpty {
|
||||
var imagePaths: [String] = []
|
||||
|
||||
for (index, data) in imageData.enumerated() {
|
||||
let deterministicName = ImageNamingUtils.generateImageFilename(
|
||||
problemId: newProblem.id.uuidString, imageIndex: index)
|
||||
|
||||
if let relativePath = ImageManager.shared.saveImageData(
|
||||
data, withName: deterministicName)
|
||||
{
|
||||
imagePaths.append(relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
if !imagePaths.isEmpty {
|
||||
let updatedProblem = newProblem.updated(imagePaths: imagePaths)
|
||||
dataManager.updateProblem(updatedProblem)
|
||||
}
|
||||
}
|
||||
dataManager.addProblem(problem)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,34 +56,38 @@ struct AddEditSessionView: View {
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(dataManager.gyms, id: \.id) { gym in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(gym.name)
|
||||
.font(.headline)
|
||||
|
||||
if let location = gym.location, !location.isEmpty {
|
||||
Text(location)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
gymRow(gym: gym, isSelected: selectedGym?.id == gym.id)
|
||||
.onTapGesture {
|
||||
selectedGym = gym
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if selectedGym?.id == gym.id {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(themeManager.accentColor)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
selectedGym = gym
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func gymRow(gym: Gym, isSelected: Bool) -> some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(gym.name)
|
||||
.font(.headline)
|
||||
|
||||
if let location = gym.location, !location.isEmpty {
|
||||
Text(location)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(themeManager.accentColor)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func SessionDetailsSection() -> some View {
|
||||
Section("Session Details") {
|
||||
|
||||
@@ -57,8 +57,7 @@ struct CalendarView: View {
|
||||
if let activeSession = dataManager.activeSession,
|
||||
let gym = dataManager.gym(withId: activeSession.gymId)
|
||||
{
|
||||
ActiveSessionBanner(session: activeSession, gym: gym)
|
||||
.environmentObject(MusicService.shared)
|
||||
ActiveSessionBanner(session: activeSession, gym: gym, onNavigateToSession: onNavigateToSession)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import Combine
|
||||
import MusicKit
|
||||
import SwiftUI
|
||||
|
||||
struct SessionDetailView: View {
|
||||
let sessionId: UUID
|
||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||
@EnvironmentObject var themeManager: ThemeManager
|
||||
@EnvironmentObject var musicService: MusicService
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var showingDeleteAlert = false
|
||||
@State private var showingAddAttempt = false
|
||||
@@ -47,12 +45,7 @@ struct SessionDetailView: View {
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
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)
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
|
||||
@@ -449,52 +442,7 @@ struct SessionStats {
|
||||
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 {
|
||||
NavigationView {
|
||||
|
||||
@@ -27,7 +27,9 @@ struct SessionsView: View {
|
||||
EmptySessionsView()
|
||||
} else {
|
||||
if viewMode == .list {
|
||||
SessionsList()
|
||||
SessionsList(onNavigateToSession: { sessionId in
|
||||
selectedSessionId = sessionId
|
||||
})
|
||||
} else {
|
||||
CalendarView(
|
||||
sessions: completedSessions,
|
||||
@@ -108,6 +110,7 @@ struct SessionsView: View {
|
||||
struct SessionsList: View {
|
||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||
@State private var sessionToDelete: ClimbSession?
|
||||
var onNavigateToSession: (UUID) -> Void
|
||||
|
||||
private var completedSessions: [ClimbSession] {
|
||||
dataManager.sessions
|
||||
@@ -121,8 +124,11 @@ struct SessionsList: View {
|
||||
let gym = dataManager.gym(withId: activeSession.gymId)
|
||||
{
|
||||
Section {
|
||||
ActiveSessionBanner(session: activeSession, gym: gym)
|
||||
.environmentObject(MusicService.shared)
|
||||
ActiveSessionBanner(
|
||||
session: activeSession,
|
||||
gym: gym,
|
||||
onNavigateToSession: onNavigateToSession
|
||||
)
|
||||
.padding(.horizontal, 16)
|
||||
.listRowInsets(EdgeInsets(top: 16, leading: 0, bottom: 24, trailing: 0))
|
||||
.listRowBackground(Color.clear)
|
||||
@@ -184,8 +190,7 @@ struct ActiveSessionBanner: View {
|
||||
let session: ClimbSession
|
||||
let gym: Gym
|
||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||
@EnvironmentObject var musicService: MusicService
|
||||
@State private var navigateToDetail = false
|
||||
var onNavigateToSession: (UUID) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
@@ -210,23 +215,12 @@ struct ActiveSessionBanner: View {
|
||||
.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)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
navigateToDetail = true
|
||||
onNavigateToSession(session.id)
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
@@ -249,9 +243,7 @@ struct ActiveSessionBanner: View {
|
||||
.fill(.green.opacity(0.1))
|
||||
.stroke(.green.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
.navigationDestination(isPresented: $navigateToDetail) {
|
||||
SessionDetailView(sessionId: session.id)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
90
ios/Ascently/Views/Settings/AppearanceView.swift
Normal file
90
ios/Ascently/Views/Settings/AppearanceView.swift
Normal file
@@ -0,0 +1,90 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AppearanceView: View {
|
||||
@EnvironmentObject var themeManager: ThemeManager
|
||||
|
||||
let columns = [
|
||||
GridItem(.adaptive(minimum: 44))
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Appearance") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Accent Color")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.textCase(.uppercase)
|
||||
|
||||
LazyVGrid(columns: columns, spacing: 12) {
|
||||
ForEach(ThemeManager.presetColors, id: \.self) { color in
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 44, height: 44)
|
||||
.overlay(
|
||||
ZStack {
|
||||
if isSelected(color) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.shadow(radius: 1)
|
||||
}
|
||||
}
|
||||
)
|
||||
.onTapGesture {
|
||||
withAnimation {
|
||||
themeManager.accentColor = color
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(colorDescription(for: color))
|
||||
.accessibilityAddTraits(isSelected(color) ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
if !isSelected(.blue) {
|
||||
Button("Reset to Default") {
|
||||
withAnimation {
|
||||
themeManager.resetToDefault()
|
||||
}
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Appearance")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func isSelected(_ color: Color) -> Bool {
|
||||
let selectedUIColor = UIColor(themeManager.accentColor)
|
||||
let targetUIColor = UIColor(color)
|
||||
|
||||
return selectedUIColor == targetUIColor
|
||||
}
|
||||
|
||||
private func colorDescription(for color: Color) -> String {
|
||||
switch color {
|
||||
case .blue: return "Blue"
|
||||
case .purple: return "Purple"
|
||||
case .pink: return "Pink"
|
||||
case .red: return "Red"
|
||||
case .orange: return "Orange"
|
||||
case .green: return "Green"
|
||||
case .teal: return "Teal"
|
||||
case .indigo: return "Indigo"
|
||||
case .mint: return "Mint"
|
||||
case Color(uiColor: .systemBrown): return "Brown"
|
||||
case Color(uiColor: .systemCyan): return "Cyan"
|
||||
default: return "Color"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
AppearanceView()
|
||||
.environmentObject(ThemeManager())
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import HealthKit
|
||||
import MusicKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
@@ -24,8 +23,7 @@ struct SettingsView: View {
|
||||
HealthKitSection()
|
||||
.environmentObject(dataManager.healthKitService)
|
||||
|
||||
MusicSection()
|
||||
.environmentObject(dataManager.musicService)
|
||||
|
||||
|
||||
AppearanceSection()
|
||||
|
||||
@@ -684,8 +682,8 @@ struct ExportDataView: View {
|
||||
|
||||
private func cleanupTempFile() {
|
||||
if let fileURL = tempFileURL {
|
||||
let logTag = Self.logTag // Capture before entering async closure
|
||||
// Clean up after a delay to ensure sharing is complete
|
||||
let logTag = Self.logTag
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
AppLogger.debug(
|
||||
@@ -754,80 +752,84 @@ struct SyncSection: View {
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
|
||||
if syncService.isConfigured {
|
||||
|
||||
// Sync Now - only show if connected
|
||||
// Sync Now - show with proper opacity when not available
|
||||
Button(action: {
|
||||
if syncService.isConnected {
|
||||
Button(action: {
|
||||
performSync()
|
||||
}) {
|
||||
HStack {
|
||||
if syncService.isSyncing {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
Text("Syncing...")
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.foregroundColor(.green)
|
||||
Text("Sync Now")
|
||||
Spacer()
|
||||
if let lastSync = syncService.lastSyncTime {
|
||||
Text(
|
||||
RelativeDateTimeFormatter().localizedString(
|
||||
for: lastSync, relativeTo: Date())
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(syncService.isSyncing)
|
||||
.foregroundColor(.primary)
|
||||
performSync()
|
||||
}
|
||||
|
||||
// Auto-sync configuration - always visible for testing
|
||||
}) {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Auto-sync")
|
||||
Text("Sync automatically on app launch and data changes")
|
||||
if syncService.isSyncing {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
Text("Syncing...")
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.foregroundColor(syncService.isConnected ? .green : .secondary)
|
||||
Text("Sync Now")
|
||||
.foregroundColor(syncService.isConnected ? .primary : .secondary)
|
||||
Spacer()
|
||||
if let lastSync = syncService.lastSyncTime {
|
||||
Text(
|
||||
RelativeDateTimeFormatter().localizedString(
|
||||
for: lastSync, relativeTo: Date())
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Toggle(
|
||||
"",
|
||||
isOn: Binding(
|
||||
get: { syncService.isAutoSyncEnabled },
|
||||
set: { syncService.isAutoSyncEnabled = $0 }
|
||||
)
|
||||
)
|
||||
.disabled(!syncService.isConnected)
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
|
||||
// Disconnect option - only show if connected
|
||||
if syncService.isConnected {
|
||||
Button(action: {
|
||||
showingDisconnectAlert = true
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "power")
|
||||
.foregroundColor(.orange)
|
||||
Text("Disconnect")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
.disabled(!syncService.isConnected || syncService.isSyncing)
|
||||
.opacity(syncService.isConfigured ? 1.0 : 0.6)
|
||||
|
||||
if let error = syncService.syncError {
|
||||
// Auto-sync configuration
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Auto-sync")
|
||||
Text("Sync automatically on app launch and data changes")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Toggle(
|
||||
"",
|
||||
isOn: Binding(
|
||||
get: { syncService.isAutoSyncEnabled },
|
||||
set: { syncService.isAutoSyncEnabled = $0 }
|
||||
)
|
||||
)
|
||||
.disabled(!syncService.isConnected)
|
||||
}
|
||||
.opacity(syncService.isConfigured ? 1.0 : 0.6)
|
||||
|
||||
// Disconnect option
|
||||
Button(action: {
|
||||
if syncService.isConnected {
|
||||
showingDisconnectAlert = true
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "power")
|
||||
.foregroundColor(syncService.isConnected ? .orange : .secondary)
|
||||
Text("Disconnect")
|
||||
.foregroundColor(syncService.isConnected ? .primary : .secondary)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.disabled(!syncService.isConnected)
|
||||
.opacity(syncService.isConfigured ? 1.0 : 0.6)
|
||||
|
||||
// Error message
|
||||
if let error = syncService.syncError {
|
||||
HStack {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
.padding(.leading, 24)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.leading, 24)
|
||||
}
|
||||
}
|
||||
.alert("Disconnect from Server", isPresented: $showingDisconnectAlert) {
|
||||
@@ -883,12 +885,12 @@ struct SyncSettingsView: View {
|
||||
|
||||
if selectedProvider == .server {
|
||||
Section {
|
||||
TextField("Server URL", text: $serverURL, prompt: Text("http://your-server:8080"))
|
||||
TextField("Server URL", text: $serverURL)
|
||||
.keyboardType(.URL)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
|
||||
TextField("Auth Token", text: $authToken, prompt: Text("your-secret-token"))
|
||||
TextField("Auth Token", text: $authToken)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
} header: {
|
||||
@@ -1217,34 +1219,32 @@ struct HealthKitSection: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
Toggle(
|
||||
isOn: Binding(
|
||||
get: { healthKitService.isEnabled },
|
||||
set: { newValue in
|
||||
if newValue && !healthKitService.isAuthorized {
|
||||
isRequestingAuthorization = true
|
||||
Task {
|
||||
do {
|
||||
try await healthKitService.requestAuthorization()
|
||||
await MainActor.run {
|
||||
healthKitService.setEnabled(true)
|
||||
isRequestingAuthorization = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
showingAuthorizationError = true
|
||||
isRequestingAuthorization = false
|
||||
}
|
||||
Toggle(isOn: Binding(
|
||||
get: { healthKitService.isEnabled },
|
||||
set: { newValue in
|
||||
if newValue && !healthKitService.isAuthorized {
|
||||
isRequestingAuthorization = true
|
||||
Task {
|
||||
do {
|
||||
try await healthKitService.requestAuthorization()
|
||||
await MainActor.run {
|
||||
healthKitService.setEnabled(true)
|
||||
isRequestingAuthorization = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
showingAuthorizationError = true
|
||||
isRequestingAuthorization = false
|
||||
}
|
||||
}
|
||||
} else if newValue {
|
||||
healthKitService.setEnabled(true)
|
||||
} else {
|
||||
healthKitService.setEnabled(false)
|
||||
}
|
||||
} else if newValue {
|
||||
healthKitService.setEnabled(true)
|
||||
} else {
|
||||
healthKitService.setEnabled(false)
|
||||
}
|
||||
)
|
||||
) {
|
||||
}
|
||||
)) {
|
||||
HStack {
|
||||
Image(systemName: "heart.fill")
|
||||
.foregroundColor(.red)
|
||||
@@ -1252,14 +1252,15 @@ struct HealthKitSection: View {
|
||||
}
|
||||
}
|
||||
.disabled(isRequestingAuthorization)
|
||||
|
||||
|
||||
if healthKitService.isEnabled {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(
|
||||
"Climbing sessions will be recorded as workouts in Apple Health"
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Climbing sessions will be recorded as workouts in Apple Health")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1282,52 +1283,7 @@ 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 {
|
||||
SettingsView()
|
||||
|
||||
Reference in New Issue
Block a user