Skip to content
Open
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
277 changes: 200 additions & 77 deletions PulseLoop/Views/Settings/AboutSettingsView.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import SwiftUI

/// About detail screen: app version, a short description, and project/license info.
/// About detail screen: a branded hero (logo + wordmark + tagline + a tappable version chip that
/// still hides the Android-style developer unlock), a short product description, and one grouped
/// Project section (source, license, community). Fits centered without scrolling on every iPhone;
/// accessibility Dynamic Type sizes fall back to a ScrollView.
struct AboutSettingsView: View {
@Binding var path: NavigationPath
/// Persisted developer unlock, surfaced as the Settings "Developer" row.
Expand All @@ -9,7 +12,10 @@
@State private var lastVersionTap: Date?
@State private var toast: String?
@State private var toastToken = 0
/// Drives the slow breathing glow behind the logo medallion.
@State private var breathe = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.dynamicTypeSize) private var dynamicTypeSize

/// Android-style: 7 quick taps on the version unlock Developer options.
private let developerTapThreshold = 7
Expand All @@ -21,71 +27,33 @@
}

private let repoURL = URL(string: "https://github.com/saksham2001/PulseLoopiOS")!
private let licenseURL = URL(string: "https://creativecommons.org/licenses/by/4.0/")!
private let discordURL = URL(string: "https://discord.gg/JWWcZaZeyG")!

private var appVersionLabel: String {
"v1.0.0-beta.2"
}
private var appVersionLabel: String { "v1.0.0-beta.2" }

