diff --git a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index 9249e05..0517efe 100644 Binary files a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/Ascently/Components/ImagePicker.swift b/ios/Ascently/Components/ImagePicker.swift new file mode 100644 index 0000000..24cc8db --- /dev/null +++ b/ios/Ascently/Components/ImagePicker.swift @@ -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() + } + } +} diff --git a/ios/Ascently/Utils/AppExtensions.swift b/ios/Ascently/Utils/AppExtensions.swift new file mode 100644 index 0000000..72c6b93 --- /dev/null +++ b/ios/Ascently/Utils/AppExtensions.swift @@ -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(_ value: T, forKey key: String) { + UserDefaults.standard.set(value, forKey: key) + } + + static func get(_ 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) + } + } + } +} diff --git a/ios/Ascently/Utils/DataHelpers.swift b/ios/Ascently/Utils/DataHelpers.swift new file mode 100644 index 0000000..2fec7e5 --- /dev/null +++ b/ios/Ascently/Utils/DataHelpers.swift @@ -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 } + } +} diff --git a/ios/Ascently/Views/AddEdit/AddEditProblemView.swift b/ios/Ascently/Views/AddEdit/AddEditProblemView.swift index 90ad078..b71da70 100644 --- a/ios/Ascently/Views/AddEdit/AddEditProblemView.swift +++ b/ios/Ascently/Views/AddEdit/AddEditProblemView.swift @@ -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.. 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") { diff --git a/ios/Ascently/Views/CalendarView.swift b/ios/Ascently/Views/CalendarView.swift index cd252e8..5fe3974 100644 --- a/ios/Ascently/Views/CalendarView.swift +++ b/ios/Ascently/Views/CalendarView.swift @@ -57,7 +57,7 @@ struct CalendarView: View { if let activeSession = dataManager.activeSession, let gym = dataManager.gym(withId: activeSession.gymId) { - ActiveSessionBanner(session: activeSession, gym: gym) + ActiveSessionBanner(session: activeSession, gym: gym, onNavigateToSession: onNavigateToSession) .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, 16) diff --git a/ios/Ascently/Views/SessionsView.swift b/ios/Ascently/Views/SessionsView.swift index e3204b1..0cc4425 100644 --- a/ios/Ascently/Views/SessionsView.swift +++ b/ios/Ascently/Views/SessionsView.swift @@ -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,7 +124,11 @@ struct SessionsList: View { let gym = dataManager.gym(withId: activeSession.gymId) { Section { - ActiveSessionBanner(session: activeSession, gym: gym) + ActiveSessionBanner( + session: activeSession, + gym: gym, + onNavigateToSession: onNavigateToSession + ) .padding(.horizontal, 16) .listRowInsets(EdgeInsets(top: 16, leading: 0, bottom: 24, trailing: 0)) .listRowBackground(Color.clear) @@ -183,8 +190,7 @@ struct ActiveSessionBanner: View { let session: ClimbSession let gym: Gym @EnvironmentObject var dataManager: ClimbingDataManager - - @State private var navigateToDetail = false + var onNavigateToSession: (UUID) -> Void var body: some View { HStack { @@ -214,7 +220,7 @@ struct ActiveSessionBanner: View { .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .onTapGesture { - navigateToDetail = true + onNavigateToSession(session.id) } Button(action: { @@ -237,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) - } + } } diff --git a/ios/Ascently/Views/Settings/AppearanceView.swift b/ios/Ascently/Views/Settings/AppearanceView.swift new file mode 100644 index 0000000..9571846 --- /dev/null +++ b/ios/Ascently/Views/Settings/AppearanceView.swift @@ -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()) + } +} diff --git a/ios/Ascently/Views/SettingsView.swift b/ios/Ascently/Views/SettingsView.swift index abc41a4..8ff94f2 100644 --- a/ios/Ascently/Views/SettingsView.swift +++ b/ios/Ascently/Views/SettingsView.swift @@ -881,12 +881,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: {