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.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..4fe25b4 --- /dev/null +++ b/Savely/Resources/Font+Warm.swift @@ -0,0 +1,140 @@ +// +// 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 + } + } +} + +// 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 69d3746..dd100f3 100755 --- a/Savely/Resources/Localizable.xcstrings +++ b/Savely/Resources/Localizable.xcstrings @@ -9,6 +9,505 @@ }, " 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" + } + } + } + }, + "—" : { + }, ".00" : { @@ -55,9 +554,16 @@ } } }, - "%lld percent" : { - - }, + "%lld percent" : { + "localizations" : { + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld por ciento" + } + } + } + }, "%lld%%" : { }, @@ -323,6 +829,9 @@ }, "Are you sure you want to delete this income?" : { + }, + "AUTO" : { + }, "Auto-move on payday" : { @@ -350,6 +859,9 @@ } } } + }, + "Can't save yet" : { + }, "Cancel" : { @@ -432,6 +944,16 @@ } } }, + "Completed" : { + "localizations" : { + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completadas" + } + } + } + }, "confirm_password_string" : { "comment" : "Confirm Password String", "extractionState" : "stale", @@ -506,6 +1028,12 @@ } } } + }, + "Couldn't save the deposit" : { + + }, + "Couldn't save the goal" : { + }, "Create your first goal" : { @@ -675,9 +1203,16 @@ } } }, - "Delete" : { - - }, + "Delete" : { + "localizations" : { + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar" + } + } + } + }, "Delete Expense" : { }, @@ -837,6 +1372,16 @@ "Edit anything" : { }, + "Edit goal" : { + "localizations" : { + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editar meta" + } + } + } + }, "email_placeholder_title" : { "comment" : "Email Placeholder Label", "extractionState" : "extracted_with_value", @@ -1268,6 +1813,9 @@ }, "Goal · %@" : { + }, + "Goal name" : { + }, "goal_alert_body" : { "comment" : "Goal Alert Body", @@ -1416,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", @@ -1691,6 +2253,12 @@ }, "No active goal" : { + }, + "no date" : { + + }, + "No deposits yet — the first one lands here." : { + }, "No expenses yet" : { @@ -1700,6 +2268,9 @@ }, "No income yet" : { + }, + "No target date — save at your own pace." : { + }, "No tip for today yet — check back soon." : { @@ -1898,6 +2469,9 @@ }, "of $%lld" : { + }, + "OK" : { + }, "ok_button" : { "comment" : "OK Button", @@ -2054,6 +2628,9 @@ } } } + }, + "Pick the amount later in the goal's settings." : { + }, "Pick what to log right now." : { @@ -2789,9 +3366,6 @@ }, "SUGGESTED PACE" : { - }, - "SYMBOL" : { - }, "Take a look\nbefore we start." : { @@ -2813,6 +3387,9 @@ } } } + }, + "Target date" : { + }, "terms_of_service_label" : { "comment" : "Terms of Service Label", 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 3feb7de..b8b8705 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,11 @@ struct HeroGoalCard: View { HStack { HStack(spacing: 6) { Image(systemName: "star.fill") - .font(.system(size: 12)) + .warmFont(12) .foregroundStyle(Color.warmAmber) + .accessibilityHidden(true) Text("FAVORITE GOAL") - .font(.system(size: 11, weight: .semibold)) + .warmFont(11, weight: .semibold) .foregroundStyle(Color.warmInkMuted) .tracking(0.8) } @@ -193,7 +194,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,37 +209,40 @@ 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) } } } .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) { 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 +253,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 +266,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 +316,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 +341,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 +355,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 +369,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) @@ -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: { @@ -407,13 +412,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 +438,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 +477,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) } @@ -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 { @@ -516,27 +522,30 @@ 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) .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/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..aacc520 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,12 +113,14 @@ 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) .cornerRadius(10) + .tappable44() } + .accessibilityLabel("Add expense") } .padding(14) .background(Color.warmSurface) @@ -127,9 +130,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 +140,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,23 +206,28 @@ 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)) + .accessibilityHidden(true) 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() } .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 4b1acdd..fcb51b6 100644 --- a/Savely/Views/GoalsTab/AddGoalFlow.swift +++ b/Savely/Views/GoalsTab/AddGoalFlow.swift @@ -108,30 +108,34 @@ 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) .cornerRadius(12) .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.warmLine, lineWidth: 1)) + .tappable44() } + .accessibilityLabel(showClose ? "Close" : "Back") 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 +176,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 +192,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,16 +209,18 @@ 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) + .tappable44() } + .accessibilityLabel("Clear name") } } .padding(14) @@ -225,14 +231,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 +259,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 +278,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 +287,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 +298,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 +308,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 +319,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 +336,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 +356,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 +392,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 +402,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 +448,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 +466,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 +489,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,29 +517,33 @@ 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) + .tappable44() } + .accessibilityLabel("Previous month") 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) + .tappable44() } + .accessibilityLabel("Next month") } .padding(.bottom, 14) 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 +202,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 +212,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..4c0dcc2 100755 --- a/Savely/Views/GoalsTab/GoalsView.swift +++ b/Savely/Views/GoalsTab/GoalsView.swift @@ -45,17 +45,19 @@ 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) + .tappable44() } + .accessibilityLabel("New goal") } .padding(.top, 8) @@ -63,11 +65,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 +96,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,16 +165,18 @@ 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) + .tappable44() } + .accessibilityLabel("New goal") } .padding(.top, 8).padding(.horizontal, 20).padding(.bottom, 28) @@ -187,11 +191,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 +206,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 +228,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,29 +282,31 @@ 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) + .tappable44() } + .accessibilityLabel(goal.isFavorite ? "Remove from favorites" : "Make favorite") } } @@ -315,15 +321,18 @@ 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) .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 617840f..de4846f 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,11 +53,14 @@ 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) + .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 @@ -65,7 +69,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 +85,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 +98,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,12 +110,14 @@ 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) .cornerRadius(10) + .tappable44() } + .accessibilityLabel("Add income") } .padding(14) .background(Color.warmSurface) @@ -121,7 +127,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 +135,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,25 +197,30 @@ 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) ) + .accessibilityHidden(true) 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() } .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 62bb2e4..2fc5efc 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() } } } @@ -72,7 +74,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) @@ -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,12 @@ struct WarmTabBar: View { .overlay(alignment: .top) { Rectangle().fill(Color.warmLine).frame(height: 1) } + .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) } } @@ -109,9 +119,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) @@ -119,6 +129,8 @@ struct WarmTabBarItem: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityLabel(label) + .accessibilityAddTraits(isActive ? [.isSelected] : []) } } @@ -187,23 +199,25 @@ 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) .clipShape(Circle()) .overlay(Circle().stroke(Color.warmLine, lineWidth: 1)) + .tappable44() } + .accessibilityLabel("Close") } .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 +229,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 +282,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) @@ -279,10 +293,14 @@ struct WarmKeypad: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityLabel(key == "⌫" ? "Delete" : key == "." ? "Decimal point" : key) } } .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) { @@ -340,15 +358,17 @@ 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) + .tappable44() } + .accessibilityLabel("Back") 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 +377,18 @@ 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) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text("Expense amount")) + .accessibilityValue(Text("\(amountStr) dollars")) 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 +401,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 +485,17 @@ 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) + .tappable44() } + .accessibilityLabel("Back") 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 +504,18 @@ 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) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text("Income amount")) + .accessibilityValue(Text("\(amountStr) dollars")) 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 +527,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 +546,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 +633,14 @@ 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) + .tappable44() } + .accessibilityLabel("Back") 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 +650,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 +660,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 +677,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 +695,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 +718,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 +747,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..728b84f 100644 --- a/Savely/Views/Onboarding/OnboardingStepView.swift +++ b/Savely/Views/Onboarding/OnboardingStepView.swift @@ -22,20 +22,21 @@ 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) .opacity(appeared ? 1 : 0) + .accessibilityHidden(true) 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..f62a540 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) } } @@ -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")) } } 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..