From df998d752f5c6060accc0c43f2dc2a25292e0edf Mon Sep 17 00:00:00 2001 From: Belli Date: Sat, 15 Aug 2026 23:49:19 -0700 Subject: [PATCH 1/5] chore(strings): catalog picks up the goal-flow strings from PR C --- Savely/Resources/Localizable.xcstrings | 45 ++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/Savely/Resources/Localizable.xcstrings b/Savely/Resources/Localizable.xcstrings index 69d3746..3c1545a 100755 --- a/Savely/Resources/Localizable.xcstrings +++ b/Savely/Resources/Localizable.xcstrings @@ -9,6 +9,9 @@ }, " of %lld" : { + }, + "—" : { + }, ".00" : { @@ -323,6 +326,9 @@ }, "Are you sure you want to delete this income?" : { + }, + "AUTO" : { + }, "Auto-move on payday" : { @@ -350,6 +356,9 @@ } } } + }, + "Can't save yet" : { + }, "Cancel" : { @@ -431,6 +440,9 @@ } } } + }, + "Completed" : { + }, "confirm_password_string" : { "comment" : "Confirm Password String", @@ -506,6 +518,12 @@ } } } + }, + "Couldn't save the deposit" : { + + }, + "Couldn't save the goal" : { + }, "Create your first goal" : { @@ -836,6 +854,9 @@ }, "Edit anything" : { + }, + "Edit goal" : { + }, "email_placeholder_title" : { "comment" : "Email Placeholder Label", @@ -1268,6 +1289,9 @@ }, "Goal · %@" : { + }, + "Goal name" : { + }, "goal_alert_body" : { "comment" : "Goal Alert Body", @@ -1691,6 +1715,12 @@ }, "No active goal" : { + }, + "no date" : { + + }, + "No deposits yet — the first one lands here." : { + }, "No expenses yet" : { @@ -1700,6 +1730,9 @@ }, "No income yet" : { + }, + "No target date — save at your own pace." : { + }, "No tip for today yet — check back soon." : { @@ -1898,6 +1931,9 @@ }, "of $%lld" : { + }, + "OK" : { + }, "ok_button" : { "comment" : "OK Button", @@ -2054,6 +2090,9 @@ } } } + }, + "Pick the amount later in the goal's settings." : { + }, "Pick what to log right now." : { @@ -2789,9 +2828,6 @@ }, "SUGGESTED PACE" : { - }, - "SYMBOL" : { - }, "Take a look\nbefore we start." : { @@ -2813,6 +2849,9 @@ } } } + }, + "Target date" : { + }, "terms_of_service_label" : { "comment" : "Terms of Service Label", From f78dae946a5898eacd6d8f86766c16702005df39 Mon Sep 17 00:00:00 2001 From: Belli Date: Sat, 15 Aug 2026 23:52:19 -0700 Subject: [PATCH 2/5] =?UTF-8?q?feat(a11y):=20warmFont=20=E2=80=94=20Dynami?= =?UTF-8?q?c=20Type=20for=20a=20fixed-size=20design;=20tappable44?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every text in the app was a fixed .system(size:) — Dynamic Type did nothing. .warmFont(size, weight:, design:) renders at exactly the design size at the default content size (unit-tested as an identity across the scale) and follows the user's reading size from there, on the curve of the text style closest to that size so headings grow less than body at accessibility sizes. It is a ViewModifier that reads the size category from the environment, so views re-render on change instead of baking a stale Font value. .tappable44() enlarges a small control's hit area to the 44pt minimum without changing its look. --- Savely.xcodeproj/project.pbxproj | 8 +++ Savely/Resources/Font+Warm.swift | 118 +++++++++++++++++++++++++++++++ SavelyTests/WarmTypeTests.swift | 47 ++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 Savely/Resources/Font+Warm.swift create mode 100644 SavelyTests/WarmTypeTests.swift diff --git a/Savely.xcodeproj/project.pbxproj b/Savely.xcodeproj/project.pbxproj index 76274ba..e1f9e65 100644 --- a/Savely.xcodeproj/project.pbxproj +++ b/Savely.xcodeproj/project.pbxproj @@ -80,6 +80,8 @@ 1DE9FE172D066EE20069AA64 /* ReportsPDFGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DE9FE162D066EE20069AA64 /* ReportsPDFGenerator.swift */; }; 1DEACCDE2D0BE6340084CC39 /* NetworkErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DEACCDD2D0BE6340084CC39 /* NetworkErrorView.swift */; }; 1DF7228E2F9C182300320B38 /* Color+Warm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF7228D2F9C182300320B38 /* Color+Warm.swift */; }; + A11A0020C0DE5AFE10000020 /* Font+Warm.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A001FC0DE5AFE1000001F /* Font+Warm.swift */; }; + A11A0022C0DE5AFE10000022 /* WarmTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0021C0DE5AFE10000021 /* WarmTypeTests.swift */; }; 1DF722902F9C18FE00320B38 /* GoalDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF7228F2F9C18FE00320B38 /* GoalDetailView.swift */; }; 1DF722922F9C19CE00320B38 /* MoneyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF722912F9C19CE00320B38 /* MoneyView.swift */; }; 1DF722942F9C19E600320B38 /* AchievementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF722932F9C19E600320B38 /* AchievementsView.swift */; }; @@ -184,6 +186,8 @@ 1DE9FE162D066EE20069AA64 /* ReportsPDFGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportsPDFGenerator.swift; sourceTree = ""; }; 1DEACCDD2D0BE6340084CC39 /* NetworkErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkErrorView.swift; sourceTree = ""; }; 1DF7228D2F9C182300320B38 /* Color+Warm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Warm.swift"; sourceTree = ""; }; + A11A001FC0DE5AFE1000001F /* Font+Warm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Font+Warm.swift"; sourceTree = ""; }; + A11A0021C0DE5AFE10000021 /* WarmTypeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WarmTypeTests.swift; sourceTree = ""; }; 1DF7228F2F9C18FE00320B38 /* GoalDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalDetailView.swift; sourceTree = ""; }; 1DF722912F9C19CE00320B38 /* MoneyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoneyView.swift; sourceTree = ""; }; 1DF722932F9C19E600320B38 /* AchievementsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementsView.swift; sourceTree = ""; }; @@ -239,6 +243,7 @@ 1D3669882CC62F9100E84FF9 /* UIConstants.swift */, 1D36698A2CC62FD700E84FF9 /* Localizable.xcstrings */, 1DF7228D2F9C182300320B38 /* Color+Warm.swift */, + A11A001FC0DE5AFE1000001F /* Font+Warm.swift */, ); path = Resources; sourceTree = ""; @@ -472,6 +477,7 @@ A11A0013C0DE5AFE10000013 /* GoalDepositsTests.swift */, A11A0019C0DE5AFE10000019 /* GoalEditValidationTests.swift */, A11A001DC0DE5AFE1000001D /* GoalPaceTests.swift */, + A11A0021C0DE5AFE10000021 /* WarmTypeTests.swift */, 62FA742D1E24F010F3273E30 /* AchievementEngineTests.swift */, 1DC258182CC07DCF0002DDE3 /* SavelyTests.swift */, ); @@ -725,6 +731,7 @@ 1DC013FB2CDB575F009A46E1 /* IncomesTrackerView.swift in Sources */, 1D3669872CC62F8200E84FF9 /* Strings.swift in Sources */, 1DF7228E2F9C182300320B38 /* Color+Warm.swift in Sources */, + A11A0020C0DE5AFE10000020 /* Font+Warm.swift in Sources */, 1DE9FE0E2D063AF20069AA64 /* NotificationSettingsStepView.swift in Sources */, 1D88B7CF2CC6EB62009B6538 /* CameraManagerExtension.swift in Sources */, 1DAF5D132CEDB421001C0AF3 /* ExpensesTrackerViewModel.swift in Sources */, @@ -763,6 +770,7 @@ A11A0014C0DE5AFE10000014 /* GoalDepositsTests.swift in Sources */, A11A001AC0DE5AFE1000001A /* GoalEditValidationTests.swift in Sources */, A11A001EC0DE5AFE1000001E /* GoalPaceTests.swift in Sources */, + A11A0022C0DE5AFE10000022 /* WarmTypeTests.swift in Sources */, 510740529955B0B1E6EA730D /* AchievementEngineTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Savely/Resources/Font+Warm.swift b/Savely/Resources/Font+Warm.swift new file mode 100644 index 0000000..00d75c7 --- /dev/null +++ b/Savely/Resources/Font+Warm.swift @@ -0,0 +1,118 @@ +// +// Font+Warm.swift +// Savely +// +// Dynamic Type for a design specified in fixed point sizes. `.warmFont(14)` +// renders at exactly 14pt at the default content size — so nothing in the +// app changes for a user who never touched the setting — and follows the +// user's reading size from there, the way `.body` / `.title` do. +// +// Why a modifier and not `Font.warm(...)`: the modifier reads the +// size category from the environment, so the view re-renders when the +// user changes it. A `Font` value computed once would bake the old size. +// + +import SwiftUI +import UIKit + +extension View { + /// System font at `size` points (default content size), scaled with + /// Dynamic Type relative to `style`. When `style` is nil it is chosen + /// from the size so headings scale like headings and captions like + /// captions (large styles grow less at accessibility sizes than body). + func warmFont( + _ size: CGFloat, + weight: Font.Weight = .regular, + design: Font.Design = .default, + relativeTo style: Font.TextStyle? = nil + ) -> some View { + modifier(WarmFontModifier(size: size, weight: weight, design: design, style: style ?? WarmType.style(for: size))) + } +} + +/// The scaling policy, kept separate from the modifier so it can be tested. +enum WarmType { + /// The text style whose Dynamic Type curve a fixed size should follow. + static func style(for size: CGFloat) -> Font.TextStyle { + switch size { + case 30...: return .largeTitle + case 24..<30: return .title + case 20..<24: return .title2 + case 17..<20: return .title3 + case 15..<17: return .body + case 13..<15: return .subheadline + case 12..<13: return .footnote + default: return .caption + } + } + + /// The point size to render `size` at for a given content size category. + static func scaledSize(_ size: CGFloat, relativeTo style: Font.TextStyle, sizeCategory: ContentSizeCategory) -> CGFloat { + let traits = UITraitCollection(preferredContentSizeCategory: UIContentSizeCategory(sizeCategory)) + return UIFontMetrics(forTextStyle: style.uiTextStyle).scaledValue(for: size, compatibleWith: traits) + } +} + +private struct WarmFontModifier: ViewModifier { + @Environment(\.sizeCategory) private var sizeCategory + let size: CGFloat + let weight: Font.Weight + let design: Font.Design + let style: Font.TextStyle + + func body(content: Content) -> some View { + content.font(.system( + size: WarmType.scaledSize(size, relativeTo: style, sizeCategory: sizeCategory), + weight: weight, + design: design + )) + } +} + +extension View { + /// Enlarges the hit area of a small control to the 44×44 pt minimum + /// without changing how it looks. Layout may grow by a few points when + /// the visual is smaller than 44 — that is the point. + func tappable44() -> some View { + frame(minWidth: 44, minHeight: 44).contentShape(Rectangle()) + } +} + +extension Font.TextStyle { + var uiTextStyle: UIFont.TextStyle { + switch self { + case .largeTitle: return .largeTitle + case .title: return .title1 + case .title2: return .title2 + case .title3: return .title3 + case .headline: return .headline + case .subheadline: return .subheadline + case .body: return .body + case .callout: return .callout + case .footnote: return .footnote + case .caption: return .caption1 + case .caption2: return .caption2 + @unknown default: return .body + } + } +} + +extension UIContentSizeCategory { + init(_ category: ContentSizeCategory) { + switch category { + case .extraSmall: self = .extraSmall + case .small: self = .small + case .medium: self = .medium + case .large: self = .large + case .extraLarge: self = .extraLarge + case .extraExtraLarge: self = .extraExtraLarge + case .extraExtraExtraLarge: self = .extraExtraExtraLarge + case .accessibilityMedium: self = .accessibilityMedium + case .accessibilityLarge: self = .accessibilityLarge + case .accessibilityExtraLarge: self = .accessibilityExtraLarge + case .accessibilityExtraExtraLarge: self = .accessibilityExtraExtraLarge + case .accessibilityExtraExtraExtraLarge: self = .accessibilityExtraExtraExtraLarge + @unknown default: self = .large + } + } +} diff --git a/SavelyTests/WarmTypeTests.swift b/SavelyTests/WarmTypeTests.swift new file mode 100644 index 0000000..a548aff --- /dev/null +++ b/SavelyTests/WarmTypeTests.swift @@ -0,0 +1,47 @@ +// +// WarmTypeTests.swift +// SavelyTests +// + +import XCTest +import SwiftUI +@testable import Savely + +final class WarmTypeTests: XCTestCase { + + /// The whole point: at the default content size, every design size renders + /// at exactly the point size the design specified. Nothing moves for a + /// user who never touched Dynamic Type. + func testDefaultContentSizeIsIdentity() { + for size: CGFloat in [10, 11, 12, 13, 14, 15, 18, 22, 26, 30, 34, 42, 64, 72] { + let scaled = WarmType.scaledSize(size, relativeTo: WarmType.style(for: size), sizeCategory: .large) + XCTAssertEqual(scaled, size, accuracy: 0.01, "size \(size) changed at the default category") + } + } + + func testAccessibilitySizesScaleUpAndSmallSizesScaleDown() { + let big = WarmType.scaledSize(14, relativeTo: .subheadline, sizeCategory: .accessibilityExtraExtraExtraLarge) + let small = WarmType.scaledSize(14, relativeTo: .subheadline, sizeCategory: .extraSmall) + XCTAssertGreaterThan(big, 14) + XCTAssertLessThan(small, 14) + } + + func testHeadingsGrowLessThanBodyAtAccessibilitySizes() { + // Large styles follow a gentler curve — a 34pt title must not triple. + let title = WarmType.scaledSize(34, relativeTo: .largeTitle, sizeCategory: .accessibilityExtraExtraExtraLarge) / 34 + let body = WarmType.scaledSize(15, relativeTo: .body, sizeCategory: .accessibilityExtraExtraExtraLarge) / 15 + XCTAssertLessThan(title, body) + } + + func testStyleMappingIsMonotonic() { + XCTAssertEqual(WarmType.style(for: 11), .caption) + XCTAssertEqual(WarmType.style(for: 12), .footnote) + XCTAssertEqual(WarmType.style(for: 14), .subheadline) + XCTAssertEqual(WarmType.style(for: 15), .body) + XCTAssertEqual(WarmType.style(for: 18), .title3) + XCTAssertEqual(WarmType.style(for: 22), .title2) + XCTAssertEqual(WarmType.style(for: 26), .title) + XCTAssertEqual(WarmType.style(for: 34), .largeTitle) + XCTAssertEqual(WarmType.style(for: 72), .largeTitle) + } +} From c970f1bb1aa8d79fe3637cd28b7f26b8cd56fdac Mon Sep 17 00:00:00 2001 From: Belli Date: Sat, 15 Aug 2026 23:54:47 -0700 Subject: [PATCH 3/5] feat(a11y): every fixed font size becomes Dynamic Type through warmFont MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mechanical half of the pass: 285 `.font(.system(size:…))` sites across the app become `.warmFont(…)` with the same size, weight and design — pixel-identical at the default content size, scaling from there. The two Text concatenations in AddGoalFlow (which need a Font value, not a modifier) scale through @ScaledMetric instead. Fixed widths that would clip at accessibility sizes now scale with the text: the inline amount fields on the Money tab and the two number fields in the goal edit sheet. --- Savely/Views/DashboardTab/DashboardView.swift | 84 +++++------ Savely/Views/DashboardTab/ReportsView.swift | 12 +- .../ExpensesTab/ExpenseTrackerView.swift | 37 ++--- Savely/Views/GoalsTab/AddGoalFlow.swift | 139 +++++++++--------- Savely/Views/GoalsTab/GoalDetailView.swift | 52 +++---- Savely/Views/GoalsTab/GoalEditSheet.swift | 18 ++- Savely/Views/GoalsTab/GoalsView.swift | 50 +++---- .../Views/IncomesTab/IncomesTrackerView.swift | 35 ++--- Savely/Views/MainNavigationView.swift | 80 +++++----- .../NotificationSettingsStepView.swift | 6 +- .../Views/Onboarding/OnboardingStepView.swift | 6 +- Savely/Views/Onboarding/OnboardingView.swift | 2 +- Savely/Views/Onboarding/WelcomeStepView.swift | 4 +- .../Views/ProfileTab/AchievementsView.swift | 14 +- Savely/Views/ProfileTab/ProfileView.swift | 54 +++---- Savely/Views/SplashScreenView.swift | 4 +- 16 files changed, 302 insertions(+), 295 deletions(-) diff --git a/Savely/Views/DashboardTab/DashboardView.swift b/Savely/Views/DashboardTab/DashboardView.swift index 3feb7de..c6d06b7 100755 --- a/Savely/Views/DashboardTab/DashboardView.swift +++ b/Savely/Views/DashboardTab/DashboardView.swift @@ -56,10 +56,10 @@ struct DashboardView: View { // person to represent) VStack(alignment: .leading, spacing: 2) { Text(formattedDate) - .font(.system(size: 13)) + .warmFont(13) .foregroundStyle(Color.warmInkMuted) Text(greeting + ".") - .font(.system(size: 30, weight: .regular, design: .serif)) + .warmFont(30, weight: .regular, design: .serif) .foregroundStyle(Color.warmInk) } .frame(maxWidth: .infinity, alignment: .leading) @@ -87,12 +87,12 @@ struct DashboardView: View { VStack(spacing: 0) { HStack { Text("Recent") - .font(.system(size: 20, weight: .regular, design: .serif)) + .warmFont(20, weight: .regular, design: .serif) .foregroundStyle(Color.warmInk) Spacer() Button(action: onSeeAll) { Text("See all") - .font(.system(size: 13, weight: .medium)) + .warmFont(13, weight: .medium) .foregroundStyle(Color.warmGreen) } .buttonStyle(.plain) @@ -102,10 +102,10 @@ struct DashboardView: View { if recentTransactions.isEmpty { VStack(spacing: 8) { Image(systemName: "tray") - .font(.system(size: 36)) + .warmFont(36) .foregroundStyle(Color.warmInkMuted) Text("No transactions yet") - .font(.system(size: 14)) + .warmFont(14) .foregroundStyle(Color.warmInkMuted) } .frame(maxWidth: .infinity) @@ -182,10 +182,10 @@ struct HeroGoalCard: View { HStack { HStack(spacing: 6) { Image(systemName: "star.fill") - .font(.system(size: 12)) + .warmFont(12) .foregroundStyle(Color.warmAmber) Text("FAVORITE GOAL") - .font(.system(size: 11, weight: .semibold)) + .warmFont(11, weight: .semibold) .foregroundStyle(Color.warmInkMuted) .tracking(0.8) } @@ -193,7 +193,7 @@ struct HeroGoalCard: View { } Text(goal.name) - .font(.system(size: 26, weight: .regular, design: .serif)) + .warmFont(26, weight: .regular, design: .serif) .foregroundStyle(Color.warmInk) // Ring + stats @@ -208,10 +208,10 @@ struct HeroGoalCard: View { VStack(spacing: 0) { HStack(alignment: .lastTextBaseline, spacing: 2) { Text("\(Int(goal.progress * 100))") - .font(.system(size: 36, weight: .regular, design: .serif)) + .warmFont(36, weight: .regular, design: .serif) .foregroundStyle(Color.warmInk) Text("%") - .font(.system(size: 18)) + .warmFont(18) .foregroundStyle(Color.warmInkMuted) } } @@ -221,24 +221,24 @@ struct HeroGoalCard: View { VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 2) { Text("SAVED") - .font(.system(size: 11, weight: .semibold)) + .warmFont(11, weight: .semibold) .foregroundStyle(Color.warmInkMuted) .tracking(0.8) Text(formattedAmount(goal.current)) - .font(.system(size: 24, weight: .regular, design: .serif)) + .warmFont(24, weight: .regular, design: .serif) .foregroundStyle(Color.warmInk) Text("of \(formattedAmount(goal.target))") - .font(.system(size: 12)) + .warmFont(12) .foregroundStyle(Color.warmInkMuted) } VStack(alignment: .leading, spacing: 2) { Text("PACE") - .font(.system(size: 11, weight: .semibold)) + .warmFont(11, weight: .semibold) .foregroundStyle(Color.warmInkMuted) .tracking(0.8) Text(pace.label) - .font(.system(size: 14, weight: .semibold)) + .warmFont(14, weight: .semibold) .foregroundStyle(pace.status == .behind ? Color.warmClay : Color.warmGreen) } } @@ -249,9 +249,9 @@ struct HeroGoalCard: View { Button(action: { showDepositSheet = true }) { HStack(spacing: 6) { Image(systemName: "plus") - .font(.system(size: 14, weight: .semibold)) + .warmFont(14, weight: .semibold) Text("Add deposit") - .font(.system(size: 15, weight: .semibold)) + .warmFont(15, weight: .semibold) } .foregroundStyle(Color.warmOnGreen) .frame(maxWidth: .infinity) @@ -262,7 +262,7 @@ struct HeroGoalCard: View { Button(action: { showEditSheet = true }) { Image(systemName: "pencil") - .font(.system(size: 16)) + .warmFont(16) .foregroundStyle(Color.warmInk) .frame(width: 48, height: 48) .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.warmLine, lineWidth: 1)) @@ -312,19 +312,19 @@ struct DepositSheet: View { ScrollView { VStack(alignment: .leading, spacing: 20) { Text("Add a deposit") - .font(.system(size: 22, weight: .regular, design: .serif)) + .warmFont(22, weight: .regular, design: .serif) .foregroundStyle(Color.warmInk) Text("Move money toward \(goal.name.components(separatedBy: ",").first ?? goal.name).") - .font(.system(size: 13)) + .warmFont(13) .foregroundStyle(Color.warmInkMuted) // Large amount HStack(alignment: .lastTextBaseline, spacing: 4) { Text("$") - .font(.system(size: 48, weight: .regular, design: .serif)) + .warmFont(48, weight: .regular, design: .serif) .foregroundStyle(Color.warmInkMuted) TextField("0", text: $amountText) - .font(.system(size: 64, weight: .regular, design: .serif)) + .warmFont(64, weight: .regular, design: .serif) .foregroundStyle(Color.warmInk) .keyboardType(.decimalPad) .frame(maxWidth: .infinity) @@ -337,7 +337,7 @@ struct DepositSheet: View { let isSelected = amountText == String(Int(amt)) Button(action: { amountText = String(Int(amt)) }) { Text("$\(Int(amt))") - .font(.system(size: 13, weight: .semibold)) + .warmFont(13, weight: .semibold) .foregroundStyle(isSelected ? Color.warmGreenDeep : Color.warmInkMuted) .padding(.horizontal, 14) .padding(.vertical, 8) @@ -351,11 +351,11 @@ struct DepositSheet: View { // Note VStack(alignment: .leading, spacing: 6) { Text("NOTE") - .font(.system(size: 11, weight: .semibold)) + .warmFont(11, weight: .semibold) .foregroundStyle(Color.warmInkMuted) .tracking(0.8) TextField("Optional note", text: $note) - .font(.system(size: 14)) + .warmFont(14) .padding(14) .frame(height: 44) .background(Color.warmBg) @@ -365,7 +365,7 @@ struct DepositSheet: View { Button(action: saveDeposit) { Text("Add \(amountText.isEmpty ? "" : "$\(amountText)") to \(goal.name.components(separatedBy: ",").first ?? goal.name)") - .font(.system(size: 15, weight: .semibold)) + .warmFont(15, weight: .semibold) .foregroundStyle(Color.warmOnGreen) .frame(maxWidth: .infinity) .frame(height: 50) @@ -407,13 +407,13 @@ struct EmptyGoalCard: View { var body: some View { VStack(spacing: 14) { Image(systemName: "target") - .font(.system(size: 40)) + .warmFont(40) .foregroundStyle(Color.warmInkMuted) Text("No active goal") - .font(.system(size: 17, weight: .semibold)) + .warmFont(17, weight: .semibold) .foregroundStyle(Color.warmInkSoft) Text("Star a goal to see it here") - .font(.system(size: 14)) + .warmFont(14) .foregroundStyle(Color.warmInkMuted) } .frame(maxWidth: .infinity) @@ -433,23 +433,23 @@ struct TipOfTheDayCard: View { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 6) { Image(systemName: "sparkles") - .font(.system(size: 12, weight: .semibold)) + .warmFont(12, weight: .semibold) .foregroundStyle(Color.warmGreen) Text("TIP OF THE DAY") - .font(.system(size: 11, weight: .bold)) + .warmFont(11, weight: .bold) .foregroundStyle(Color.warmGreen) .tracking(1) } if let tip = viewModel.currentTip { Text(tip.content) - .font(.system(size: 17, weight: .regular, design: .serif)) + .warmFont(17, weight: .regular, design: .serif) .foregroundStyle(Color.warmInk) .lineSpacing(3) .fixedSize(horizontal: false, vertical: true) } else { Text("No tip for today yet — check back soon.") - .font(.system(size: 17, weight: .regular, design: .serif)) + .warmFont(17, weight: .regular, design: .serif) .foregroundStyle(Color.warmInkSoft) } } @@ -472,18 +472,18 @@ struct MonthlySummaryCell: View { var body: some View { VStack(alignment: .leading, spacing: 6) { Text(label) - .font(.system(size: 11, weight: .semibold)) + .warmFont(11, weight: .semibold) .foregroundStyle(Color.warmInkMuted) .tracking(0.8) Text(formattedAmount(value)) - .font(.system(size: 20, weight: .regular, design: .serif)) + .warmFont(20, weight: .regular, design: .serif) .foregroundStyle(Color.warmInk) if let change = change { HStack(spacing: 4) { Image(systemName: positive ? "arrow.up" : "arrow.down") - .font(.system(size: 9, weight: .semibold)) + .warmFont(9, weight: .semibold) Text(change) - .font(.system(size: 11, weight: .semibold)) + .warmFont(11, weight: .semibold) } .foregroundStyle(positive ? Color.warmGreen : Color.warmClay) } @@ -516,23 +516,23 @@ struct RecentTransactionRow: View { .fill(iconBg) .frame(width: 34, height: 34) Image(systemName: txn.isExpense ? "arrow.down" : "arrow.up") - .font(.system(size: 14, weight: .medium)) + .warmFont(14, weight: .medium) .foregroundStyle(iconColor) } VStack(alignment: .leading, spacing: 2) { Text(txn.description) - .font(.system(size: 14, weight: .semibold)) + .warmFont(14, weight: .semibold) .foregroundStyle(Color.warmInk) Text(shortDate(txn.date)) - .font(.system(size: 12)) + .warmFont(12) .foregroundStyle(Color.warmInkMuted) } Spacer() Text(formattedAmount) - .font(.system(size: 14, weight: .semibold)) + .warmFont(14, weight: .semibold) .foregroundStyle(txn.isExpense ? Color.warmInk : Color.warmGreen) } .padding(.horizontal, 14) diff --git a/Savely/Views/DashboardTab/ReportsView.swift b/Savely/Views/DashboardTab/ReportsView.swift index a2a6571..e30ab9e 100755 --- a/Savely/Views/DashboardTab/ReportsView.swift +++ b/Savely/Views/DashboardTab/ReportsView.swift @@ -57,10 +57,10 @@ struct ReportsView: View { // — Header — VStack(alignment: .leading, spacing: 2) { Text("Reports") - .font(.system(size: 34, weight: .regular, design: .serif)) + .warmFont(34, weight: .regular, design: .serif) .foregroundStyle(Color.warmInk) Text(rangeSubtitle) - .font(.system(size: 13)) + .warmFont(13) .foregroundStyle(Color.warmInkMuted) } .frame(maxWidth: .infinity, alignment: .leading) @@ -116,9 +116,9 @@ struct ReportsView: View { if !viewModel.isLoading && incomeBySource.isEmpty && expensesByCategory.isEmpty { VStack(spacing: 10) { - Image(systemName: "chart.bar").font(.system(size: 36)).foregroundStyle(Color.warmInkMuted) + Image(systemName: "chart.bar").warmFont(36).foregroundStyle(Color.warmInkMuted) Text(Strings.ReportsView.noDataLabel) - .font(.system(size: 14)) + .warmFont(14) .foregroundStyle(Color.warmInkSoft) .multilineTextAlignment(.center) } @@ -146,7 +146,7 @@ struct ReportsView: View { private func dateRow(_ title: String, selection: Binding) -> some View { HStack(spacing: 16) { Text(title) - .font(.system(size: 14, weight: .medium)) + .warmFont(14, weight: .medium) .foregroundStyle(Color.warmInk) Spacer() DatePicker(title, selection: selection, displayedComponents: .date) @@ -159,7 +159,7 @@ struct ReportsView: View { private func chartCard(title: String, @ViewBuilder content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 12) { Text(title) - .font(.system(size: 18, weight: .regular, design: .serif)) + .warmFont(18, weight: .regular, design: .serif) .foregroundStyle(Color.warmInk) content() } diff --git a/Savely/Views/ExpensesTab/ExpenseTrackerView.swift b/Savely/Views/ExpensesTab/ExpenseTrackerView.swift index e3bd53a..1430bd7 100755 --- a/Savely/Views/ExpensesTab/ExpenseTrackerView.swift +++ b/Savely/Views/ExpensesTab/ExpenseTrackerView.swift @@ -6,6 +6,7 @@ struct ExpenseTrackerView: View { @StateObject private var viewModel = ExpenseTrackerViewModel() @State private var showCameraView = false @FocusState private var focusedField: Field? + @ScaledMetric(relativeTo: .subheadline) private var amountFieldWidth: CGFloat = 70 enum Field: Hashable { case description, amount } @@ -43,10 +44,10 @@ struct ExpenseTrackerView: View { HStack { VStack(alignment: .leading, spacing: 2) { Text("Expenses") - .font(.system(size: 34, weight: .regular, design: .serif)) + .warmFont(34, weight: .regular, design: .serif) .foregroundStyle(Color.warmInk) Text("\(currentMonthName) · \(formattedTotal)") - .font(.system(size: 13)) + .warmFont(13) .foregroundStyle(Color.warmInkMuted) } Spacer() @@ -59,18 +60,18 @@ struct ExpenseTrackerView: View { RoundedRectangle(cornerRadius: 12) .fill(Color.warmOnInk.opacity(0.1)) .frame(width: 44, height: 44) - .overlay(Image(systemName: "camera.fill").font(.system(size: 18)).foregroundStyle(Color.warmOnInk)) + .overlay(Image(systemName: "camera.fill").warmFont(18).foregroundStyle(Color.warmOnInk)) VStack(alignment: .leading, spacing: 2) { Text("Scan a receipt") - .font(.system(size: 15, weight: .semibold)) + .warmFont(15, weight: .semibold) .foregroundStyle(Color.warmOnInk) Text("We'll read the total and category") - .font(.system(size: 12)) + .warmFont(12) .foregroundStyle(Color.warmOnInk.opacity(0.65)) } Spacer() Image(systemName: "chevron.right") - .font(.system(size: 14)) + .warmFont(14) .foregroundStyle(Color.warmOnInk.opacity(0.6)) } .padding(16) @@ -85,7 +86,7 @@ struct ExpenseTrackerView: View { // — Inline add — HStack(spacing: 10) { TextField("What did you buy?", text: $viewModel.expenseDescription) - .font(.system(size: 14)) + .warmFont(14) .foregroundStyle(Color.warmInk) .padding(.horizontal, 12) .frame(maxWidth: .infinity) @@ -99,10 +100,10 @@ struct ExpenseTrackerView: View { Text("$").foregroundStyle(Color.warmInkMuted).padding(.leading, 8) TextField("0.00", text: $viewModel.amount) .keyboardType(.decimalPad) - .font(.system(size: 14)) + .warmFont(14) .monospacedDigit() .padding(.vertical, 0) - .frame(width: 70) + .frame(width: amountFieldWidth) .focused($focusedField, equals: .amount) } .frame(height: 40) @@ -112,7 +113,7 @@ struct ExpenseTrackerView: View { Button(action: { viewModel.addExpense(); focusedField = nil }) { Image(systemName: "plus") - .font(.system(size: 15, weight: .semibold)) + .warmFont(15, weight: .semibold) .foregroundStyle(Color.warmOnGreen) .frame(width: 40, height: 40) .background(Color.warmGreenFill) @@ -127,9 +128,9 @@ struct ExpenseTrackerView: View { // — Grouped list — if viewModel.expenses.isEmpty { VStack(spacing: 10) { - Image(systemName: "tray").font(.system(size: 40)).foregroundStyle(Color.warmInkMuted) - Text("No expenses yet").font(.system(size: 16, weight: .semibold)).foregroundStyle(Color.warmInkSoft) - Text("Add your first expense above").font(.system(size: 13)).foregroundStyle(Color.warmInkMuted) + Image(systemName: "tray").warmFont(40).foregroundStyle(Color.warmInkMuted) + Text("No expenses yet").warmFont(16, weight: .semibold).foregroundStyle(Color.warmInkSoft) + Text("Add your first expense above").warmFont(13).foregroundStyle(Color.warmInkMuted) } .frame(maxWidth: .infinity).padding(.vertical, 44) } else { @@ -137,7 +138,7 @@ struct ExpenseTrackerView: View { ForEach(groupedExpenses, id: \.0) { group in VStack(alignment: .leading, spacing: 8) { Text(group.0) - .font(.system(size: 11, weight: .semibold)) + .warmFont(11, weight: .semibold) .foregroundStyle(Color.warmInkMuted) .tracking(1) .textCase(.uppercase) @@ -203,19 +204,19 @@ struct ExpenseRowWarm: View { RoundedRectangle(cornerRadius: 10) .fill(iconBg) .frame(width: 36, height: 36) - .overlay(Image(systemName: expenseIcon).font(.system(size: 15)).foregroundStyle(iconColor)) + .overlay(Image(systemName: expenseIcon).warmFont(15).foregroundStyle(iconColor)) VStack(alignment: .leading, spacing: 2) { Text(expense.expenseDescription) - .font(.system(size: 14, weight: .semibold)) + .warmFont(14, weight: .semibold) .foregroundStyle(Color.warmInk) Text(categoryLabel) - .font(.system(size: 12)) + .warmFont(12) .foregroundStyle(Color.warmInkMuted) } Spacer() Text("−\(formattedAmount(expense.amount))") - .font(.system(size: 14, weight: .semibold)) + .warmFont(14, weight: .semibold) .foregroundStyle(Color.warmInk) .monospacedDigit() } diff --git a/Savely/Views/GoalsTab/AddGoalFlow.swift b/Savely/Views/GoalsTab/AddGoalFlow.swift index 4b1acdd..020193e 100644 --- a/Savely/Views/GoalsTab/AddGoalFlow.swift +++ b/Savely/Views/GoalsTab/AddGoalFlow.swift @@ -108,13 +108,15 @@ struct AddGoalHeader: View { let onBack: () -> Void; let onSkip: () -> Void var showClose: Bool = false var showSkip: Bool = true + // Concatenated Text needs a Font value, so scale it here instead of via warmFont. + @ScaledMetric(relativeTo: .subheadline) private var headerSize: CGFloat = 13 var body: some View { VStack(spacing: 0) { HStack(alignment: .center) { Button(action: onBack) { Image(systemName: showClose ? "xmark" : "chevron.left") - .font(.system(size: 14, weight: .medium)) + .warmFont(14, weight: .medium) .foregroundStyle(Color.warmInk) .frame(width: 36, height: 36) .background(Color.warmSurface) @@ -123,15 +125,15 @@ struct AddGoalHeader: View { } Spacer() Text("New goal · ") - .font(.system(size: 13)) + .font(.system(size: headerSize)) .foregroundStyle(Color.warmInkMuted) - + Text("\(step)").font(.system(size: 13, weight: .semibold)).foregroundColor(Color.warmInk) - + Text(" of \(total)").font(.system(size: 13)).foregroundColor(Color.warmInkMuted) + + Text("\(step)").font(.system(size: headerSize, weight: .semibold)).foregroundColor(Color.warmInk) + + Text(" of \(total)").font(.system(size: headerSize)).foregroundColor(Color.warmInkMuted) Spacer() if showSkip { Button(action: onSkip) { Text("Skip") - .font(.system(size: 13)) + .warmFont(13) .foregroundStyle(Color.warmInkMuted) .frame(width: 36, height: 36) } @@ -172,12 +174,12 @@ struct AddGoalStep1View: View { // Section label + headline VStack(alignment: .leading, spacing: 10) { Text("WHAT YOU'RE SAVING FOR") - .font(.system(size: 11, weight: .bold)).foregroundStyle(Color.warmGreen).tracking(1.2) + .warmFont(11, weight: .bold).foregroundStyle(Color.warmGreen).tracking(1.2) Text("Give it a name\nyou'll recognize.") - .font(.system(size: 32, weight: .regular, design: .serif)).foregroundStyle(Color.warmInk) + .warmFont(32, weight: .regular, design: .serif).foregroundStyle(Color.warmInk) .lineSpacing(2) Text("This is what you'll see on your dashboard.") - .font(.system(size: 14)).foregroundStyle(Color.warmInkSoft) + .warmFont(14).foregroundStyle(Color.warmInkSoft) } .padding(.horizontal, 24).padding(.bottom, 24) @@ -188,10 +190,10 @@ struct AddGoalStep1View: View { GoalInitialCircle(name: state.name, color: state.color, size: 44) VStack(alignment: .leading, spacing: 2) { Text(state.name.isEmpty ? "Your goal name" : state.name) - .font(.system(size: 20, weight: .regular, design: .serif)) + .warmFont(20, weight: .regular, design: .serif) .foregroundStyle(state.name.isEmpty ? Color.warmInkMuted : Color.warmInk) Text("Live preview") - .font(.system(size: 11)).foregroundStyle(Color.warmInkMuted) + .warmFont(11).foregroundStyle(Color.warmInkMuted) } } .padding(.vertical, 12).padding(.horizontal, 18) @@ -205,15 +207,15 @@ struct AddGoalStep1View: View { // Name input VStack(alignment: .leading, spacing: 8) { Text("NAME") - .font(.system(size: 11, weight: .semibold)).foregroundStyle(Color.warmInkMuted).tracking(0.8) + .warmFont(11, weight: .semibold).foregroundStyle(Color.warmInkMuted).tracking(0.8) HStack { TextField("e.g. Kyoto, autumn '26", text: $state.name) - .font(.system(size: 16)).foregroundStyle(Color.warmInk) + .warmFont(16).foregroundStyle(Color.warmInk) .focused($nameFocused) if !state.name.isEmpty { Button(action: { state.name = "" }) { Image(systemName: "xmark.circle.fill") - .font(.system(size: 16)).foregroundStyle(Color.warmInkMuted) + .warmFont(16).foregroundStyle(Color.warmInkMuted) } } } @@ -225,14 +227,14 @@ struct AddGoalStep1View: View { .stroke(nameFocused ? Color.warmGreen : Color.warmLine, lineWidth: nameFocused ? 1.5 : 1) ) Text("Tip: be specific. Try a specific name like Kyoto trip '26.") - .font(.system(size: 12)).foregroundStyle(Color.warmInkMuted).padding(.leading, 4) + .warmFont(12).foregroundStyle(Color.warmInkMuted).padding(.leading, 4) } .padding(.horizontal, 20).padding(.bottom, 24) // Color picker (6-swatch row from design) VStack(alignment: .leading, spacing: 10) { Text("COLOR") - .font(.system(size: 11, weight: .semibold)).foregroundStyle(Color.warmInkMuted).tracking(0.8) + .warmFont(11, weight: .semibold).foregroundStyle(Color.warmInkMuted).tracking(0.8) HStack(spacing: 12) { ForEach([GoalColor.green, .blue, .yellow, .red, .purple, .brown], id: \.id) { gc in let isOn = state.color == gc @@ -253,7 +255,7 @@ struct AddGoalStep1View: View { Button(action: onNext) { Text("Continue") - .font(.system(size: 15, weight: .semibold)).foregroundStyle(Color.warmOnGreen) + .warmFont(15, weight: .semibold).foregroundStyle(Color.warmOnGreen) .frame(maxWidth: .infinity).frame(height: 50) .background(state.name.isEmpty ? Color.warmInkMuted : Color.warmGreenFill) .cornerRadius(14) @@ -272,6 +274,7 @@ struct AddGoalStep1View: View { struct AddGoalStep2View: View { @ObservedObject var state: AddGoalState let onBack: () -> Void; let onNext: () -> Void; let onSkip: () -> Void + @ScaledMetric(relativeTo: .footnote) private var noteSize: CGFloat = 12 private let presets: [Double] = [1000, 2500, 5000, 10000] var body: some View { @@ -280,9 +283,9 @@ struct AddGoalStep2View: View { VStack(alignment: .leading, spacing: 10) { Text("HOW MUCH") - .font(.system(size: 11, weight: .bold)).foregroundStyle(Color.warmGreen).tracking(1.2) + .warmFont(11, weight: .bold).foregroundStyle(Color.warmGreen).tracking(1.2) Text("What's the\nfinish line?") - .font(.system(size: 32, weight: .regular, design: .serif)).foregroundStyle(Color.warmInk) + .warmFont(32, weight: .regular, design: .serif).foregroundStyle(Color.warmInk) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 24).padding(.bottom, 8) @@ -291,7 +294,7 @@ struct AddGoalStep2View: View { HStack(spacing: 10) { GoalInitialCircle(name: state.name, color: state.color, size: 28) Text(state.name) - .font(.system(size: 13)).foregroundStyle(Color.warmInkSoft) + .warmFont(13).foregroundStyle(Color.warmInkSoft) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 24).padding(.bottom, 8) @@ -301,9 +304,9 @@ struct AddGoalStep2View: View { // Large amount display HStack(alignment: .firstTextBaseline, spacing: 2) { Text("$") - .font(.system(size: 36, weight: .regular, design: .serif)).foregroundStyle(Color.warmInkMuted) + .warmFont(36, weight: .regular, design: .serif).foregroundStyle(Color.warmInkMuted) Text(formatKeypadAmount(state.amountStr)) - .font(.system(size: 88, weight: .regular, design: .serif)).foregroundStyle(Color.warmInk) + .warmFont(88, weight: .regular, design: .serif).foregroundStyle(Color.warmInk) .monospacedDigit().minimumScaleFactor(0.35).lineLimit(1) } .padding(.horizontal, 20) @@ -312,13 +315,13 @@ struct AddGoalStep2View: View { if state.amount > 0 { HStack(spacing: 6) { Image(systemName: "sparkles") - .font(.system(size: 11)).foregroundStyle(Color.warmAmber) + .warmFont(11).foregroundStyle(Color.warmAmber) Text("About ") - .font(.system(size: 12)).foregroundStyle(Color.warmInkMuted) + .font(.system(size: noteSize)).foregroundStyle(Color.warmInkMuted) + Text("$\(Int(state.weeklyPace))/week") - .font(.system(size: 12, weight: .semibold)).foregroundColor(Color.warmInk) + .font(.system(size: noteSize, weight: .semibold)).foregroundColor(Color.warmInk) + Text(" for 18 months") - .font(.system(size: 12)).foregroundColor(Color.warmInkMuted) + .font(.system(size: noteSize)).foregroundColor(Color.warmInkMuted) } .padding(.top, 14) } @@ -329,7 +332,7 @@ struct AddGoalStep2View: View { let isOn = state.amountStr == "\(Int(p))" Button(action: { state.amountStr = "\(Int(p))" }) { Text("$\(Int(p).formatted())") - .font(.system(size: 13, weight: .semibold)) + .warmFont(13, weight: .semibold) .foregroundStyle(isOn ? Color.warmGreenDeep : Color.warmInkSoft) .padding(.horizontal, 14).padding(.vertical, 8) .background(isOn ? Color.warmGreenSoft : Color.clear) @@ -349,7 +352,7 @@ struct AddGoalStep2View: View { WarmKeypad(amountStr: $state.amountStr) Button(action: onNext) { Text("Continue") - .font(.system(size: 15, weight: .semibold)).foregroundStyle(Color.warmOnGreen) + .warmFont(15, weight: .semibold).foregroundStyle(Color.warmOnGreen) .frame(maxWidth: .infinity).frame(height: 50) .background(state.amount > 0 ? Color.warmGreenFill : Color.warmInkMuted) .cornerRadius(14) @@ -385,9 +388,9 @@ struct AddGoalStep3View: View { VStack(alignment: .leading, spacing: 10) { Text("TIMING") - .font(.system(size: 11, weight: .bold)).foregroundStyle(Color.warmGreen).tracking(1.2) + .warmFont(11, weight: .bold).foregroundStyle(Color.warmGreen).tracking(1.2) Text("When do you\nwant this by?") - .font(.system(size: 32, weight: .regular, design: .serif)).foregroundStyle(Color.warmInk) + .warmFont(32, weight: .regular, design: .serif).foregroundStyle(Color.warmInk) } .padding(.horizontal, 24).padding(.bottom, 20) @@ -395,23 +398,23 @@ struct AddGoalStep3View: View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 6) { Image(systemName: "sparkles") - .font(.system(size: 11, weight: .bold)).foregroundStyle(Color.warmOnGreen.opacity(0.85)) + .warmFont(11, weight: .bold).foregroundStyle(Color.warmOnGreen.opacity(0.85)) Text("SUGGESTED PACE") - .font(.system(size: 11, weight: .bold)).foregroundStyle(Color.warmOnGreen.opacity(0.85)).tracking(1.2) + .warmFont(11, weight: .bold).foregroundStyle(Color.warmOnGreen.opacity(0.85)).tracking(1.2) } HStack(alignment: .firstTextBaseline) { Text(state.hasDeadline ? "$\(Int(state.weeklyPace))" : "—") - .font(.system(size: 38, weight: .regular, design: .serif)).foregroundStyle(Color.warmOnGreen) + .warmFont(38, weight: .regular, design: .serif).foregroundStyle(Color.warmOnGreen) Text("/wk") - .font(.system(size: 18)).foregroundStyle(Color.warmOnGreen.opacity(0.7)).padding(.leading, 2) + .warmFont(18).foregroundStyle(Color.warmOnGreen.opacity(0.7)).padding(.leading, 2) Spacer() Text(state.hasDeadline ? "~$\(Int(state.monthlyPace))/mo" : "no date") - .font(.system(size: 13)).foregroundStyle(Color.warmOnGreen.opacity(0.85)) + .warmFont(13).foregroundStyle(Color.warmOnGreen.opacity(0.85)) } Text(state.hasDeadline ? "To hit **$\(Int(state.amount))** by **\(formattedDeadline)**." : "No target date — save at your own pace.") - .font(.system(size: 13)).foregroundStyle(Color.warmOnGreen.opacity(0.85)) + .warmFont(13).foregroundStyle(Color.warmOnGreen.opacity(0.85)) } .padding(18) .background(Color.warmGreenFill) @@ -441,7 +444,7 @@ struct AddGoalStep3View: View { } }) { Text(preset.label) - .font(.system(size: 13, weight: .semibold)) + .warmFont(13, weight: .semibold) .foregroundStyle(isOn ? Color.warmGreenDeep : Color.warmInkSoft) .padding(.horizontal, 14).padding(.vertical, 8) .background(isOn ? Color.warmGreenSoft : Color.clear) @@ -459,15 +462,15 @@ struct AddGoalStep3View: View { // Auto-deposit toggle HStack(spacing: 12) { Image(systemName: "sparkles") - .font(.system(size: 15)).foregroundStyle(Color.warmGreen) + .warmFont(15).foregroundStyle(Color.warmGreen) .frame(width: 36, height: 36).background(Color.warmGreenSoft).cornerRadius(10) VStack(alignment: .leading, spacing: 2) { Text("Auto-move on payday") - .font(.system(size: 14, weight: .semibold)).foregroundStyle(Color.warmInk) + .warmFont(14, weight: .semibold).foregroundStyle(Color.warmInk) Text(state.hasDeadline ? "Set $\(Int(state.monthlyPace)) aside the day after each paycheck." : "Pick the amount later in the goal's settings.") - .font(.system(size: 12)).foregroundStyle(Color.warmInkMuted) + .warmFont(12).foregroundStyle(Color.warmInkMuted) } Spacer() Toggle("Auto-move on payday", isOn: $state.autoDeposit) @@ -482,7 +485,7 @@ struct AddGoalStep3View: View { Button(action: onNext) { Text("Continue") - .font(.system(size: 15, weight: .semibold)).foregroundStyle(Color.warmOnGreen) + .warmFont(15, weight: .semibold).foregroundStyle(Color.warmOnGreen) .frame(maxWidth: .infinity).frame(height: 50) .background(Color.warmGreenFill).cornerRadius(14) } @@ -510,14 +513,14 @@ struct MiniCalendarView: View { VStack(spacing: 0) { HStack { Button(action: shiftMonth(-1)) { - Image(systemName: "chevron.left").font(.system(size: 13)).foregroundStyle(Color.warmInkSoft) + Image(systemName: "chevron.left").warmFont(13).foregroundStyle(Color.warmInkSoft) .frame(width: 28, height: 28).cornerRadius(8) } Spacer() - Text(monthTitle).font(.system(size: 18, weight: .regular, design: .serif)).foregroundStyle(Color.warmInk) + Text(monthTitle).warmFont(18, weight: .regular, design: .serif).foregroundStyle(Color.warmInk) Spacer() Button(action: shiftMonth(1)) { - Image(systemName: "chevron.right").font(.system(size: 13)).foregroundStyle(Color.warmInkSoft) + Image(systemName: "chevron.right").warmFont(13).foregroundStyle(Color.warmInkSoft) .frame(width: 28, height: 28).cornerRadius(8) } } @@ -525,14 +528,14 @@ struct MiniCalendarView: View { LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 7), spacing: 4) { ForEach(dayLabels, id: \.self) { d in - Text(d).font(.system(size: 10, weight: .semibold)).foregroundStyle(Color.warmInkMuted) + Text(d).warmFont(10, weight: .semibold).foregroundStyle(Color.warmInkMuted) } ForEach(0.. some View { Text(text.uppercased()) - .font(.system(size: 11, weight: .semibold)) + .warmFont(11, weight: .semibold) .foregroundStyle(Color.warmInkMuted) .tracking(0.8) .padding(.leading, 4) @@ -199,7 +201,7 @@ struct GoalEditSheet: View { private func fieldRow(label: String, @ViewBuilder content: () -> Content) -> some View { HStack(spacing: 16) { Text(label) - .font(.system(size: 14, weight: .medium)) + .warmFont(14, weight: .medium) .foregroundStyle(Color.warmInk) Spacer(minLength: 12) content() @@ -209,7 +211,7 @@ struct GoalEditSheet: View { private func toggleRow(_ title: String, isOn: Binding) -> some View { Toggle(isOn: isOn) { - Text(title).font(.system(size: 14, weight: .medium)).foregroundStyle(Color.warmInk) + Text(title).warmFont(14, weight: .medium).foregroundStyle(Color.warmInk) } .tint(Color.warmGreen) .padding(.horizontal, 16).padding(.vertical, 12) diff --git a/Savely/Views/GoalsTab/GoalsView.swift b/Savely/Views/GoalsTab/GoalsView.swift index 543ac93..8d5a59c 100755 --- a/Savely/Views/GoalsTab/GoalsView.swift +++ b/Savely/Views/GoalsTab/GoalsView.swift @@ -45,15 +45,15 @@ struct GoalsView: View { HStack { VStack(alignment: .leading, spacing: 2) { Text("Goals") - .font(.system(size: 34, weight: .regular, design: .serif)) + .warmFont(34, weight: .regular, design: .serif) .foregroundStyle(Color.warmInk) Text("\(activeGoals.count) active · \(formattedAmount(totalSaved)) saved of \(formattedAmount(totalTarget))") - .font(.system(size: 14)).foregroundStyle(Color.warmInkMuted) + .warmFont(14).foregroundStyle(Color.warmInkMuted) } Spacer() Button(action: { showAddGoalFlow = true }) { Image(systemName: "plus") - .font(.system(size: 18, weight: .semibold)).foregroundStyle(Color.warmOnInk) + .warmFont(18, weight: .semibold).foregroundStyle(Color.warmOnInk) .frame(width: 40, height: 40).background(Color.warmInk).cornerRadius(14) } } @@ -63,11 +63,11 @@ struct GoalsView: View { VStack(alignment: .leading, spacing: 10) { HStack { Text("Progress overall") - .font(.system(size: 12, weight: .semibold)).foregroundStyle(Color.warmInkMuted) + .warmFont(12, weight: .semibold).foregroundStyle(Color.warmInkMuted) .tracking(0.6).textCase(.uppercase) Spacer() Text("\(Int(overallProgress * 100))%") - .font(.system(size: 22, weight: .regular, design: .serif)).foregroundStyle(Color.warmInk) + .warmFont(22, weight: .regular, design: .serif).foregroundStyle(Color.warmInk) } GeometryReader { geo in HStack(spacing: 0) { @@ -94,7 +94,7 @@ struct GoalsView: View { if !completedGoals.isEmpty { VStack(alignment: .leading, spacing: 10) { Text("Completed") - .font(.system(size: 11, weight: .semibold)) + .warmFont(11, weight: .semibold) .foregroundStyle(Color.warmInkMuted) .tracking(1) .textCase(.uppercase) @@ -163,14 +163,14 @@ struct GoalsEmptyStateView: View { HStack { VStack(alignment: .leading, spacing: 4) { Text("Goals") - .font(.system(size: 34, weight: .regular, design: .serif)).foregroundStyle(Color.warmInk) + .warmFont(34, weight: .regular, design: .serif).foregroundStyle(Color.warmInk) Text("Nothing here yet — let's plant the first one.") - .font(.system(size: 14)).foregroundStyle(Color.warmInkMuted) + .warmFont(14).foregroundStyle(Color.warmInkMuted) } Spacer() Button(action: onCreateGoal) { Image(systemName: "plus") - .font(.system(size: 18, weight: .semibold)).foregroundStyle(Color.warmOnInk) + .warmFont(18, weight: .semibold).foregroundStyle(Color.warmOnInk) .frame(width: 40, height: 40).background(Color.warmInk).cornerRadius(14) } } @@ -187,11 +187,11 @@ struct GoalsEmptyStateView: View { ZStack { Circle().fill(Color.warmGreenSoft).frame(width: 64, height: 64) Image(systemName: "target") - .font(.system(size: 26)).foregroundStyle(Color.warmGreen) + .warmFont(26).foregroundStyle(Color.warmGreen) } } Text("Day one") - .font(.system(size: 10, weight: .bold)).foregroundStyle(Color.warmAmber) + .warmFont(10, weight: .bold).foregroundStyle(Color.warmAmber) .tracking(0.8).textCase(.uppercase) .padding(.horizontal, 10).padding(.vertical, 4) .background(Color.warmAmberSoft).clipShape(Capsule()) @@ -202,17 +202,17 @@ struct GoalsEmptyStateView: View { VStack(spacing: 10) { Text("What are you saving for?") - .font(.system(size: 28, weight: .regular, design: .serif)).foregroundStyle(Color.warmInk) + .warmFont(28, weight: .regular, design: .serif).foregroundStyle(Color.warmInk) .multilineTextAlignment(.center) Text("A goal is a name, a number, and a date.\nSavely takes care of the rest.") - .font(.system(size: 14)).foregroundStyle(Color.warmInkSoft) + .warmFont(14).foregroundStyle(Color.warmInkSoft) .multilineTextAlignment(.center).lineSpacing(3) } Button(action: onCreateGoal) { HStack(spacing: 8) { - Image(systemName: "plus").font(.system(size: 14, weight: .semibold)) - Text("Create your first goal").font(.system(size: 15, weight: .semibold)) + Image(systemName: "plus").warmFont(14, weight: .semibold) + Text("Create your first goal").warmFont(15, weight: .semibold) } .foregroundStyle(Color.warmOnGreen) .frame(maxWidth: .infinity).frame(height: 50) @@ -224,8 +224,8 @@ struct GoalsEmptyStateView: View { ForEach([("🌿","Emergency fund"),("✈️","A trip"),("🏡","Down payment")], id: \.0) { item in Button(action: onCreateGoal) { HStack(spacing: 6) { - Text(item.0).font(.system(size: 13)) - Text(item.1).font(.system(size: 12)).foregroundStyle(Color.warmInkSoft) + Text(item.0).warmFont(13) + Text(item.1).warmFont(12).foregroundStyle(Color.warmInkSoft) } .padding(.horizontal, 12).padding(.vertical, 8) .background(Color.warmBg) @@ -278,27 +278,27 @@ struct WarmGoalCard: View { .fill(goal.color).frame(width: 44, height: 44) .overlay( Text(goal.name.prefix(1).uppercased()) - .font(.system(size: 22, weight: .regular, design: .serif)).foregroundStyle(Color.warmOnGreen) + .warmFont(22, weight: .regular, design: .serif).foregroundStyle(Color.warmOnGreen) ) VStack(alignment: .leading, spacing: 3) { HStack(spacing: 5) { Text(goal.name) - .font(.system(size: 15, weight: .semibold)).foregroundStyle(Color.warmInk).lineLimit(1) + .warmFont(15, weight: .semibold).foregroundStyle(Color.warmInk).lineLimit(1) if goal.isFavorite { - Image(systemName: "star.fill").font(.system(size: 12)).foregroundStyle(Color.warmAmber) + Image(systemName: "star.fill").warmFont(12).foregroundStyle(Color.warmAmber) } } Text(pace.label) - .font(.system(size: 12)) + .warmFont(12) .foregroundStyle(pace.status == .behind ? Color.warmClay : Color.warmInkMuted) } Spacer() VStack(alignment: .trailing, spacing: 2) { Text("\(Int(goal.progress * 100))%") - .font(.system(size: 18, weight: .regular, design: .serif)).foregroundStyle(Color.warmInk) + .warmFont(18, weight: .regular, design: .serif).foregroundStyle(Color.warmInk) Button(action: onFavoriteToggle) { Image(systemName: goal.isFavorite ? "star.fill" : "star") - .font(.system(size: 16)) + .warmFont(16) .foregroundStyle(goal.isFavorite ? Color.warmAmber : Color.warmInkMuted) } } @@ -315,10 +315,10 @@ struct WarmGoalCard: View { HStack { Text(formattedAmount(goal.current)) - .font(.system(size: 12)).foregroundStyle(Color.warmInkSoft).monospacedDigit() + .warmFont(12).foregroundStyle(Color.warmInkSoft).monospacedDigit() Spacer() Text("of \(formattedAmount(goal.target))") - .font(.system(size: 12)).foregroundStyle(Color.warmInkMuted).monospacedDigit() + .warmFont(12).foregroundStyle(Color.warmInkMuted).monospacedDigit() } } .padding(16) diff --git a/Savely/Views/IncomesTab/IncomesTrackerView.swift b/Savely/Views/IncomesTab/IncomesTrackerView.swift index 617840f..f67b9a5 100755 --- a/Savely/Views/IncomesTab/IncomesTrackerView.swift +++ b/Savely/Views/IncomesTab/IncomesTrackerView.swift @@ -5,6 +5,7 @@ struct IncomesTrackerView: View { @Environment(\.modelContext) private var modelContext @StateObject private var viewModel = IncomesTrackerViewModel() @FocusState private var focusedField: Field? + @ScaledMetric(relativeTo: .subheadline) private var amountFieldWidth: CGFloat = 70 enum Field: Hashable { case description, amount } @@ -29,10 +30,10 @@ struct IncomesTrackerView: View { // — Header — VStack(alignment: .leading, spacing: 2) { Text("Income") - .font(.system(size: 34, weight: .regular, design: .serif)) + .warmFont(34, weight: .regular, design: .serif) .foregroundStyle(Color.warmInk) Text("\(currentMonthName) · \(formattedAmount(viewModel.totalIncomeThisMonth))") - .font(.system(size: 13)) + .warmFont(13) .foregroundStyle(Color.warmInkMuted) } .frame(maxWidth: .infinity, alignment: .leading) @@ -42,7 +43,7 @@ struct IncomesTrackerView: View { VStack(alignment: .leading, spacing: 14) { HStack { Text("7-month trend") - .font(.system(size: 12, weight: .semibold)) + .warmFont(12, weight: .semibold) .foregroundStyle(Color.warmInkMuted) .tracking(0.6) .textCase(.uppercase) @@ -52,9 +53,9 @@ struct IncomesTrackerView: View { let isUp = viewModel.percentageChange >= 0 HStack(spacing: 3) { Image(systemName: isUp ? "arrow.up" : "arrow.down") - .font(.system(size: 10, weight: .semibold)) + .warmFont(10, weight: .semibold) Text(String(format: "%+.0f%%", viewModel.percentageChange)) - .font(.system(size: 13, weight: .semibold)) + .warmFont(13, weight: .semibold) } .foregroundStyle(isUp ? Color.warmGreen : Color.warmClay) } @@ -65,7 +66,7 @@ struct IncomesTrackerView: View { .fill(idx == barData.count - 1 ? Color.warmGreen : Color.warmGreenSoft) .frame(height: max(4, CGFloat(bar.1 / maxBar) * 80)) Text(bar.0) - .font(.system(size: 10)) + .warmFont(10) .foregroundStyle(Color.warmInkMuted) } .frame(maxWidth: .infinity, alignment: .bottom) @@ -81,7 +82,7 @@ struct IncomesTrackerView: View { // — Inline add — HStack(spacing: 10) { TextField("Source…", text: $viewModel.incomeDescription) - .font(.system(size: 14)) + .warmFont(14) .padding(.horizontal, 12) .frame(maxWidth: .infinity) .frame(height: 40) @@ -94,9 +95,9 @@ struct IncomesTrackerView: View { Text("$").foregroundStyle(Color.warmInkMuted).padding(.leading, 8) TextField("0.00", text: $viewModel.amount) .keyboardType(.decimalPad) - .font(.system(size: 14)) + .warmFont(14) .monospacedDigit() - .frame(width: 70) + .frame(width: amountFieldWidth) .focused($focusedField, equals: .amount) } .frame(height: 40) @@ -106,7 +107,7 @@ struct IncomesTrackerView: View { Button(action: { viewModel.addIncome(); focusedField = nil }) { Image(systemName: "plus") - .font(.system(size: 15, weight: .semibold)) + .warmFont(15, weight: .semibold) .foregroundStyle(Color.warmOnGreen) .frame(width: 40, height: 40) .background(Color.warmGreenFill) @@ -121,7 +122,7 @@ struct IncomesTrackerView: View { // — History — VStack(alignment: .leading, spacing: 8) { Text("History") - .font(.system(size: 11, weight: .semibold)) + .warmFont(11, weight: .semibold) .foregroundStyle(Color.warmInkMuted) .tracking(1) .textCase(.uppercase) @@ -129,8 +130,8 @@ struct IncomesTrackerView: View { if viewModel.incomes.isEmpty { VStack(spacing: 10) { - Image(systemName: "tray").font(.system(size: 40)).foregroundStyle(Color.warmInkMuted) - Text("No income yet").font(.system(size: 16, weight: .semibold)).foregroundStyle(Color.warmInkSoft) + Image(systemName: "tray").warmFont(40).foregroundStyle(Color.warmInkMuted) + Text("No income yet").warmFont(16, weight: .semibold).foregroundStyle(Color.warmInkSoft) } .frame(maxWidth: .infinity).padding(.vertical, 44) } else { @@ -191,21 +192,21 @@ struct IncomeRowWarm: View { .frame(width: 36, height: 36) .overlay( Image(systemName: source?.icon ?? "arrow.up") - .font(.system(size: 14, weight: .medium)) + .warmFont(14, weight: .medium) .foregroundStyle(Color.warmGreen) ) VStack(alignment: .leading, spacing: 2) { Text(income.incomeDescription) - .font(.system(size: 14, weight: .semibold)) + .warmFont(14, weight: .semibold) .foregroundStyle(Color.warmInk) Text(source.map { "\($0.label) · \(shortDate(income.date))" } ?? shortDate(income.date)) - .font(.system(size: 12)) + .warmFont(12) .foregroundStyle(Color.warmInkMuted) } Spacer() Text("+\(formattedAmount(income.amount))") - .font(.system(size: 14, weight: .semibold)) + .warmFont(14, weight: .semibold) .foregroundStyle(Color.warmGreen) .monospacedDigit() } diff --git a/Savely/Views/MainNavigationView.swift b/Savely/Views/MainNavigationView.swift index 62bb2e4..b6d66f1 100755 --- a/Savely/Views/MainNavigationView.swift +++ b/Savely/Views/MainNavigationView.swift @@ -72,7 +72,7 @@ struct WarmTabBar: View { Button(action: onAddTapped) { Image(systemName: "plus") - .font(.system(size: 22, weight: .semibold)) + .warmFont(22, weight: .semibold) .foregroundStyle(isExpanded ? Color.warmOnGreen : Color.warmOnInk) .frame(width: 52, height: 52) .background(isExpanded ? Color.warmGreenFill : Color.warmInk) @@ -109,9 +109,9 @@ struct WarmTabBarItem: View { Button(action: { selectedTab = tag }) { VStack(spacing: 4) { Image(systemName: icon) - .font(.system(size: 22, weight: isActive ? .semibold : .regular)) + .warmFont(22, weight: isActive ? .semibold : .regular) Text(label) - .font(.system(size: 10, weight: isActive ? .semibold : .medium)) + .warmFont(10, weight: isActive ? .semibold : .medium) } .foregroundStyle(isActive ? Color.warmGreen : Color.warmInkMuted) .frame(maxWidth: .infinity) @@ -187,12 +187,12 @@ struct WarmActionSheet: View { HStack(alignment: .center) { Text("What's the move?") - .font(.system(size: 24, weight: .regular, design: .serif)) + .warmFont(24, weight: .regular, design: .serif) .foregroundStyle(Color.warmInk) Spacer() Button(action: onDismiss) { Image(systemName: "xmark") - .font(.system(size: 11, weight: .medium)) + .warmFont(11, weight: .medium) .foregroundStyle(Color.warmInkSoft) .frame(width: 32, height: 32) .background(Color.warmSurface) @@ -203,7 +203,7 @@ struct WarmActionSheet: View { .padding(.horizontal, 20) Text("Pick what to log right now.") - .font(.system(size: 13)).foregroundStyle(Color.warmInkMuted) + .warmFont(13).foregroundStyle(Color.warmInkMuted) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 20).padding(.top, 2).padding(.bottom, 18) @@ -215,22 +215,22 @@ struct WarmActionSheet: View { }) { HStack(spacing: 14) { Image(systemName: action.icon) - .font(.system(size: 16)) + .warmFont(16) .foregroundStyle(action.fg) .frame(width: 40, height: 40) .background(action.bg) .cornerRadius(12) VStack(alignment: .leading, spacing: 2) { Text(action.label) - .font(.system(size: 15, weight: .semibold)) + .warmFont(15, weight: .semibold) .foregroundStyle(Color.warmInk) Text(action.sub) - .font(.system(size: 12)) + .warmFont(12) .foregroundStyle(Color.warmInkMuted) } Spacer() Image(systemName: "chevron.right") - .font(.system(size: 11, weight: .medium)) + .warmFont(11, weight: .medium) .foregroundStyle(Color.warmInkMuted) } .padding(.horizontal, 16).padding(.vertical, 14) @@ -268,10 +268,10 @@ struct WarmKeypad: View { Group { if key == "⌫" { Image(systemName: "delete.left") - .font(.system(size: 20, weight: .light)) + .warmFont(20, weight: .light) } else { Text(key) - .font(.system(size: 24, weight: .regular, design: .serif)) + .warmFont(24, weight: .regular, design: .serif) } } .foregroundStyle(Color.warmInk) @@ -340,15 +340,15 @@ struct WarmQuickExpenseView: View { HStack { Button(action: onBack) { Image(systemName: "chevron.left") - .font(.system(size: 14, weight: .medium)).foregroundStyle(Color.warmInkSoft) + .warmFont(14, weight: .medium).foregroundStyle(Color.warmInkSoft) .frame(width: 32, height: 32) } Spacer() Text("Log expense") - .font(.system(size: 18, weight: .regular, design: .serif)).foregroundStyle(Color.warmInk) + .warmFont(18, weight: .regular, design: .serif).foregroundStyle(Color.warmInk) Spacer() Button("Save", action: saveAndDismiss) - .font(.system(size: 13, weight: .semibold)).foregroundStyle(Color.warmGreen) + .warmFont(13, weight: .semibold).foregroundStyle(Color.warmGreen) .opacity(canSave ? 1 : 0.4).disabled(!canSave) } .padding(.horizontal, 20).padding(.bottom, 4) @@ -357,15 +357,15 @@ struct WarmQuickExpenseView: View { HStack(alignment: .firstTextBaseline, spacing: 2) { Text("−$") - .font(.system(size: 32, weight: .regular, design: .serif)).foregroundStyle(Color.warmInkMuted) + .warmFont(32, weight: .regular, design: .serif).foregroundStyle(Color.warmInkMuted) Text(formatKeypadAmount(amountStr)) - .font(.system(size: 72, weight: .regular, design: .serif)).foregroundStyle(Color.warmInk) + .warmFont(72, weight: .regular, design: .serif).foregroundStyle(Color.warmInk) .monospacedDigit().minimumScaleFactor(0.4).lineLimit(1) } .padding(.top, 8) TextField("Merchant · Today", text: $description) - .font(.system(size: 12)).foregroundStyle(Color.warmInkSoft) + .warmFont(12).foregroundStyle(Color.warmInkSoft) .multilineTextAlignment(.center) .padding(.horizontal, 16).padding(.vertical, 7) .background(Color.warmSurface).clipShape(Capsule()) @@ -378,7 +378,7 @@ struct WarmQuickExpenseView: View { let isOn = selectedCat == cat.label Button(action: { selectedCat = cat.label }) { Text(cat.label) - .font(.system(size: 13, weight: .semibold)) + .warmFont(13, weight: .semibold) .foregroundStyle(isOn ? cat.fg : Color.warmInkSoft) .padding(.horizontal, 14).padding(.vertical, 8) .background(isOn ? cat.bg : Color.clear) @@ -462,15 +462,15 @@ struct WarmQuickIncomeView: View { HStack { Button(action: onBack) { Image(systemName: "chevron.left") - .font(.system(size: 14, weight: .medium)).foregroundStyle(Color.warmInkSoft) + .warmFont(14, weight: .medium).foregroundStyle(Color.warmInkSoft) .frame(width: 32, height: 32) } Spacer() Text("Log income") - .font(.system(size: 18, weight: .regular, design: .serif)).foregroundStyle(Color.warmInk) + .warmFont(18, weight: .regular, design: .serif).foregroundStyle(Color.warmInk) Spacer() Button("Save", action: saveAndDismiss) - .font(.system(size: 13, weight: .semibold)).foregroundStyle(Color.warmGreen) + .warmFont(13, weight: .semibold).foregroundStyle(Color.warmGreen) .opacity(canSave ? 1 : 0.4).disabled(!canSave) } .padding(.horizontal, 20).padding(.bottom, 4) @@ -479,15 +479,15 @@ struct WarmQuickIncomeView: View { HStack(alignment: .firstTextBaseline, spacing: 2) { Text("+$") - .font(.system(size: 32, weight: .regular, design: .serif)).foregroundStyle(Color.warmGreen) + .warmFont(32, weight: .regular, design: .serif).foregroundStyle(Color.warmGreen) Text(formatKeypadAmount(amountStr)) - .font(.system(size: 72, weight: .regular, design: .serif)).foregroundStyle(Color.warmInk) + .warmFont(72, weight: .regular, design: .serif).foregroundStyle(Color.warmInk) .monospacedDigit().minimumScaleFactor(0.4).lineLimit(1) } .padding(.top, 8) TextField("Source · Today", text: $description) - .font(.system(size: 12)).foregroundStyle(Color.warmInkSoft) + .warmFont(12).foregroundStyle(Color.warmInkSoft) .multilineTextAlignment(.center) .padding(.horizontal, 16).padding(.vertical, 7) .background(Color.warmSurface).clipShape(Capsule()) @@ -499,7 +499,7 @@ struct WarmQuickIncomeView: View { let isOn = selectedSource == src Button(action: { selectedSource = src }) { Text(src) - .font(.system(size: 13, weight: .semibold)) + .warmFont(13, weight: .semibold) .foregroundStyle(isOn ? Color.warmGreenDeep : Color.warmInkSoft) .padding(.horizontal, 14).padding(.vertical, 8) .background(isOn ? Color.warmGreenSoft : Color.clear) @@ -518,17 +518,17 @@ struct WarmQuickIncomeView: View { if let suggestion = autoMoveSuggestion { HStack(spacing: 10) { Image(systemName: autoMoveArmed ? "checkmark.circle.fill" : "sparkles") - .font(.system(size: 13)).foregroundStyle(Color.warmGreen) + .warmFont(13).foregroundStyle(Color.warmGreen) .frame(width: 32, height: 32).background(Color.warmSurface).cornerRadius(10) Text(bannerText(for: suggestion)) - .font(.system(size: 12)).foregroundStyle(Color.warmGreenDeep) + .warmFont(12).foregroundStyle(Color.warmGreenDeep) .frame(maxWidth: .infinity, alignment: .leading) Button(autoMoveArmed ? "UNDO" : "YES") { withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) { autoMoveArmed.toggle() } } - .font(.system(size: 11, weight: .bold)) + .warmFont(11, weight: .bold) .foregroundStyle(autoMoveArmed ? Color.warmGreenDeep : Color.warmOnGreen) .padding(.horizontal, 10).padding(.vertical, 6) .background(autoMoveArmed ? Color.warmSurface : Color.warmGreenFill) @@ -605,12 +605,12 @@ struct WarmQuickDepositView: View { HStack { Button(action: onBack) { Image(systemName: "chevron.left") - .font(.system(size: 14, weight: .medium)).foregroundStyle(Color.warmInkSoft) + .warmFont(14, weight: .medium).foregroundStyle(Color.warmInkSoft) .frame(width: 32, height: 32) } Spacer() Text("Move money") - .font(.system(size: 18, weight: .regular, design: .serif)).foregroundStyle(Color.warmInk) + .warmFont(18, weight: .regular, design: .serif).foregroundStyle(Color.warmInk) Spacer() Color.clear.frame(width: 32, height: 32) } @@ -620,9 +620,9 @@ struct WarmQuickDepositView: View { VStack(spacing: 12) { HStack(alignment: .firstTextBaseline, spacing: 2) { Text("$") - .font(.system(size: 28, weight: .regular, design: .serif)).foregroundStyle(Color.warmInkMuted) + .warmFont(28, weight: .regular, design: .serif).foregroundStyle(Color.warmInkMuted) Text("\(Int(selectedPreset))") - .font(.system(size: 60, weight: .regular, design: .serif)).foregroundStyle(Color.warmInk) + .warmFont(60, weight: .regular, design: .serif).foregroundStyle(Color.warmInk) .monospacedDigit() } HStack(spacing: 6) { @@ -630,7 +630,7 @@ struct WarmQuickDepositView: View { let isOn = selectedPreset == p Button(action: { selectedPreset = p }) { Text("$\(Int(p))") - .font(.system(size: 12, weight: .semibold)) + .warmFont(12, weight: .semibold) .foregroundStyle(isOn ? Color.warmGreenDeep : Color.warmInkSoft) .padding(.horizontal, 12).padding(.vertical, 6) .background(isOn ? Color.warmGreenSoft : Color.clear) @@ -647,12 +647,12 @@ struct WarmQuickDepositView: View { // Goal picker VStack(alignment: .leading, spacing: 8) { Text("TO WHICH GOAL") - .font(.system(size: 11, weight: .semibold)).foregroundStyle(Color.warmInkMuted).tracking(0.8) + .warmFont(11, weight: .semibold).foregroundStyle(Color.warmInkMuted).tracking(0.8) .padding(.horizontal, 20) if goals.isEmpty { Text("No goals yet — create one first.") - .font(.system(size: 13)).foregroundStyle(Color.warmInkMuted) + .warmFont(13).foregroundStyle(Color.warmInkMuted) .padding(.horizontal, 20) } else { ScrollView { @@ -665,12 +665,12 @@ struct WarmQuickDepositView: View { .frame(width: 36, height: 36) .overlay( Text(goal.name.prefix(1).uppercased()) - .font(.system(size: 16, weight: .regular, design: .serif)) + .warmFont(16, weight: .regular, design: .serif) .foregroundStyle(Color.warmOnGreen) ) VStack(alignment: .leading, spacing: 4) { Text(goal.name) - .font(.system(size: 14, weight: .semibold)).foregroundStyle(Color.warmInk) + .warmFont(14, weight: .semibold).foregroundStyle(Color.warmInk) .lineLimit(1) GeometryReader { geo in ZStack(alignment: .leading) { @@ -688,7 +688,7 @@ struct WarmQuickDepositView: View { if isOn { Circle().fill(Color.warmGreenFill).frame(width: 22, height: 22) Image(systemName: "checkmark") - .font(.system(size: 10, weight: .bold)).foregroundStyle(Color.warmOnGreen) + .warmFont(10, weight: .bold).foregroundStyle(Color.warmOnGreen) } } } @@ -717,7 +717,7 @@ struct WarmQuickDepositView: View { Button(action: saveDeposit) { let goalName = selectedGoal.map { $0.name.components(separatedBy: ",").first ?? $0.name } Text(goalName.map { "Add $\(Int(selectedPreset)) to \($0)" } ?? "Select a goal") - .font(.system(size: 15, weight: .semibold)).foregroundStyle(Color.warmOnGreen) + .warmFont(15, weight: .semibold).foregroundStyle(Color.warmOnGreen) .frame(maxWidth: .infinity).frame(height: 50) .background(selectedGoal != nil ? Color.warmGreenFill : Color.warmInkMuted) .cornerRadius(14) diff --git a/Savely/Views/Onboarding/NotificationSettingsStepView.swift b/Savely/Views/Onboarding/NotificationSettingsStepView.swift index 556be11..28f507e 100644 --- a/Savely/Views/Onboarding/NotificationSettingsStepView.swift +++ b/Savely/Views/Onboarding/NotificationSettingsStepView.swift @@ -23,7 +23,7 @@ struct NotificationSettingsStepView: View { .frame(width: 96, height: 96) .overlay( Image(systemName: "bell") - .font(.system(size: 40, weight: .medium)) + .warmFont(40, weight: .medium) .foregroundStyle(Color.warmClay) ) .scaleEffect(appeared ? 1 : 0.92) @@ -32,12 +32,12 @@ struct NotificationSettingsStepView: View { VStack(spacing: 12) { Text(Strings.Onboarding.notificationSettingsTitle) - .font(.system(size: 30, weight: .regular, design: .serif)) + .warmFont(30, weight: .regular, design: .serif) .foregroundStyle(Color.warmInk) .multilineTextAlignment(.center) Text(Strings.Onboarding.notificationSettingsDescription) - .font(.system(size: 15)) + .warmFont(15) .foregroundStyle(Color.warmInkSoft) .multilineTextAlignment(.center) .lineSpacing(3) diff --git a/Savely/Views/Onboarding/OnboardingStepView.swift b/Savely/Views/Onboarding/OnboardingStepView.swift index 703d88e..959030b 100644 --- a/Savely/Views/Onboarding/OnboardingStepView.swift +++ b/Savely/Views/Onboarding/OnboardingStepView.swift @@ -22,7 +22,7 @@ struct OnboardingStepView: View { .frame(width: 96, height: 96) .overlay( Image(systemName: step.image) - .font(.system(size: 40, weight: .medium)) + .warmFont(40, weight: .medium) .foregroundStyle(step.tileColor) ) .scaleEffect(appeared ? 1 : 0.92) @@ -30,12 +30,12 @@ struct OnboardingStepView: View { VStack(spacing: 12) { Text(step.title) - .font(.system(size: 30, weight: .regular, design: .serif)) + .warmFont(30, weight: .regular, design: .serif) .foregroundStyle(Color.warmInk) .multilineTextAlignment(.center) Text(step.description) - .font(.system(size: 15)) + .warmFont(15) .foregroundStyle(Color.warmInkSoft) .multilineTextAlignment(.center) .lineSpacing(3) diff --git a/Savely/Views/Onboarding/OnboardingView.swift b/Savely/Views/Onboarding/OnboardingView.swift index 330e6b9..1fc1e05 100644 --- a/Savely/Views/Onboarding/OnboardingView.swift +++ b/Savely/Views/Onboarding/OnboardingView.swift @@ -44,7 +44,7 @@ struct OnboardingView: View { Button(action: handleButtonTap) { Text(isLastStep ? Strings.Buttons.startButton : Strings.Buttons.nextButton) - .font(.system(size: 16, weight: .semibold)) + .warmFont(16, weight: .semibold) .frame(maxWidth: .infinity) .padding(.vertical, 16) .background(Color.warmGreenFill) diff --git a/Savely/Views/Onboarding/WelcomeStepView.swift b/Savely/Views/Onboarding/WelcomeStepView.swift index 536f034..57a3d35 100644 --- a/Savely/Views/Onboarding/WelcomeStepView.swift +++ b/Savely/Views/Onboarding/WelcomeStepView.swift @@ -23,12 +23,12 @@ struct WelcomeStepView: View { VStack(spacing: 12) { Text(Strings.Onboarding.welcomeTitle) - .font(.system(size: 30, weight: .regular, design: .serif)) + .warmFont(30, weight: .regular, design: .serif) .foregroundStyle(Color.warmInk) .multilineTextAlignment(.center) Text(Strings.Onboarding.welcomeLabel) - .font(.system(size: 15)) + .warmFont(15) .foregroundStyle(Color.warmInkSoft) .multilineTextAlignment(.center) .lineSpacing(3) diff --git a/Savely/Views/ProfileTab/AchievementsView.swift b/Savely/Views/ProfileTab/AchievementsView.swift index 17a5bde..5f8ddb5 100644 --- a/Savely/Views/ProfileTab/AchievementsView.swift +++ b/Savely/Views/ProfileTab/AchievementsView.swift @@ -34,11 +34,11 @@ struct AchievementsView: View { // Heading VStack(alignment: .leading, spacing: 6) { Text("\(earned) earned,\n\(states.count - earned) to go.") - .font(.system(size: 32, weight: .regular, design: .serif)) + .warmFont(32, weight: .regular, design: .serif) .foregroundStyle(Color.warmInk) .lineSpacing(2) Text("Badges unlock as you save — no pressure.") - .font(.system(size: 14)) + .warmFont(14) .foregroundStyle(Color.warmInkMuted) } .padding(.top, 8) @@ -107,7 +107,7 @@ struct AchievementRow: View { .overlay(RoundedRectangle(cornerRadius: 14).stroke(badge.unlocked ? Color.clear : Color.warmLine, lineWidth: 1)) .overlay( Image(systemName: badge.icon) - .font(.system(size: 20, weight: badge.unlocked ? .semibold : .regular)) + .warmFont(20, weight: badge.unlocked ? .semibold : .regular) .foregroundStyle(badge.unlocked ? badge.tileColor : Color.warmInkMuted) ) .scaleEffect(celebrate ? 1.12 : 1) @@ -119,10 +119,10 @@ struct AchievementRow: View { VStack(alignment: .leading, spacing: 3) { Text(badge.title) - .font(.system(size: 14, weight: .semibold)) + .warmFont(14, weight: .semibold) .foregroundStyle(badge.unlocked ? Color.warmInk : Color.warmInkSoft) Text(badge.subtitle) - .font(.system(size: 12)) + .warmFont(12) .foregroundStyle(Color.warmInkMuted) // Progress bar for locked badges — fills to its real value on appear. @@ -141,7 +141,7 @@ struct AchievementRow: View { } .frame(height: 4) Text("\(Int(badge.progress * 100))%") - .font(.system(size: 11)) + .warmFont(11) .foregroundStyle(Color.warmInkMuted) .monospacedDigit() } @@ -151,7 +151,7 @@ struct AchievementRow: View { Spacer() if badge.unlocked { Image(systemName: "checkmark") - .font(.system(size: 16, weight: .medium)) + .warmFont(16, weight: .medium) .foregroundStyle(Color.warmGreen) } } diff --git a/Savely/Views/ProfileTab/ProfileView.swift b/Savely/Views/ProfileTab/ProfileView.swift index dba4250..727d168 100755 --- a/Savely/Views/ProfileTab/ProfileView.swift +++ b/Savely/Views/ProfileTab/ProfileView.swift @@ -95,14 +95,14 @@ struct ProfileView: View { VStack(alignment: .leading, spacing: 4) { Text("LIFETIME INCOME") - .font(.system(size: 11, weight: .semibold)) + .warmFont(11, weight: .semibold) .foregroundStyle(Color.warmOnGreen.opacity(0.7)) .tracking(0.8) Text(formattedAmount(lifetimeIncome)) - .font(.system(size: 40, weight: .regular, design: .serif)) + .warmFont(40, weight: .regular, design: .serif) .foregroundStyle(Color.warmOnGreen) Text(savingSinceSubtitle) - .font(.system(size: 13)) + .warmFont(13) .foregroundStyle(Color.warmOnGreen.opacity(0.75)) } .frame(maxWidth: .infinity, alignment: .leading) @@ -150,12 +150,12 @@ struct ProfileView: View { VStack(spacing: 12) { HStack { Text("Achievements") - .font(.system(size: 18, weight: .regular, design: .serif)) + .warmFont(18, weight: .regular, design: .serif) .foregroundStyle(Color.warmInk) Spacer() Button(action: { showingAchievements = true }) { Text("See all ›") - .font(.system(size: 13, weight: .medium)) + .warmFont(13, weight: .medium) .foregroundStyle(Color.warmGreen) } } @@ -176,7 +176,7 @@ struct ProfileView: View { NextAchievementRow(state: next) } else { Text(Strings.Profile.allAchievementsUnlockedLabel) - .font(.system(size: 13)) + .warmFont(13) .foregroundStyle(Color.warmInkSoft) .frame(maxWidth: .infinity, alignment: .leading) } @@ -241,12 +241,12 @@ struct ProfileView: View { ProfileSection(header: Strings.Profile.dataPrivacyTitle) { HStack(spacing: 16) { Image(systemName: "lock.shield") - .font(.system(size: 18)) + .warmFont(18) .foregroundStyle(Color.warmGreen) .frame(width: 28) .accessibilityHidden(true) Text(Strings.Profile.dataStaysLocalLabel) - .font(.system(size: 13)) + .warmFont(13) .foregroundStyle(Color.warmInkSoft) Spacer() } @@ -276,14 +276,14 @@ struct ProfileStatCell: View { var body: some View { VStack(alignment: .leading, spacing: 6) { Text(label) - .font(.system(size: 11, weight: .semibold)) + .warmFont(11, weight: .semibold) .foregroundStyle(Color.warmInkMuted) .tracking(0.8) .textCase(.uppercase) .lineLimit(1) .minimumScaleFactor(0.8) Text(value) - .font(.system(size: 20, weight: .regular, design: .serif)) + .warmFont(20, weight: .regular, design: .serif) .foregroundStyle(Color.warmInk) .monospacedDigit() .lineLimit(1) @@ -306,7 +306,7 @@ struct NextAchievementRow: View { var body: some View { VStack(alignment: .leading, spacing: 8) { Text(Strings.Profile.nextAchievementLabel) - .font(.system(size: 11, weight: .semibold)) + .warmFont(11, weight: .semibold) .foregroundStyle(Color.warmInkMuted) .tracking(0.8) .textCase(.uppercase) @@ -315,15 +315,15 @@ struct NextAchievementRow: View { RoundedRectangle(cornerRadius: 10) .fill(state.tileBackground) .frame(width: 36, height: 36) - .overlay(Image(systemName: state.icon).font(.system(size: 15)).foregroundStyle(state.tileColor)) + .overlay(Image(systemName: state.icon).warmFont(15).foregroundStyle(state.tileColor)) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 4) { Text(state.title) - .font(.system(size: 14, weight: .semibold)) + .warmFont(14, weight: .semibold) .foregroundStyle(Color.warmInk) Text(state.subtitle) - .font(.system(size: 12)) + .warmFont(12) .foregroundStyle(Color.warmInkSoft) GeometryReader { geo in ZStack(alignment: .leading) { @@ -337,7 +337,7 @@ struct NextAchievementRow: View { } Spacer() Text("\(Int(state.progress * 100))%") - .font(.system(size: 13, weight: .semibold)) + .warmFont(13, weight: .semibold) .foregroundStyle(Color.warmInkSoft) .monospacedDigit() } @@ -355,18 +355,18 @@ struct NotificationsDeniedRow: View { var body: some View { HStack(alignment: .top, spacing: 16) { Image(systemName: "bell.slash") - .font(.system(size: 18)) + .warmFont(18) .foregroundStyle(Color.warmInkSoft) .frame(width: 28) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 6) { Text(Strings.Profile.notificationsDeniedHint) - .font(.system(size: 13)) + .warmFont(13) .foregroundStyle(Color.warmInkSoft) .fixedSize(horizontal: false, vertical: true) Button(action: onOpenSettings) { Text(Strings.Profile.openSettingsButton) - .font(.system(size: 13, weight: .medium)) + .warmFont(13, weight: .medium) .foregroundStyle(Color.warmGreen) } } @@ -385,7 +385,7 @@ struct ReminderTimeRow: View { HStack(spacing: 16) { Color.clear.frame(width: 28) Text(Strings.Profile.reminderTimeLabel) - .font(.system(size: 14)) + .warmFont(14) .foregroundStyle(Color.warmInkSoft) Spacer() DatePicker(Strings.Profile.reminderTimeLabel, selection: $time, displayedComponents: .hourAndMinute) @@ -404,7 +404,7 @@ struct BadgeTile: View { RoundedRectangle(cornerRadius: 14) .fill(bg) .overlay(RoundedRectangle(cornerRadius: 14).stroke(unlocked ? Color.clear : Color.warmLine, lineWidth: 1)) - .overlay(Image(systemName: icon).font(.system(size: 18, weight: unlocked ? .semibold : .regular)).foregroundStyle(color)) + .overlay(Image(systemName: icon).warmFont(18, weight: unlocked ? .semibold : .regular).foregroundStyle(color)) .frame(maxWidth: .infinity) .aspectRatio(1, contentMode: .fit) } @@ -418,7 +418,7 @@ struct ProfileSection: View { var body: some View { VStack(alignment: .leading, spacing: 8) { Text(header.uppercased()) - .font(.system(size: 11, weight: .semibold)) + .warmFont(11, weight: .semibold) .foregroundStyle(Color.warmInkMuted) .tracking(0.8) .padding(.leading, 4) @@ -448,12 +448,12 @@ struct SettingsToggleRow: View { var body: some View { HStack(spacing: 16) { Image(systemName: icon) - .font(.system(size: 18)) + .warmFont(18) .foregroundStyle(Color.warmInkSoft) .frame(width: 28) .accessibilityHidden(true) Toggle(isOn: $isOn) { - Text(title).font(.system(size: 14, weight: .medium)).foregroundStyle(Color.warmInk) + Text(title).warmFont(14, weight: .medium).foregroundStyle(Color.warmInk) } .tint(Color.warmGreen) } @@ -471,16 +471,16 @@ struct SettingsNavRow: View { Button(action: onTap) { HStack(spacing: 16) { Image(systemName: icon) - .font(.system(size: 18)) + .warmFont(18) .foregroundStyle(color) .frame(width: 28) - Text(title).font(.system(size: 14, weight: .medium)).foregroundStyle(color == Color.warmInkSoft ? Color.warmInk : color) + Text(title).warmFont(14, weight: .medium).foregroundStyle(color == Color.warmInkSoft ? Color.warmInk : color) Spacer() if let d = detail { - Text(d).font(.system(size: 13)).foregroundStyle(Color.warmInkMuted) + Text(d).warmFont(13).foregroundStyle(Color.warmInkMuted) } if color != Color.warmClay { - Image(systemName: "chevron.right").font(.system(size: 12)).foregroundStyle(Color.warmInkMuted) + Image(systemName: "chevron.right").warmFont(12).foregroundStyle(Color.warmInkMuted) } } .padding(.horizontal, 16).padding(.vertical, 14) diff --git a/Savely/Views/SplashScreenView.swift b/Savely/Views/SplashScreenView.swift index d7fe546..1380e14 100755 --- a/Savely/Views/SplashScreenView.swift +++ b/Savely/Views/SplashScreenView.swift @@ -71,7 +71,7 @@ struct SplashScreenView: View { HStack(spacing: 0) { ForEach(0.. Date: Sun, 16 Aug 2026 00:02:15 -0700 Subject: [PATCH 4/5] feat(a11y): VoiceOver labels, 44pt hit areas, Reduce Motion everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Every icon-only control has a name: the "+" shell (with a hint), tab bar items (isTabBar container, isSelected on the active one), sheet back/close chevrons, the keypad's ⌫ and decimal point, the Money tab's inline "+" buttons, the Goals "+" buttons, the favorite star (label flips with state), the wizard's back/close, clear-name and month arrows. All of them get a ≥44pt hit area via tappable44(). - Rows read as one element with a spoken value: recent transactions, expense and income rows (with the category/source and a long-press hint), monthly summary cells, goal cards, achievement rows. Rings and bars expose "N percent". The keypad amount reads "Expense amount, 500 dollars" instead of three fragments; the income trend badge reads "Month over month, up 12 percent". Decorative tiles/stars/glyphs are hidden from VoiceOver. - honorsReduceMotion(): under Reduce Motion every animation in a subtree becomes instant. Applied at the app root, the quick-add sheet, the goal wizard cover, the goal edit sheet and the deposit sheet — the presented roots the app-level modifier cannot reach. - Every new spoken string is localized: literals go through the String Catalog, interpolations through String(localized:) / Text so the key is a real format string; es-419 added for all 37 new keys. --- Savely/Resources/Font+Warm.swift | 22 + Savely/Resources/Localizable.xcstrings | 574 +++++++++++++++++- Savely/SavelyApp.swift | 1 + Savely/Views/DashboardTab/DashboardView.swift | 9 + .../ExpensesTab/ExpenseTrackerView.swift | 7 + Savely/Views/GoalsTab/AddGoalFlow.swift | 8 + Savely/Views/GoalsTab/GoalDetailView.swift | 3 + Savely/Views/GoalsTab/GoalEditSheet.swift | 1 + Savely/Views/GoalsTab/GoalsView.swift | 9 + .../Views/IncomesTab/IncomesTrackerView.swift | 10 + Savely/Views/MainNavigationView.swift | 23 + .../Views/Onboarding/OnboardingStepView.swift | 1 + .../Views/ProfileTab/AchievementsView.swift | 3 + 13 files changed, 653 insertions(+), 18 deletions(-) diff --git a/Savely/Resources/Font+Warm.swift b/Savely/Resources/Font+Warm.swift index 00d75c7..4fe25b4 100644 --- a/Savely/Resources/Font+Warm.swift +++ b/Savely/Resources/Font+Warm.swift @@ -116,3 +116,25 @@ extension UIContentSizeCategory { } } } + +// MARK: - Reduce Motion + +extension View { + /// Under Reduce Motion every animation in this subtree becomes instant — + /// entrances, springs, ring fills, celebrations. Applied at each root + /// (app, sheets, full-screen covers) because presented views are their + /// own tree. + func honorsReduceMotion() -> some View { + modifier(ReduceMotionModifier()) + } +} + +private struct ReduceMotionModifier: ViewModifier { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func body(content: Content) -> some View { + content.transaction { transaction in + if reduceMotion { transaction.animation = nil } + } + } +} diff --git a/Savely/Resources/Localizable.xcstrings b/Savely/Resources/Localizable.xcstrings index 3c1545a..dd100f3 100755 --- a/Savely/Resources/Localizable.xcstrings +++ b/Savely/Resources/Localizable.xcstrings @@ -9,6 +9,502 @@ }, " of %lld" : { + }, + "%@ %lld percent" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%@ %lld percent" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$lld por ciento" + } + } + } + }, + "%@ dollars" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%@ dollars" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ dólares" + } + } + } + }, + "%@%@, %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%@%@, %@" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%2$@, %3$@" + } + } + } + }, + "%@, %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%@, %@" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@" + } + } + } + }, + "%@, %@, %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%@, %@, %@" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@, %3$@" + } + } + } + }, + "%lld percent, %@ of %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%lld percent, %@ of %@" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld por ciento, %2$@ de %3$@" + } + } + } + }, + ", favorite" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : ", favorite" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : ", favorita" + } + } + } + }, + "Add" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar" + } + } + } + }, + "Add expense" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add expense" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar gasto" + } + } + } + }, + "Add income" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add income" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar ingreso" + } + } + } + }, + "Back" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Back" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atrás" + } + } + } + }, + "Clear name" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear name" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Borrar nombre" + } + } + } + }, + "Close" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cerrar" + } + } + } + }, + "Decimal point" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Decimal point" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Punto decimal" + } + } + } + }, + "Expense" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Expense" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gasto" + } + } + } + }, + "Expense amount" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Expense amount" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Monto del gasto" + } + } + } + }, + "Income amount" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Income amount" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Monto del ingreso" + } + } + } + }, + "Log an expense, income, deposit, or new goal" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Log an expense, income, deposit, or new goal" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registra un gasto, un ingreso, un depósito o una meta nueva" + } + } + } + }, + "Long press to delete" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Long press to delete" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mantén presionado para eliminar" + } + } + } + }, + "Make favorite" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Make favorite" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Marcar como favorita" + } + } + } + }, + "Month over month" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Month over month" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mes contra mes" + } + } + } + }, + "New goal" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "New goal" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nueva meta" + } + } + } + }, + "Next month" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Next month" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mes siguiente" + } + } + } + }, + "Previous month" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Previous month" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mes anterior" + } + } + } + }, + "Progress" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Progress" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Progreso" + } + } + } + }, + "Remove from favorites" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Remove from favorites" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quitar de favoritas" + } + } + } + }, + "Unlocked" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Unlocked" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desbloqueado" + } + } + } + }, + "down" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "down" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "abajo" + } + } + } + }, + "minus %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "minus %@" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "menos %@" + } + } + } + }, + "plus %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "plus %@" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "más %@" + } + } + } + }, + "up" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "up" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "arriba" + } + } + } }, "—" : { @@ -58,9 +554,16 @@ } } }, - "%lld percent" : { - - }, + "%lld percent" : { + "localizations" : { + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld por ciento" + } + } + } + }, "%lld%%" : { }, @@ -441,9 +944,16 @@ } } }, - "Completed" : { - - }, + "Completed" : { + "localizations" : { + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completadas" + } + } + } + }, "confirm_password_string" : { "comment" : "Confirm Password String", "extractionState" : "stale", @@ -693,9 +1203,16 @@ } } }, - "Delete" : { - - }, + "Delete" : { + "localizations" : { + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar" + } + } + } + }, "Delete Expense" : { }, @@ -855,9 +1372,16 @@ "Edit anything" : { }, - "Edit goal" : { - - }, + "Edit goal" : { + "localizations" : { + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editar meta" + } + } + } + }, "email_placeholder_title" : { "comment" : "Email Placeholder Label", "extractionState" : "extracted_with_value", @@ -1440,15 +1964,29 @@ } } }, - "History" : { - - }, + "History" : { + "localizations" : { + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Historial" + } + } + } + }, "HOW MUCH" : { }, - "Income" : { - - }, + "Income" : { + "localizations" : { + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingreso" + } + } + } + }, "income_distribution_label" : { "comment" : "Income Distribution Label", "extractionState" : "extracted_with_value", diff --git a/Savely/SavelyApp.swift b/Savely/SavelyApp.swift index 14acfa1..4e255da 100755 --- a/Savely/SavelyApp.swift +++ b/Savely/SavelyApp.swift @@ -19,6 +19,7 @@ struct SavelyApp: App { SplashScreenView() .environmentObject(AppViewModel()) .preferredColorScheme(darkModeEnabled ? .dark : .light) + .honorsReduceMotion() .onAppear { NotificationManager.shared.requestAuthorization() } diff --git a/Savely/Views/DashboardTab/DashboardView.swift b/Savely/Views/DashboardTab/DashboardView.swift index c6d06b7..b8b8705 100755 --- a/Savely/Views/DashboardTab/DashboardView.swift +++ b/Savely/Views/DashboardTab/DashboardView.swift @@ -184,6 +184,7 @@ struct HeroGoalCard: View { Image(systemName: "star.fill") .warmFont(12) .foregroundStyle(Color.warmAmber) + .accessibilityHidden(true) Text("FAVORITE GOAL") .warmFont(11, weight: .semibold) .foregroundStyle(Color.warmInkMuted) @@ -217,6 +218,9 @@ struct HeroGoalCard: View { } } .frame(width: 132, height: 132) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text("Progress")) + .accessibilityValue(Text("\(Int(goal.progress * 100)) percent")) VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 2) { @@ -381,6 +385,7 @@ struct DepositSheet: View { .background(Color.warmSurface) .presentationDetents([.medium, .large]) .presentationDragIndicator(.hidden) + .honorsReduceMotion() .alert("Couldn't save the deposit", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) { Button("OK", role: .cancel) {} } message: { @@ -493,6 +498,7 @@ struct MonthlySummaryCell: View { .background(Color.warmSurface) .cornerRadius(16) .overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.warmLine, lineWidth: 1)) + .accessibilityElement(children: .combine) } private func formattedAmount(_ v: Double) -> String { @@ -537,6 +543,9 @@ struct RecentTransactionRow: View { } .padding(.horizontal, 14) .padding(.vertical, 12) + .accessibilityElement(children: .combine) + .accessibilityLabel(Text("\(txn.isExpense ? String(localized: "Expense") : String(localized: "Income")), \(txn.description), \(shortDate(txn.date))")) + .accessibilityValue(Text(formattedAmount)) } private var iconBg: Color { txn.isExpense ? Color.warmAmberSoft : Color.warmGreenSoft } diff --git a/Savely/Views/ExpensesTab/ExpenseTrackerView.swift b/Savely/Views/ExpensesTab/ExpenseTrackerView.swift index 1430bd7..aacc520 100755 --- a/Savely/Views/ExpensesTab/ExpenseTrackerView.swift +++ b/Savely/Views/ExpensesTab/ExpenseTrackerView.swift @@ -118,7 +118,9 @@ struct ExpenseTrackerView: View { .frame(width: 40, height: 40) .background(Color.warmGreenFill) .cornerRadius(10) + .tappable44() } + .accessibilityLabel("Add expense") } .padding(14) .background(Color.warmSurface) @@ -205,6 +207,7 @@ struct ExpenseRowWarm: View { .fill(iconBg) .frame(width: 36, height: 36) .overlay(Image(systemName: expenseIcon).warmFont(15).foregroundStyle(iconColor)) + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { Text(expense.expenseDescription) @@ -221,6 +224,10 @@ struct ExpenseRowWarm: View { .monospacedDigit() } .padding(.horizontal, 14).padding(.vertical, 12) + .accessibilityElement(children: .combine) + .accessibilityLabel(Text("\(expense.expenseDescription), \(categoryLabel)")) + .accessibilityValue(Text("minus \(formattedAmount(expense.amount))")) + .accessibilityHint("Long press to delete") .contextMenu { Button(role: .destructive, action: { showDeleteConfirmation = true }) { Label("Delete", systemImage: "trash") } } diff --git a/Savely/Views/GoalsTab/AddGoalFlow.swift b/Savely/Views/GoalsTab/AddGoalFlow.swift index 020193e..fcb51b6 100644 --- a/Savely/Views/GoalsTab/AddGoalFlow.swift +++ b/Savely/Views/GoalsTab/AddGoalFlow.swift @@ -122,7 +122,9 @@ struct AddGoalHeader: View { .background(Color.warmSurface) .cornerRadius(12) .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.warmLine, lineWidth: 1)) + .tappable44() } + .accessibilityLabel(showClose ? "Close" : "Back") Spacer() Text("New goal · ") .font(.system(size: headerSize)) @@ -216,7 +218,9 @@ struct AddGoalStep1View: View { Button(action: { state.name = "" }) { Image(systemName: "xmark.circle.fill") .warmFont(16).foregroundStyle(Color.warmInkMuted) + .tappable44() } + .accessibilityLabel("Clear name") } } .padding(14) @@ -515,14 +519,18 @@ struct MiniCalendarView: View { Button(action: shiftMonth(-1)) { Image(systemName: "chevron.left").warmFont(13).foregroundStyle(Color.warmInkSoft) .frame(width: 28, height: 28).cornerRadius(8) + .tappable44() } + .accessibilityLabel("Previous month") Spacer() Text(monthTitle).warmFont(18, weight: .regular, design: .serif).foregroundStyle(Color.warmInk) Spacer() Button(action: shiftMonth(1)) { Image(systemName: "chevron.right").warmFont(13).foregroundStyle(Color.warmInkSoft) .frame(width: 28, height: 28).cornerRadius(8) + .tappable44() } + .accessibilityLabel("Next month") } .padding(.bottom, 14) diff --git a/Savely/Views/GoalsTab/GoalDetailView.swift b/Savely/Views/GoalsTab/GoalDetailView.swift index 252d5be..a1f070c 100644 --- a/Savely/Views/GoalsTab/GoalDetailView.swift +++ b/Savely/Views/GoalsTab/GoalDetailView.swift @@ -68,6 +68,9 @@ struct GoalDetailView: View { } .frame(width: 200, height: 200) .padding(.bottom, 12) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Progress") + .accessibilityValue(Text("\(Int(goal.progress * 100)) percent, \(formattedAmount(goal.current)) of \(formattedAmount(goal.target))")) // — Status · deadline — HStack(spacing: 6) { diff --git a/Savely/Views/GoalsTab/GoalEditSheet.swift b/Savely/Views/GoalsTab/GoalEditSheet.swift index f567239..bba62e3 100644 --- a/Savely/Views/GoalsTab/GoalEditSheet.swift +++ b/Savely/Views/GoalsTab/GoalEditSheet.swift @@ -157,6 +157,7 @@ struct GoalEditSheet: View { } .presentationDetents([.large]) .presentationDragIndicator(.visible) + .honorsReduceMotion() } // MARK: - Save diff --git a/Savely/Views/GoalsTab/GoalsView.swift b/Savely/Views/GoalsTab/GoalsView.swift index 8d5a59c..4c0dcc2 100755 --- a/Savely/Views/GoalsTab/GoalsView.swift +++ b/Savely/Views/GoalsTab/GoalsView.swift @@ -55,7 +55,9 @@ struct GoalsView: View { Image(systemName: "plus") .warmFont(18, weight: .semibold).foregroundStyle(Color.warmOnInk) .frame(width: 40, height: 40).background(Color.warmInk).cornerRadius(14) + .tappable44() } + .accessibilityLabel("New goal") } .padding(.top, 8) @@ -172,7 +174,9 @@ struct GoalsEmptyStateView: View { Image(systemName: "plus") .warmFont(18, weight: .semibold).foregroundStyle(Color.warmOnInk) .frame(width: 40, height: 40).background(Color.warmInk).cornerRadius(14) + .tappable44() } + .accessibilityLabel("New goal") } .padding(.top, 8).padding(.horizontal, 20).padding(.bottom, 28) @@ -300,7 +304,9 @@ struct WarmGoalCard: View { Image(systemName: goal.isFavorite ? "star.fill" : "star") .warmFont(16) .foregroundStyle(goal.isFavorite ? Color.warmAmber : Color.warmInkMuted) + .tappable44() } + .accessibilityLabel(goal.isFavorite ? "Remove from favorites" : "Make favorite") } } @@ -324,6 +330,9 @@ struct WarmGoalCard: View { .padding(16) .background(Color.warmSurface).cornerRadius(18) .overlay(RoundedRectangle(cornerRadius: 18).stroke(Color.warmLine, lineWidth: 1)) + .accessibilityElement(children: .contain) + .accessibilityLabel(Text("\(goal.name)\(goal.isFavorite ? String(localized: ", favorite") : ""), \(pace.label)")) + .accessibilityValue(Text("\(Int(goal.progress * 100)) percent, \(formattedAmount(goal.current)) of \(formattedAmount(goal.target))")) .contextMenu { Button(role: .destructive, action: { showDeleteConfirmation = true }) { Label("Delete", systemImage: "trash") diff --git a/Savely/Views/IncomesTab/IncomesTrackerView.swift b/Savely/Views/IncomesTab/IncomesTrackerView.swift index f67b9a5..de4846f 100755 --- a/Savely/Views/IncomesTab/IncomesTrackerView.swift +++ b/Savely/Views/IncomesTab/IncomesTrackerView.swift @@ -58,6 +58,9 @@ struct IncomesTrackerView: View { .warmFont(13, weight: .semibold) } .foregroundStyle(isUp ? Color.warmGreen : Color.warmClay) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Month over month") + .accessibilityValue(Text("\(isUp ? String(localized: "up") : String(localized: "down")) \(Int(abs(viewModel.percentageChange))) percent")) } HStack(alignment: .bottom, spacing: 8) { ForEach(Array(barData.enumerated()), id: \.offset) { idx, bar in @@ -112,7 +115,9 @@ struct IncomesTrackerView: View { .frame(width: 40, height: 40) .background(Color.warmGreenFill) .cornerRadius(10) + .tappable44() } + .accessibilityLabel("Add income") } .padding(14) .background(Color.warmSurface) @@ -195,6 +200,7 @@ struct IncomeRowWarm: View { .warmFont(14, weight: .medium) .foregroundStyle(Color.warmGreen) ) + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { Text(income.incomeDescription) @@ -211,6 +217,10 @@ struct IncomeRowWarm: View { .monospacedDigit() } .padding(.horizontal, 14).padding(.vertical, 14) + .accessibilityElement(children: .combine) + .accessibilityLabel(Text("\(income.incomeDescription), \(source.map { "\($0.label), " } ?? "")\(shortDate(income.date))")) + .accessibilityValue(Text("plus \(formattedAmount(income.amount))")) + .accessibilityHint("Long press to delete") .contextMenu { Button(role: .destructive, action: { showDeleteConfirmation = true }) { Label("Delete", systemImage: "trash") } } diff --git a/Savely/Views/MainNavigationView.swift b/Savely/Views/MainNavigationView.swift index b6d66f1..a399549 100755 --- a/Savely/Views/MainNavigationView.swift +++ b/Savely/Views/MainNavigationView.swift @@ -47,6 +47,7 @@ struct MainNavigationView: View { } } ) + .honorsReduceMotion() .presentationBackground(Color.warmBg) .presentationCornerRadius(28) .presentationDetents(quickScreen == .actionSheet ? [.height(540)] : [.large]) @@ -54,6 +55,7 @@ struct MainNavigationView: View { } .fullScreenCover(isPresented: $showAddGoalFlow) { AddGoalFlowView(isPresented: $showAddGoalFlow) + .honorsReduceMotion() } } } @@ -83,6 +85,8 @@ struct WarmTabBar: View { ) .rotationEffect(.degrees(isExpanded ? 45 : 0)) } + .accessibilityLabel(isExpanded ? "Close" : "Add") + .accessibilityHint("Log an expense, income, deposit, or new goal") .animation(.spring(response: 0.28, dampingFraction: 0.65), value: isExpanded) .frame(maxWidth: .infinity) .offset(y: -14) @@ -97,6 +101,8 @@ struct WarmTabBar: View { .overlay(alignment: .top) { Rectangle().fill(Color.warmLine).frame(height: 1) } + .accessibilityElement(children: .contain) + .accessibilityAddTraits(.isTabBar) } } @@ -119,6 +125,8 @@ struct WarmTabBarItem: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityLabel(label) + .accessibilityAddTraits(isActive ? [.isSelected] : []) } } @@ -198,7 +206,9 @@ struct WarmActionSheet: View { .background(Color.warmSurface) .clipShape(Circle()) .overlay(Circle().stroke(Color.warmLine, lineWidth: 1)) + .tappable44() } + .accessibilityLabel("Close") } .padding(.horizontal, 20) @@ -279,6 +289,7 @@ struct WarmKeypad: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityLabel(key == "⌫" ? "Delete" : key == "." ? "Decimal point" : key) } } .background(Color.warmSurface) @@ -342,7 +353,9 @@ struct WarmQuickExpenseView: View { Image(systemName: "chevron.left") .warmFont(14, weight: .medium).foregroundStyle(Color.warmInkSoft) .frame(width: 32, height: 32) + .tappable44() } + .accessibilityLabel("Back") Spacer() Text("Log expense") .warmFont(18, weight: .regular, design: .serif).foregroundStyle(Color.warmInk) @@ -363,6 +376,9 @@ struct WarmQuickExpenseView: View { .monospacedDigit().minimumScaleFactor(0.4).lineLimit(1) } .padding(.top, 8) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text("Expense amount")) + .accessibilityValue(Text("\(amountStr) dollars")) TextField("Merchant · Today", text: $description) .warmFont(12).foregroundStyle(Color.warmInkSoft) @@ -464,7 +480,9 @@ struct WarmQuickIncomeView: View { Image(systemName: "chevron.left") .warmFont(14, weight: .medium).foregroundStyle(Color.warmInkSoft) .frame(width: 32, height: 32) + .tappable44() } + .accessibilityLabel("Back") Spacer() Text("Log income") .warmFont(18, weight: .regular, design: .serif).foregroundStyle(Color.warmInk) @@ -485,6 +503,9 @@ struct WarmQuickIncomeView: View { .monospacedDigit().minimumScaleFactor(0.4).lineLimit(1) } .padding(.top, 8) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text("Income amount")) + .accessibilityValue(Text("\(amountStr) dollars")) TextField("Source · Today", text: $description) .warmFont(12).foregroundStyle(Color.warmInkSoft) @@ -607,7 +628,9 @@ struct WarmQuickDepositView: View { Image(systemName: "chevron.left") .warmFont(14, weight: .medium).foregroundStyle(Color.warmInkSoft) .frame(width: 32, height: 32) + .tappable44() } + .accessibilityLabel("Back") Spacer() Text("Move money") .warmFont(18, weight: .regular, design: .serif).foregroundStyle(Color.warmInk) diff --git a/Savely/Views/Onboarding/OnboardingStepView.swift b/Savely/Views/Onboarding/OnboardingStepView.swift index 959030b..728b84f 100644 --- a/Savely/Views/Onboarding/OnboardingStepView.swift +++ b/Savely/Views/Onboarding/OnboardingStepView.swift @@ -27,6 +27,7 @@ struct OnboardingStepView: View { ) .scaleEffect(appeared ? 1 : 0.92) .opacity(appeared ? 1 : 0) + .accessibilityHidden(true) VStack(spacing: 12) { Text(step.title) diff --git a/Savely/Views/ProfileTab/AchievementsView.swift b/Savely/Views/ProfileTab/AchievementsView.swift index 5f8ddb5..f62a540 100644 --- a/Savely/Views/ProfileTab/AchievementsView.swift +++ b/Savely/Views/ProfileTab/AchievementsView.swift @@ -160,6 +160,9 @@ struct AchievementRow: View { .cornerRadius(16) .overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.warmLine, lineWidth: 1)) .opacity(badge.unlocked ? 1 : 0.88) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text("\(badge.title), \(badge.subtitle)")) + .accessibilityValue(badge.unlocked ? Text("Unlocked") : Text("\(Int(badge.progress * 100)) percent")) } } From e15e15a17847d764ed32c65d1e6a19fed268ccfe Mon Sep 17 00:00:00 2001 From: Belli Date: Sun, 16 Aug 2026 00:17:52 -0700 Subject: [PATCH 5/5] fix(a11y): cap tab bar and keypad type like UIKit; record the invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At accessibility sizes the four tab labels wrapped to single letters and the keypad glyphs outgrew their 52pt cells. UITabBar and the system number pad do not scale into the accessibility range either — they rely on VoiceOver labels and hit targets — so both cap at xxxLarge. gotchas.yaml and DESIGN.md record the rules this pass introduced: warmFont over .system(size:), tappable44, labels on icon-only controls, honorsReduceMotion on presented roots, localized spoken strings. --- .claude/knowledge/gotchas.yaml | 5 +++++ DESIGN.md | 1 + Savely/Views/MainNavigationView.swift | 7 +++++++ 3 files changed, 13 insertions(+) diff --git a/.claude/knowledge/gotchas.yaml b/.claude/knowledge/gotchas.yaml index 596dde4..865f3c9 100644 --- a/.claude/knowledge/gotchas.yaml +++ b/.claude/knowledge/gotchas.yaml @@ -47,3 +47,8 @@ summary: The Log-income auto-move banner is a REAL feature (2026-08-15) — computed from goals, armed by YES, applied on save. FeatureFlags.autoMoveSuggestionsEnabled is its kill switch (ON). why: It began as a hardcoded "$230 to Kyoto" placeholder whose YES did nothing. Now GoalModel persists autoMoveEnabled/autoMoveAmount (the AddGoalFlow wizard collected these but never saved them), AutoMoveSuggestion.compute picks one goal (favorite first; else highest required weekly pace = remaining/weeks-to-deadline; open-ended goals last by lowest progress) and caps the amount at min(pace, remaining target, income being logged, month margin = month incomes + this income - month expenses; an under-water month suggests nothing), and the deposit applies only together with the income save using the same clamp as the manual deposit flow. how-to-apply: Any change to the suggestion policy goes in Utilities/AutoMoveSuggestion.swift (unit-tested in AutoMoveSuggestionTests). Never apply a move before the income is saved. The banner recomputes live from the keypad - do not cache a suggestion across amount edits. + +- id: warmfont-not-system-size + summary: Text sizes go through .warmFont(size, weight:, design:) — never .font(.system(size:)) — and Toggle/icon-only Buttons carry a real label. + why: The design is specified in fixed point sizes; .system(size:) ignores Dynamic Type entirely, which broke the WCAG AA commitment (PRODUCT.md). .warmFont renders the exact design size at the default content size (unit-tested identity) and scales on the matching text-style curve; it is a ViewModifier that reads the size category so views re-render on change. Two Text concatenations in AddGoalFlow use @ScaledMetric because they need a Font value. VoiceOver read "Switch, on" and "Button" for unlabeled controls before PR D/D3. + how-to-apply: New text → .warmFont(...). Fixed widths around text → @ScaledMetric. Small controls → .tappable44(). Icon-only Button → .accessibilityLabel(...). Decorative Image/tile → .accessibilityHidden(true). Rows → .accessibilityElement(children: .combine) with a value. Presented roots (sheet/fullScreenCover) → .honorsReduceMotion(). Bars that cannot carry AX text (tab bar, keypad) cap with .dynamicTypeSize(...DynamicTypeSize.xxxLarge) like UIKit does. Spoken strings: literals or Text/String(localized:) so they land in the catalog with es-419. diff --git a/DESIGN.md b/DESIGN.md index afb5e0a..9279a1d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -216,6 +216,7 @@ A warm neutral ground with a single committed green; everything else is a soft t - **Body** (regular 400 / strong 600, 14–15pt): row primary text, descriptions, button labels. Row titles are 600; supporting copy 400. - **Label** (600, 11–13pt, tracking 0.8–1pt, uppercase): section kickers ("FAVORITE GOAL", "SETTINGS", "HISTORY", "7-MONTH TREND") and metadata lines. This is an incumbent, committed convention — one kicker style used consistently, not a new pattern to add elsewhere. - **Figures**: every money figure uses `.monospacedDigit()`. Non-tabular money is a bug. +- **Dynamic Type**: every size above is applied with `.warmFont(size, weight:, design:)` (`Font+Warm.swift`), which is pixel-identical at the default content size and scales on the curve of the nearest system text style. Never `.font(.system(size:))`. The tab bar and the numeric keypad cap at `xxxLarge`, as UIKit's do. ### Named Rules **The Serif-Names, Sans-Explains Rule.** If it is the name of a thing or the number that matters, it is serif. If it tells you what to do with it, it is sans. diff --git a/Savely/Views/MainNavigationView.swift b/Savely/Views/MainNavigationView.swift index a399549..2fc5efc 100755 --- a/Savely/Views/MainNavigationView.swift +++ b/Savely/Views/MainNavigationView.swift @@ -103,6 +103,10 @@ struct WarmTabBar: View { } .accessibilityElement(children: .contain) .accessibilityAddTraits(.isTabBar) + // Like UITabBar: labels stop growing at the largest non-accessibility + // size — five items in 4 columns cannot carry AX text; VoiceOver + // labels and the 44pt targets carry accessibility here. + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) } } @@ -294,6 +298,9 @@ struct WarmKeypad: View { } .background(Color.warmSurface) } + // A numeric keypad, like the system one, keeps its key size; the + // amount above it scales instead. + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) } private func tap(_ key: String) {