Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions PulseLoop/App/AppTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 14 additions & 3 deletions PulseLoop/Coach/Schema/CoachActionCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,27 @@ 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) {
Text(action.confirmLabel)
.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)
}
Expand Down
9 changes: 8 additions & 1 deletion PulseLoop/Coach/Schema/CoachChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion PulseLoop/Coach/Schema/CoachResponseView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
}
}

Expand Down
12 changes: 10 additions & 2 deletions PulseLoop/DesignSystem/WorkoutMapView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
55 changes: 37 additions & 18 deletions PulseLoop/Views/ActivityView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
30 changes: 19 additions & 11 deletions PulseLoop/Views/CoachView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
65 changes: 50 additions & 15 deletions PulseLoop/Views/LogPastActivityView.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import SwiftUI
import SwiftData
import UIKit

struct LogPastActivityView: View {
@Environment(\.modelContext) private var modelContext
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -136,6 +139,7 @@ private struct ActivityTypeGrid: View, Equatable {
}
}
}
.sensoryFeedback(.selection, trigger: selectedType)
}
}

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

Expand Down Expand Up @@ -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<Content: View>(title: String, systemImage: String, @ViewBuilder content: () -> Content) -> some View {
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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 {
Expand Down
Loading
Loading