var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 22) {
SettingsGroup(header: "App") {
FormField {
Button(action: registerVersionTap) {
StatusCopy(title: "Version", body: appVersionLabel)
}
.buttonStyle(VersionRowButtonStyle())
.overlay {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(PulseColors.accent.opacity(tapProgress * 0.22))
.allowsHitTesting(false)
}
.overlay {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(PulseColors.accent.opacity(tapProgress * 0.9), lineWidth: 1.5)
.allowsHitTesting(false)
}
.animation(reduceMotion ? nil : .easeOut(duration: 0.25), value: versionTapCount)
.accessibilityHint("Tap repeatedly to unlock developer options.")
.keyframeAnimator(initialValue: 1.0, trigger: versionTapCount) { content, value in
content.scaleEffect(reduceMotion ? 1.0 : value)
} keyframes: { _ in
LinearKeyframe(0.96, duration: 0.08)
SpringKeyframe(1.0, duration: 0.28, spring: .bouncy(extraBounce: 0.15))
}
.sensoryFeedback(trigger: versionTapCount) { _, count in
guard count > 0 else { return nil }
if count >= developerTapThreshold { return .success }
return count > 3 ? .impact(weight: .medium) : .impact(weight: .light)
}
}
Group {
if dynamicTypeSize.isAccessibilitySize {
// Large accessibility type can't fit without scrolling — degrade gracefully.
ScrollView {
content(s: 1.0)
.padding()
}
StatusCopy(
title: "PulseLoop",
body: """
An LLM-native health app that turns a cheap Bluetooth smart ring into a real, \
conversational health tracker. It talks to the ring directly over Bluetooth — no \
vendor cloud, no account — and layers an AI coach on top of your own data.
"""
)

SettingsGroup(header: "Project") {
linkCard(
icon: "chevron.left.forwardslash.chevron.right",
title: "Source on GitHub",
subtitle: "github.com/saksham2001/PulseLoopiOS",
url: repoURL
)
} else {
GeometryReader { geo in
// Scale hero + spacings to the available height so the page fills, centered,
// without scrolling on small iPhones and without a dead gap on big ones.
let s = min(1.0, max(0.80, geo.size.height / 680))
VStack(spacing: 0) {
Spacer(minLength: 0)
content(s: s)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, 24)
}
StatusCopy(
title: "License",
body: """
Creative Commons Attribution 4.0 International (CC BY 4.0). Free to share and \
adapt, including commercially, with appropriate credit: PulseLoop by Saksham Bhutani.
"""
)
}
.padding()
}
.background(PulseColors.background)
.pageChrome("About")
Expand All @@ -104,6 +72,140 @@
.animation(.easeInOut(duration: 0.2), value: toast)
}

@ViewBuilder
private func content(s: CGFloat) -> some View {
VStack(alignment: .center, spacing: (38 * s).rounded()) {
brandHero(s: s)

Text("PulseLoop turns an affordable Bluetooth smart ring into a private, conversational health companion — connecting directly to your ring over Bluetooth, with no vendor cloud and no account. An on-device AI coach reads the data that never leaves your phone.")

Check failure on line 80 in PulseLoop/Views/Settings/AboutSettingsView.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Line should be 200 characters or less; currently it has 273 characters (line_length)
.font(PulseFont.subheadline)
.foregroundStyle(PulseColors.textSecondary)
.multilineTextAlignment(.center)
.lineSpacing(3)
.frame(maxWidth: 480)

SettingsGroup(header: "Project") {
linkRow(
icon: "curlybraces", tint: PulseColors.accent,
title: "Source on GitHub",
subtitle: "github.com/saksham2001/PulseLoopiOS",
url: repoURL
)
linkRow(
icon: "bubble.left.and.bubble.right.fill", tint: PulseColors.accent,
title: "Join the community",
subtitle: "Discord",
url: discordURL
)
linkRow(
icon: "checkmark.seal", tint: PulseColors.info,
title: "License",
subtitle: "CC BY 4.0",
url: licenseURL
)
}
}
.frame(maxWidth: 560)
.frame(maxWidth: .infinity)
}

// MARK: - Brand hero

private func brandHero(s: CGFloat) -> some View {
let medallion = (104 * s).rounded()
let medallionShape = RoundedRectangle(cornerRadius: 26 * s, style: .continuous)
return VStack(alignment: .center, spacing: 0) {
// The logo art *is* the glass squircle: it fills the shape edge-to-edge, clipped to the
// squircle with a glass rim on top, rather than sitting small inside a tinted tile.
Image("pulseloop-logo")
.resizable()
.scaledToFill()
.frame(width: medallion, height: medallion)
.clipShape(medallionShape)
.pulseGlass(medallionShape)
.shadow(color: PulseColors.accent.opacity(0.4), radius: 22 * s, y: 8 * s)
// Slow breathing aura behind the medallion — a soft accent radial that swells and fades.
.background {
Circle()
.fill(
RadialGradient(
colors: [PulseColors.accent.opacity(0.85), PulseColors.accent.opacity(0.0)],
center: .center, startRadius: 0, endRadius: 120 * s
)
)
.frame(width: 210 * s, height: 210 * s)
.blur(radius: 26 * s)
.scaleEffect(breathe ? 1.28 : 0.84)
.opacity(breathe ? 1.0 : 0.55)
}
.accessibilityHidden(true)
.onAppear {
guard !reduceMotion else { return }
withAnimation(.easeInOut(duration: 2.8).repeatForever(autoreverses: true)) {
breathe = true
}
}

Text("PulseLoop")
.font(s >= 0.92 ? PulseFont.title : PulseFont.title2)
.foregroundStyle(PulseColors.textPrimary)
.padding(.top, (18 * s).rounded())

Text("Your smart ring, made conversational.")
.font(PulseFont.subheadline)
.foregroundStyle(PulseColors.textSecondary)
.multilineTextAlignment(.center)
.padding(.top, 8)

versionChip
.padding(.top, (22 * s).rounded())
}
.frame(maxWidth: .infinity)
}

/// The version pill — stands on its own (no card nesting) and carries the full developer unlock.
private var versionChip: some View {
Button(action: registerVersionTap) {
HStack(spacing: 6) {
if developerUnlocked {
Image(systemName: "checkmark.seal.fill")
.font(PulseFont.caption2.weight(.semibold))
.foregroundStyle(PulseColors.success)
}
Text(appVersionLabel)
.font(PulseFont.caption.weight(.semibold))
.foregroundStyle(PulseColors.textSecondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
.buttonStyle(VersionRowButtonStyle())
.pulseGlass(Capsule())
.overlay {
Capsule()
.fill(PulseColors.accent.opacity(tapProgress * 0.22))
.allowsHitTesting(false)
}
.overlay {
Capsule()
.strokeBorder(PulseColors.accent.opacity(tapProgress * 0.9), lineWidth: 1.5)
.allowsHitTesting(false)
}
.animation(reduceMotion ? nil : .easeOut(duration: 0.25), value: versionTapCount)
.accessibilityHint("Tap repeatedly to unlock developer options.")
.keyframeAnimator(initialValue: 1.0, trigger: versionTapCount) { content, value in
content.scaleEffect(reduceMotion ? 1.0 : value)
} keyframes: { _ in
LinearKeyframe(0.96, duration: 0.08)
SpringKeyframe(1.0, duration: 0.28, spring: .bouncy(extraBounce: 0.15))
}
.sensoryFeedback(trigger: versionTapCount) { _, count in
guard count > 0 else { return nil }
if count >= developerTapThreshold { return .success }
return count > 3 ? .impact(weight: .medium) : .impact(weight: .light)
}
}

// MARK: - Developer unlock (tap the version 7×, Android-style)

private func registerVersionTap() {
Expand Down Expand Up @@ -138,32 +240,53 @@
}
}

private func linkCard(icon: String, title: String, subtitle: String, url: URL) -> some View {
// MARK: - Link rows (one coherent icon family; the app's external-link affordance)

private func linkRow(icon: String, tint: Color, title: String, subtitle: String, url: URL) -> some View {
Link(destination: url) {
FormField {
HStack(spacing: 14) {
Image(systemName: icon)
HStack(spacing: 14) {
Image(systemName: icon)
.font(PulseFont.callout.weight(.semibold))
.foregroundStyle(.white)
.frame(width: 34, height: 34)
// Neutral "white glass" tile matching the Settings rows: a light translucent
// fill + top sheen and a hairline rim. glassEffect can't nest inside the glass
// section card, so this gradient stands in for Liquid Glass. White glyph, no tint.
.background(
LinearGradient(
colors: [Color.white.opacity(0.16), Color.white.opacity(0.07)],
startPoint: .top, endPoint: .bottom
),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(.white.opacity(0.18), lineWidth: 0.5)
)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(PulseFont.callout.weight(.semibold))
.foregroundStyle(PulseColors.accent)
.frame(width: 36, height: 36)
.background(PulseColors.accent.opacity(0.14), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
VStack(alignment: .leading, spacing: 2) {
Text(title).font(PulseFont.callout.weight(.semibold)).foregroundStyle(PulseColors.textPrimary)
Text(subtitle).font(PulseFont.caption.weight(.regular)).foregroundStyle(PulseColors.textSecondary)
}
Spacer(minLength: 8)
Image(systemName: "arrow.up.right")
.font(PulseFont.caption.weight(.semibold))
.foregroundStyle(PulseColors.textMuted)
.foregroundStyle(PulseColors.textPrimary)
Text(subtitle)
.font(PulseFont.caption.weight(.regular))
.foregroundStyle(PulseColors.textSecondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: 8)
Image(systemName: "arrow.up.right")
.font(PulseFont.caption.weight(.semibold))
.foregroundStyle(PulseColors.textMuted)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.frame(minHeight: 64)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}

/// Press feedback for the version row: dims briefly while held, marking it as tappable.
/// Press feedback for the version chip: dims briefly while held, marking it as tappable.
private struct VersionRowButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
Expand Down
Loading