diff --git a/PulseLoop/App/AppTheme.swift b/PulseLoop/App/AppTheme.swift index ff42cbd..193d9c3 100644 --- a/PulseLoop/App/AppTheme.swift +++ b/PulseLoop/App/AppTheme.swift @@ -96,6 +96,15 @@ enum PulseColors { static let borderStrong = Color.white.opacity(0.16) } +/// Corner-radius tokens so peer glass surfaces share one radius instead of the +/// ad-hoc 16/18/20/22/24 mix. `card` for standard glass cards, `compact` for +/// smaller/inset surfaces, `control` for buttons and small controls. +enum PulseRadius { + static let card: CGFloat = 20 + static let compact: CGFloat = 16 + static let control: CGFloat = 12 +} + extension Color { init(hex: String) { let trimmed = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) diff --git a/PulseLoop/Coach/Schema/CoachActionCardView.swift b/PulseLoop/Coach/Schema/CoachActionCardView.swift index 12e52f3..a244542 100644 --- a/PulseLoop/Coach/Schema/CoachActionCardView.swift +++ b/PulseLoop/Coach/Schema/CoachActionCardView.swift @@ -29,7 +29,17 @@ struct CoachActionCardView: View { .font(PulseFont.footnote.weight(.semibold)) .frame(maxWidth: .infinity).padding(.vertical, 9) .foregroundStyle(PulseColors.textPrimary) - .pulseGlass(Capsule(), interactive: true) + // Solid tile, not glass: this button sits inside the card's own glass, + // and glass can't sample glass (renders flat). A subtle white sheen + + // hairline mirrors the Settings icon tile. + .background( + LinearGradient( + colors: [Color.white.opacity(0.16), Color.white.opacity(0.07)], + startPoint: .top, endPoint: .bottom + ), + in: Capsule() + ) + .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 0.5)) } .buttonStyle(.plain) Button(action: onConfirm) { @@ -37,8 +47,9 @@ struct CoachActionCardView: View { .font(PulseFont.footnote.weight(.semibold)) .frame(maxWidth: .infinity).padding(.vertical, 9) .foregroundStyle(.white) - .pulseGlass(Capsule(), interactive: true, - tint: isDestructive ? PulseColors.danger : PulseColors.accent) + // Solid accent/danger fill (not tinted glass): keeps the destructive + // affordance visible while avoiding glass-inside-glass flattening. + .background(isDestructive ? PulseColors.danger : PulseColors.accent, in: Capsule()) } .buttonStyle(.plain) } diff --git a/PulseLoop/Coach/Schema/CoachChartView.swift b/PulseLoop/Coach/Schema/CoachChartView.swift index 7e836e3..8dd0abf 100644 --- a/PulseLoop/Coach/Schema/CoachChartView.swift +++ b/PulseLoop/Coach/Schema/CoachChartView.swift @@ -53,7 +53,14 @@ struct CoachChartView: View { } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) - .pulseGlass(RoundedRectangle(cornerRadius: 16, style: .continuous)) + // Solid card fill, not glass: this chart card renders inside the assistant bubble's + // glass, and glass can't sample glass (renders flat). A card fill + hairline keeps it + // reading as a distinct raised surface. + .background(PulseColors.card, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(.white.opacity(0.12), lineWidth: 0.5) + ) } // MARK: - Standard plot (line / dot / bar / sparkline) diff --git a/PulseLoop/Coach/Schema/CoachResponseView.swift b/PulseLoop/Coach/Schema/CoachResponseView.swift index a1cc79f..0012627 100644 --- a/PulseLoop/Coach/Schema/CoachResponseView.swift +++ b/PulseLoop/Coach/Schema/CoachResponseView.swift @@ -140,7 +140,14 @@ struct CoachFollowUpChipLabel: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12).padding(.vertical, 10) - .pulseGlass(RoundedRectangle(cornerRadius: 12, style: .continuous), interactive: true) + // Solid tile, not glass: this chip renders inside the assistant bubble's glass, + // and glass can't sample glass (renders flat). A soft card fill + hairline keeps + // the chip visible as a tappable affordance. + .background(PulseColors.cardSoft, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(.white.opacity(0.12), lineWidth: 0.5) + ) } } diff --git a/PulseLoop/DesignSystem/WorkoutMapView.swift b/PulseLoop/DesignSystem/WorkoutMapView.swift index b82bf4a..fabee1e 100644 --- a/PulseLoop/DesignSystem/WorkoutMapView.swift +++ b/PulseLoop/DesignSystem/WorkoutMapView.swift @@ -58,8 +58,16 @@ struct WorkoutMapView: View { } .frame(height: height) .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) - .overlay(alignment: .bottomLeading) { infoOverlay } - .overlay(alignment: .topTrailing) { if follow { followControls } } + // Both glass overlays share one container so they render/blend as one group. + .overlay { + ZStack { + infoOverlay.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) + if follow { + followControls.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + } + } + .pulseGlassContainer(spacing: 8) + } .onAppear { recenter() } .onChange(of: coordinates.count) { _, _ in // Live (follow) mode tracks growth while auto-follow is engaged; static (summary) diff --git a/PulseLoop/Views/ActivityView.swift b/PulseLoop/Views/ActivityView.swift index 922e405..a48f638 100644 --- a/PulseLoop/Views/ActivityView.swift +++ b/PulseLoop/Views/ActivityView.swift @@ -14,32 +14,44 @@ struct ActivityView: View { @State private var goalsOpen = false @State private var historyOpen = false + @State private var dataChange = PulseDataChange.shared + // Cached off the render path (via reload() on token/data change) so an @Query invalidation + // doesn't re-run buildTodaySummary and re-create the glass tiles every body eval — that + // re-render flashed all the tiles white on iOS 26. Mirrors Today/Vitals. + @State private var summary: TodaySummary? + @State private var stale: [ActivitySession] = [] + @State private var caloriesAvailable = false + + private func reload() { + summary = MetricsService.buildTodaySummary(context: modelContext) + stale = ActivityRecorderService.recoverStaleSession(context: modelContext) + caloriesAvailable = MetricsService.isVisible(.calories, context: modelContext) + } var body: some View { - let summary = MetricsService.buildTodaySummary(context: modelContext) - let stale = ActivityRecorderService.recoverStaleSession(context: modelContext) let todayWorkouts = sessions.filter { $0.status == .finished && Calendar.current.isDateInToday($0.startedAt) } - let stepGoal = summary.goals.stepsDaily - let activeGoal = summary.goals.activeMinutesDaily - let todayIdx = todayWeekIndex() - let days = weeklyDays(summary: summary, stepGoal: stepGoal, todayIndex: todayIdx) - let activeDayCount = days.filter(\.completed).count ScrollView { VStack(spacing: 16) { - if !stale.isEmpty { - StaleSessionRecoveryCard(sessions: stale) - } + if let summary { + let stepGoal = summary.goals.stepsDaily + let activeGoal = summary.goals.activeMinutesDaily + let days = weeklyDays(summary: summary, stepGoal: stepGoal, todayIndex: todayWeekIndex()) + let activeDayCount = days.filter(\.completed).count + + if !stale.isEmpty { + StaleSessionRecoveryCard(sessions: stale) + } - DailyActivitySummaryCard( - summary: summary, - units: units, - caloriesAvailable: MetricsService.isVisible(.calories, context: modelContext) - ) { - path.append(AppRoute.activityTrends) - } + DailyActivitySummaryCard( + summary: summary, + units: units, + caloriesAvailable: caloriesAvailable + ) { + path.append(AppRoute.activityTrends) + } - HStack(spacing: 12) { + HStack(spacing: 12) { Button { path.append(AppRoute.recordSelect) } label: { Text("+ Record Activity") .font(PulseFont.headline) @@ -62,6 +74,8 @@ struct ActivityView: View { } .buttonStyle(.plain) } + // Record pill + calendar circle share one container so their glass renders/blends together. + .pulseGlassContainer(spacing: 12) Button { path.append(AppRoute.logPastActivity) } label: { HStack(spacing: 12) { @@ -133,13 +147,18 @@ struct ActivityView: View { .pulseGlass(RoundedRectangle(cornerRadius: 20, style: .continuous)) } .buttonStyle(.plain) + } // if let summary } .padding(.horizontal, 16) .padding(.bottom, 96) + // NB: no glass container around the whole content stack — it made adjacent workout + // cards blend/bleed into each other. Small button clusters keep their own containers. } .background(PulseColors.background) .refreshable { await coordinator.pullToRefresh() } .pulseScrollEdges() + .task(id: dataChange.token) { reload() } + .onChange(of: sessions.count) { _, _ in reload() } .sheet(isPresented: $goalsOpen) { GoalEditorSheet() } .sheet(isPresented: $historyOpen) { WorkoutHistorySheet(units: units) { id in diff --git a/PulseLoop/Views/CoachView.swift b/PulseLoop/Views/CoachView.swift index 5593af6..4d00ce9 100644 --- a/PulseLoop/Views/CoachView.swift +++ b/PulseLoop/Views/CoachView.swift @@ -122,6 +122,8 @@ struct CoachView: View { } } .padding(.horizontal, 12).padding(.vertical, 8) + // Prompt chips share one container so their glass renders/blends together. + .pulseGlassContainer(spacing: 6) } } composer @@ -188,18 +190,22 @@ struct CoachView: View { Text("Using your latest ring sync").font(PulseFont.caption2.weight(.regular)).foregroundStyle(PulseColors.textMuted) } Spacer() - Button { composerFocused = false; showUsage = true } label: { - Image(systemName: "info.circle").font(PulseFont.body).foregroundStyle(PulseColors.textSecondary) - .frame(width: 36, height: 36).pulseGlass(Circle(), interactive: true) - } - Button { newConversation() } label: { - Image(systemName: "plus").font(PulseFont.body).foregroundStyle(PulseColors.textSecondary) - .frame(width: 36, height: 36).pulseGlass(Circle(), interactive: true) - } - Button { composerFocused = false; showHistory = true } label: { - Image(systemName: "clock.arrow.circlepath").font(PulseFont.body).foregroundStyle(PulseColors.textSecondary) - .frame(width: 36, height: 36).pulseGlass(Circle(), interactive: true) + // Sibling glass circles share one container so their glass renders/blends together. + HStack(spacing: 12) { + Button { composerFocused = false; showUsage = true } label: { + Image(systemName: "info.circle").font(PulseFont.body).foregroundStyle(PulseColors.textSecondary) + .frame(width: 36, height: 36).pulseGlass(Circle(), interactive: true) + } + Button { newConversation() } label: { + Image(systemName: "plus").font(PulseFont.body).foregroundStyle(PulseColors.textSecondary) + .frame(width: 36, height: 36).pulseGlass(Circle(), interactive: true) + } + Button { composerFocused = false; showHistory = true } label: { + Image(systemName: "clock.arrow.circlepath").font(PulseFont.body).foregroundStyle(PulseColors.textSecondary) + .frame(width: 36, height: 36).pulseGlass(Circle(), interactive: true) + } } + .pulseGlassContainer(spacing: 8) } .padding(.horizontal, 16).padding(.vertical, 12) .background(PulseColors.secondaryBackground) @@ -243,6 +249,8 @@ struct CoachView: View { .buttonStyle(.plain) .disabled(!canSend) } + // Camera / field / send glass share one container so they render/blend together. + .pulseGlassContainer(spacing: 8) } .padding(.horizontal, 12).padding(.vertical, 10) .photosPicker(isPresented: $showPhotosPicker, selection: $photosPickerItem, matching: .images) diff --git a/PulseLoop/Views/LogPastActivityView.swift b/PulseLoop/Views/LogPastActivityView.swift index b3cbb86..51607e8 100644 --- a/PulseLoop/Views/LogPastActivityView.swift +++ b/PulseLoop/Views/LogPastActivityView.swift @@ -1,5 +1,6 @@ import SwiftUI import SwiftData +import UIKit struct LogPastActivityView: View { @Environment(\.modelContext) private var modelContext @@ -76,9 +77,11 @@ struct LogPastActivityView: View { durationMinutes: Double(durationMinutes), context: modelContext ) + UINotificationFeedbackGenerator().notificationOccurred(.success) // "activity logged" cue path.removeLast() path.append(AppRoute.activityDetail(session.id)) } catch { + UINotificationFeedbackGenerator().notificationOccurred(.error) saveError = error.localizedDescription } } @@ -136,6 +139,7 @@ private struct ActivityTypeGrid: View, Equatable { } } } + .sensoryFeedback(.selection, trigger: selectedType) } } @@ -153,21 +157,35 @@ private struct ActivityTypeButton: View, Equatable { HStack(spacing: 10) { Image(systemName: kind.symbol) .font(PulseFont.title3.weight(.regular)) - .foregroundStyle(isSelected ? PulseColors.accent : PulseColors.textSecondary) + .foregroundStyle(isSelected ? .white : PulseColors.textSecondary) .frame(width: 38, height: 38) - .background(isSelected ? PulseColors.accentSoft : PulseColors.cardSoft, in: Circle()) + .background(isSelected ? Color.white.opacity(0.22) : PulseColors.cardSoft, in: Circle()) Text(kind.label) .font(PulseFont.callout.weight(.semibold)) - .foregroundStyle(PulseColors.textPrimary) + .foregroundStyle(isSelected ? .white : PulseColors.textPrimary) Spacer(minLength: 0) + if isSelected { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 16)) + .foregroundStyle(PulseColors.accent) + .accessibilityHidden(true) + } } .padding(12) .frame(maxWidth: .infinity) - // Selected = accent-tinted glass; others = plain glass. - .pulseGlass(RoundedRectangle(cornerRadius: 18, style: .continuous), - interactive: true, tint: isSelected ? PulseColors.accent : nil) + .contentShape(RoundedRectangle(cornerRadius: PulseRadius.compact, style: .continuous)) + // Selected = dim accent-tinted glass; others = plain glass. + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.compact, style: .continuous), + interactive: true, tint: isSelected ? PulseColors.accentSoft : nil) + .overlay( + RoundedRectangle(cornerRadius: PulseRadius.compact, style: .continuous) + .strokeBorder(isSelected ? PulseColors.accent.opacity(0.9) : Color.clear, lineWidth: 1.5) + ) } .buttonStyle(.plain) + .accessibilityElement(children: .combine) + .accessibilityLabel(kind.label) + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) } } @@ -196,7 +214,7 @@ private struct PastActivityTimeCard: View { .foregroundStyle(isValid ? PulseColors.textSecondary : PulseColors.warning) } } - .pulseGlass(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) } private func row(title: String, systemImage: String, @ViewBuilder content: () -> Content) -> some View { @@ -207,7 +225,8 @@ private struct PastActivityTimeCard: View { Text(title) .font(PulseFont.subheadline) .foregroundStyle(PulseColors.textPrimary) - Spacer() + .fixedSize() // never wrap "Started" when the date/time chips are wide + Spacer(minLength: 8) content() } .padding(.horizontal, 16) @@ -223,10 +242,14 @@ private struct PastActivityDurationCard: View, Equatable { static func == (lhs: Self, rhs: Self) -> Bool { lhs.minutes == rhs.minutes } + private var atFloor: Bool { minutes <= 5 } + var body: some View { VStack(spacing: 16) { HStack { - durationButton(systemImage: "minus") { onChange(max(5, minutes - 5)) } + durationButton(systemImage: "minus", disabled: atFloor) { onChange(max(5, minutes - 5)) } + .accessibilityLabel("Decrease duration") + .accessibilityValue(durationText) Spacer() VStack(spacing: 2) { Text(durationText) @@ -240,31 +263,43 @@ private struct PastActivityDurationCard: View, Equatable { } Spacer() durationButton(systemImage: "plus") { onChange(minutes + 5) } + .accessibilityLabel("Increase duration") + .accessibilityValue(durationText) } HStack(spacing: 8) { ForEach(Self.quickDurations, id: \.self) { value in + let chipSelected = minutes == value Button("\(value)m") { onChange(value) } .font(PulseFont.caption.weight(.semibold)) - .foregroundStyle(minutes == value ? Color.white : PulseColors.textSecondary) + .foregroundStyle(chipSelected ? PulseColors.accent : PulseColors.textSecondary) .frame(maxWidth: .infinity) .frame(height: 34) - .background(minutes == value ? PulseColors.accent : PulseColors.cardSoft, in: Capsule()) + .background(chipSelected ? PulseColors.accentSoft : PulseColors.cardSoft, in: Capsule()) + .overlay( + Capsule().strokeBorder(chipSelected ? PulseColors.accent.opacity(0.9) : Color.clear, lineWidth: 1) + ) .buttonStyle(.plain) + .accessibilityLabel("\(value) minutes") + .accessibilityAddTraits(chipSelected ? [.isButton, .isSelected] : .isButton) } } + .sensoryFeedback(.selection, trigger: minutes) } .padding(16) - .pulseGlass(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) } - private func durationButton(systemImage: String, action: @escaping () -> Void) -> some View { + private func durationButton(systemImage: String, disabled: Bool = false, action: @escaping () -> Void) -> some View { Button(action: action) { - Image(systemName: systemImage).frame(width: 44, height: 44) + Image(systemName: systemImage) + .foregroundStyle(disabled ? PulseColors.textMuted : PulseColors.textPrimary) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) // whole 44pt circle taps, not just the thin glyph } .buttonStyle(.plain) - .foregroundStyle(PulseColors.textPrimary) .background(PulseColors.cardSoft, in: Circle()) + .disabled(disabled) } private var durationText: String { diff --git a/PulseLoop/Views/MetricDetailView.swift b/PulseLoop/Views/MetricDetailView.swift index 2924f10..e50035d 100644 --- a/PulseLoop/Views/MetricDetailView.swift +++ b/PulseLoop/Views/MetricDetailView.swift @@ -74,6 +74,8 @@ struct MetricDetailView: View { } .padding(16) .padding(.bottom, 40) + // Stacked glass cards share one container so their glass renders/blends consistently. + .pulseGlassContainer(spacing: 18) } .background(PulseColors.background) // Shared glass chrome: centered title + glass back button, no system nav @@ -133,7 +135,7 @@ struct MetricDetailView: View { } .padding(.horizontal, 14) .padding(.vertical, 10) - .pulseGlass(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) } /// BP shows two series — systolic in the metric accent, diastolic lighter. @@ -179,7 +181,7 @@ struct MetricDetailView: View { .padding(.vertical, 14) .padding(.horizontal, 10) .frame(maxWidth: .infinity) - .pulseGlass(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) } private func stat(_ title: String, _ value: String) -> some View { @@ -217,7 +219,7 @@ struct MetricDetailView: View { } } .padding(16) - .pulseGlass(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) } private var explainer: some View { @@ -227,7 +229,7 @@ struct MetricDetailView: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(16) - .pulseGlass(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) } private var disclaimer: some View { @@ -237,8 +239,8 @@ struct MetricDetailView: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(16) - .background(PulseColors.warning.opacity(0.1), in: RoundedRectangle(cornerRadius: 20, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 20, style: .continuous).stroke(PulseColors.warning.opacity(0.3), lineWidth: 1)) + .background(PulseColors.warning.opacity(0.1), in: RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous).stroke(PulseColors.warning.opacity(0.3), lineWidth: 1)) } // MARK: - Data diff --git a/PulseLoop/Views/RecordSummaryViews.swift b/PulseLoop/Views/RecordSummaryViews.swift index a54f709..8648721 100644 --- a/PulseLoop/Views/RecordSummaryViews.swift +++ b/PulseLoop/Views/RecordSummaryViews.swift @@ -132,7 +132,24 @@ struct RecordSummaryView: View { .font(PulseFont.footnote) .foregroundStyle(active ? PulseColors.textPrimary : PulseColors.textSecondary) .padding(.horizontal, 12).padding(.vertical, 8) - .pulseGlass(Capsule(), interactive: true, tint: active ? PulseColors.accent : nil) + // Solid tile, not glass: these pills sit inside the effort card's + // own glass, and glass can't sample glass (renders flat). Selected + // keeps the accent as a solid fill; unselected gets a subtle white + // sheen + hairline. + .background { + if active { + Capsule().fill(PulseColors.accent) + } else { + Capsule() + .fill( + LinearGradient( + colors: [Color.white.opacity(0.16), Color.white.opacity(0.07)], + startPoint: .top, endPoint: .bottom + ) + ) + .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 0.5)) + } + } } .buttonStyle(.plain) } @@ -141,7 +158,14 @@ struct RecordSummaryView: View { .lineLimit(2...4) .font(PulseFont.subheadline.weight(.regular)) .padding(12) - .pulseGlass(RoundedRectangle(cornerRadius: 14, style: .continuous)) + // Solid field background, not glass: this input sits inside the effort card's + // glass (glass can't sample glass → flat). A soft fill + hairline gives it a + // visible input affordance. + .background(PulseColors.cardSoft, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(.white.opacity(0.12), lineWidth: 0.5) + ) } .frame(maxWidth: .infinity, alignment: .leading) .padding(16) diff --git a/PulseLoop/Views/RecordViews.swift b/PulseLoop/Views/RecordViews.swift index 06b3f1e..9c7ea0e 100644 --- a/PulseLoop/Views/RecordViews.swift +++ b/PulseLoop/Views/RecordViews.swift @@ -1,5 +1,6 @@ import SwiftUI import SwiftData +import UIKit // MARK: - Shared small pieces @@ -379,12 +380,22 @@ private struct EditWorkoutSheet: View { struct RecordSelectView: View { @Environment(\.modelContext) private var modelContext @Environment(LiveWorkoutManager.self) private var liveWorkout + @Environment(\.dynamicTypeSize) private var dynamicTypeSize @Binding var path: NavigationPath @State private var selected = "run" @State private var useGps = true private var gpsCapable: Bool { ActivityMeta.meta(selected).gpsCapable } + // At accessibility text sizes the 2-up grid clips the 2-line helper — reflow to a + // single column so each card can grow vertically. Icon sizes stay fixed. + private var gridColumns: [GridItem] { + if dynamicTypeSize >= .accessibility1 { + return [GridItem(.flexible(), spacing: 12)] + } + return [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)] + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { @@ -393,34 +404,52 @@ struct RecordSelectView: View { .textCase(.uppercase) .foregroundStyle(PulseColors.textSecondary) - LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) { + LazyVGrid(columns: gridColumns, spacing: 12) { ForEach(ActivityMeta.allKinds) { kind in let isSelected = kind.type == selected Button { selected = kind.type } label: { VStack(alignment: .leading, spacing: 6) { Image(systemName: kind.symbol) .font(PulseFont.title2.weight(.regular)) - .foregroundStyle(isSelected ? PulseColors.accent : PulseColors.textSecondary) + .foregroundStyle(isSelected ? .white : PulseColors.textSecondary) .frame(width: 46, height: 46) - .background(isSelected ? PulseColors.accentSoft : PulseColors.cardSoft, in: Circle()) + .background(isSelected ? Color.white.opacity(0.22) : PulseColors.cardSoft, in: Circle()) Text(kind.label) .font(PulseFont.bodyEmphasis) - .foregroundStyle(PulseColors.textPrimary) + .foregroundStyle(isSelected ? .white : PulseColors.textPrimary) Text(kind.helper) .font(PulseFont.caption.weight(.regular)) - .foregroundStyle(PulseColors.textMuted) + .foregroundStyle(isSelected ? Color.white.opacity(0.85) : PulseColors.textMuted) .lineLimit(2) .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxWidth: .infinity, alignment: .leading) .padding(16) - .background(isSelected ? PulseColors.accentSoft : PulseColors.card) - .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 24, style: .continuous).stroke(isSelected ? PulseColors.accent : PulseColors.borderSubtle, lineWidth: 1)) + .contentShape(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous), + interactive: true, tint: isSelected ? PulseColors.accentSoft : nil) + .overlay( + RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous) + .strokeBorder(isSelected ? PulseColors.accent.opacity(0.9) : Color.clear, lineWidth: 1.5) + ) + .overlay(alignment: .topTrailing) { + if isSelected { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 18)) + .foregroundStyle(PulseColors.accent) + .padding(10) + .accessibilityHidden(true) + } + } } .buttonStyle(.plain) + .accessibilityElement(children: .combine) + .accessibilityLabel(kind.label) + .accessibilityHint(kind.helper) + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) } } + .sensoryFeedback(.selection, trigger: selected) Toggle(isOn: $useGps) { VStack(alignment: .leading, spacing: 2) { @@ -437,6 +466,7 @@ struct RecordSelectView: View { PrimaryButton(title: "Start", systemImage: "play.fill") { let willUseGps = useGps && gpsCapable let session = liveWorkout.start(type: selected, useGps: willUseGps) + UINotificationFeedbackGenerator().notificationOccurred(.success) // "it started" cue path.append(AppRoute.recordLive(session.id)) } }