O p t i m i z e

This commit is contained in:
2026-02-01 23:52:50 -07:00
parent 3874703fcb
commit 25688b0615
10 changed files with 390 additions and 472 deletions

View 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()
}
}
}

View 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)
}
}
}
}

View 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 }
}
}

View File

@@ -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()
}
}

View File

@@ -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") {

View File

@@ -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)

View File

@@ -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)
}
}
}

View 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())
}
}

View File

@@ -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: {