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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Savely.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

/* Begin PBXBuildFile section */
120AE56C41CBD54B070A8436 /* ScreenshotSeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11E18C2383669F09FCC4748 /* ScreenshotSeed.swift */; };
A11Y0AUD17BUILDFILE00000 /* AccessibilityAuditUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11Y0AUD17FILEREF0000000 /* AccessibilityAuditUITests.swift */; };
F25F23C6EEAA47853BFA137D /* StoreScreenshotTourUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46D0412010EDA0CEB30538B1 /* StoreScreenshotTourUITests.swift */; };
C5DDD46B40453371BD90C01A /* ReceiptScanUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C696EE6BA0906CAD429D0881 /* ReceiptScanUITests.swift */; };
7479BDFD4C96ED1C9E05A119 /* ReceiptOCRIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A7AF8AF4BCB197FCA023DC6 /* ReceiptOCRIntegrationTests.swift */; };
Expand Down Expand Up @@ -123,6 +124,7 @@

/* Begin PBXFileReference section */
F11E18C2383669F09FCC4748 /* ScreenshotSeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotSeed.swift; sourceTree = "<group>"; };
A11Y0AUD17FILEREF0000000 /* AccessibilityAuditUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityAuditUITests.swift; sourceTree = "<group>"; };
46D0412010EDA0CEB30538B1 /* StoreScreenshotTourUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreScreenshotTourUITests.swift; sourceTree = "<group>"; };
C696EE6BA0906CAD429D0881 /* ReceiptScanUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptScanUITests.swift; sourceTree = "<group>"; };
4A7AF8AF4BCB197FCA023DC6 /* ReceiptOCRIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptOCRIntegrationTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -535,6 +537,7 @@
1DC258212CC07DCF0002DDE3 /* SavelyUITests */ = {
isa = PBXGroup;
children = (
A11Y0AUD17FILEREF0000000 /* AccessibilityAuditUITests.swift */,
46D0412010EDA0CEB30538B1 /* StoreScreenshotTourUITests.swift */,
C696EE6BA0906CAD429D0881 /* ReceiptScanUITests.swift */,
1DC258222CC07DCF0002DDE3 /* SavelyUITests.swift */,
Expand Down Expand Up @@ -841,6 +844,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A11Y0AUD17BUILDFILE00000 /* AccessibilityAuditUITests.swift in Sources */,
F25F23C6EEAA47853BFA137D /* StoreScreenshotTourUITests.swift in Sources */,
C5DDD46B40453371BD90C01A /* ReceiptScanUITests.swift in Sources */,
1DC258252CC07DCF0002DDE3 /* SavelyUITestsLaunchTests.swift in Sources */,
Expand Down
5 changes: 5 additions & 0 deletions Savely/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Savely ships no encryption of its own. The only network call is
HTTPS through URLSession, which is exempt, so App Store Connect
needs no export-compliance answer per upload. -->
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
Expand Down
12 changes: 12 additions & 0 deletions Savely/Resources/Color+Warm.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ extension Color {
static let warmLilac = Color(light: 0x7D6AB8, dark: 0xB3A4DC)
static let warmLilacSoft = Color(light: 0xECE8F4, dark: 0x2E2740)

// The accents above are glyph colors: they clear 3:1 on their own soft
// tint, which is the bar for an icon, and fail the 4.5:1 a *label* needs
// (clay 3.59, sky 3.61, lilac 3.76 in light). The "deep" pair is the same
// hue taken down in lightness until it clears 5:1 — use it whenever the
// accent carries words, as `warmAmberDeep` already did. Dark keeps the
// accent itself: on the dark tints it already reads (clay 4.94, sky 5.67,
// lilac 6.26). Ratios recomputed 2026-08-18 against Apple's Sufficient
// Contrast criteria after XCUIAccessibilityAudit flagged the chips.
static let warmClayDeep = Color(light: 0x944A35, dark: 0xD9846A) // 5.07 on clay-soft
static let warmSkyDeep = Color(light: 0x3C6487, dark: 0x7EA9D0) // 5.01 on sky-soft
static let warmLilacDeep = Color(light: 0x6953AD, dark: 0xB3A4DC) // 5.06 on lilac-soft

// Roles that used to be hardcoded at call sites.
/// Content drawn on `warmGreenFill` (button labels, the income card copy).
static let warmOnGreen = Color(light: 0xFFFFFF, dark: 0xFFFFFF)
Expand Down
13 changes: 13 additions & 0 deletions Savely/Utilities/MoneyCategories.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ enum ExpenseCategory: String, CaseIterable, Identifiable {
}
}

/// `tileColor` is sized for a glyph (3:1 on the tint). A chip label is
/// text and needs 4.5:1, so the selected chips read from here instead —
/// see the "deep" tokens in Color+Warm.swift.
var tileTextColor: Color {
switch self {
case .coffee: return .warmAmberDeep
case .food: return .warmGreen
case .transit: return .warmSkyDeep
case .shopping: return .warmClayDeep
case .other: return .warmInkSoft
}
}

/// The category to *show* for a row: the stored chip when there is one,
/// otherwise a keyword guess. The guess is display-only — it is never
/// written back to the store.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ struct ReceiptReviewView: View {
HStack(spacing: 8) {
ForEach(ExpenseCategory.allCases) { cat in
let isOn = review.wrappedValue.category == cat
let fg = cat == .other ? Color.warmInkSoft : cat.tileColor
let fg = cat.tileTextColor
Button(action: { review.wrappedValue.category = cat }) {
Text(cat.label)
.warmFont(13, weight: .semibold)
Expand Down
7 changes: 7 additions & 0 deletions Savely/Views/DashboardTab/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ struct DashboardView: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 8)
// One heading for VoiceOver: "Good afternoon. Tuesday, August 18"
// instead of the bare "Afternoon." the audit flags as
// not human-readable.
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isHeader)
.accessibilityLabel(Text("Good \(greeting.lowercased()). \(formattedDate)"))

// — Hero Goal Card —
if let goal = favoriteGoal {
Expand Down Expand Up @@ -94,6 +100,7 @@ struct DashboardView: View {
Text("See all")
.warmFont(13, weight: .medium)
.foregroundStyle(Color.warmGreen)
.tappable44()
}
.buttonStyle(.plain)
}
Expand Down
4 changes: 2 additions & 2 deletions Savely/Views/ExpensesTab/ExpenseTrackerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ struct ExpenseTrackerView: View {
.foregroundStyle(Color.warmInk)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity)
.frame(height: 40)
.frame(minHeight: 40)
.background(Color.warmBg)
.cornerRadius(10)
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.warmLineSoft, lineWidth: 1))
Expand All @@ -108,7 +108,7 @@ struct ExpenseTrackerView: View {
.frame(width: amountFieldWidth)
.focused($focusedField, equals: .amount)
}
.frame(height: 40)
.frame(minHeight: 40)
.background(Color.warmBg)
.cornerRadius(10)
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.warmLineSoft, lineWidth: 1))
Expand Down
10 changes: 8 additions & 2 deletions Savely/Views/GoalsTab/GoalDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ struct GoalDetailView: View {
}

private var pace: GoalPace { GoalPace.compute(goal: goal, deposits: goalDeposits) }
@Environment(\.dynamicTypeSize) private var typeSize

var body: some View {
ScrollView {
Expand Down Expand Up @@ -84,8 +85,13 @@ struct GoalDetailView: View {
}
.padding(.bottom, 20)

// — Stat pills —
HStack(spacing: 8) {
// — Stat pills — three across, or stacked once the type is
// an accessibility size: at AX5 a third of the width wraps
// "$1,480" onto two lines, which reads as "$1,48" and "0".
let statLayout = typeSize.isAccessibilitySize
? AnyLayout(VStackLayout(spacing: 8))
: AnyLayout(HStackLayout(spacing: 8))
statLayout {
StatPill(label: "Remaining", value: formattedAmount(max(0, goal.target - goal.current)), accent: goal.color)
StatPill(label: "Needed / wk", value: pace.requiredWeekly.map { formattedAmount($0) } ?? "—", accent: goal.color)
StatPill(label: "ETA", value: pace.etaText(), accent: goal.color)
Expand Down
84 changes: 57 additions & 27 deletions Savely/Views/GoalsTab/GoalsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ struct WarmGoalCard: View {
let onDelete: () -> Void
@State private var showDeleteConfirmation = false
@Query private var goalDeposits: [DepositModel]
@Environment(\.dynamicTypeSize) private var typeSize

init(goal: GoalModel, onFavoriteToggle: @escaping () -> Void, onDelete: @escaping () -> Void) {
self.goal = goal
Expand All @@ -277,36 +278,26 @@ struct WarmGoalCard: View {

var body: some View {
VStack(spacing: 12) {
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 12)
.fill(goal.color).frame(width: 44, height: 44)
.overlay(
Text(goal.name.prefix(1).uppercased())
.warmFont(22, weight: .regular, design: .serif).foregroundStyle(Color.warmOnGreen)
)
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 5) {
Text(goal.name)
.warmFont(15, weight: .semibold).foregroundStyle(Color.warmInk).lineLimit(1)
if goal.isFavorite {
Image(systemName: "star.fill").warmFont(12).foregroundStyle(Color.warmAmber)
}
// Identity left, percent + favorite right — until the type is an
// accessibility size, where the name needs the whole width
// ("Trip to Oaxaca" became "Trip t…") and the figures drop below.
if typeSize.isAccessibilitySize {
VStack(alignment: .leading, spacing: 10) {
identity
HStack(alignment: .firstTextBaseline) {
percent
Spacer(minLength: 8)
favoriteButton
}
Text(pace.label)
.warmFont(12)
.foregroundStyle(pace.status == .behind ? Color.warmClay : Color.warmInkMuted)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("\(Int(goal.progress * 100))%")
.warmFont(18, weight: .regular, design: .serif).foregroundStyle(Color.warmInk)
Button(action: onFavoriteToggle) {
Image(systemName: goal.isFavorite ? "star.fill" : "star")
.warmFont(16)
.foregroundStyle(goal.isFavorite ? Color.warmAmber : Color.warmInkMuted)
.tappable44()
} else {
HStack(spacing: 12) {
identity
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 2) {
percent
favoriteButton
}
.accessibilityLabel(goal.isFavorite ? "Remove from favorites" : "Make favorite")
}
}

Expand Down Expand Up @@ -346,6 +337,45 @@ struct WarmGoalCard: View {
}
}

private var identity: some View {
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 12)
.fill(goal.color).frame(width: 44, height: 44)
.overlay(
Text(goal.name.prefix(1).uppercased())
.warmFont(22, weight: .regular, design: .serif).foregroundStyle(Color.warmOnGreen)
)
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline, spacing: 5) {
Text(goal.name)
.warmFont(15, weight: .semibold).foregroundStyle(Color.warmInk).lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
if goal.isFavorite {
Image(systemName: "star.fill").warmFont(12).foregroundStyle(Color.warmAmber)
}
}
Text(pace.label)
.warmFont(12)
.foregroundStyle(pace.status == .behind ? Color.warmClay : Color.warmInkMuted)
}
}
}

private var percent: some View {
Text("\(Int(goal.progress * 100))%")
.warmFont(18, weight: .regular, design: .serif).foregroundStyle(Color.warmInk)
}

private var favoriteButton: some View {
Button(action: onFavoriteToggle) {
Image(systemName: goal.isFavorite ? "star.fill" : "star")
.warmFont(16)
.foregroundStyle(goal.isFavorite ? Color.warmAmber : Color.warmInkMuted)
.tappable44()
}
.accessibilityLabel(goal.isFavorite ? "Remove from favorites" : "Make favorite")
}

private func formattedAmount(_ v: Double) -> String {
let f = NumberFormatter()
f.numberStyle = .currency; f.currencySymbol = "$"; f.maximumFractionDigits = 0
Expand Down
4 changes: 2 additions & 2 deletions Savely/Views/IncomesTab/IncomesTrackerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ struct IncomesTrackerView: View {
.warmFont(14)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity)
.frame(height: 40)
.frame(minHeight: 40)
.background(Color.warmBg)
.cornerRadius(10)
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.warmLineSoft, lineWidth: 1))
Expand All @@ -103,7 +103,7 @@ struct IncomesTrackerView: View {
.frame(width: amountFieldWidth)
.focused($focusedField, equals: .amount)
}
.frame(height: 40)
.frame(minHeight: 40)
.background(Color.warmBg)
.cornerRadius(10)
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.warmLineSoft, lineWidth: 1))
Expand Down
19 changes: 13 additions & 6 deletions Savely/Views/MainNavigationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,20 @@ struct MainNavigationView: View {

var body: some View {
ZStack(alignment: .bottom) {
// `.toolbar(.hidden, for: .tabBar)` goes on each child, not on the
// TabView: applied outside, the system tab bar is invisible but
// still in the accessibility tree as four unlabelled buttons
// (XCUIAccessibilityAudit: "Element has no description" ×4).
TabView(selection: $selectedTab) {
NavigationStack { DashboardView(onSeeAll: { selectedTab = 2 }) }.tag(0)
NavigationStack { GoalsView(showAddGoalFlow: $showAddGoalFlow) }.tag(1)
NavigationStack { MoneyView() }.tag(2)
NavigationStack { ProfileView() }.tag(3)
NavigationStack { DashboardView(onSeeAll: { selectedTab = 2 }) }
.toolbar(.hidden, for: .tabBar).tag(0)
NavigationStack { GoalsView(showAddGoalFlow: $showAddGoalFlow) }
.toolbar(.hidden, for: .tabBar).tag(1)
NavigationStack { MoneyView() }
.toolbar(.hidden, for: .tabBar).tag(2)
NavigationStack { ProfileView() }
.toolbar(.hidden, for: .tabBar).tag(3)
}
.toolbar(.hidden, for: .tabBar)
.tint(Color.warmGreen)

WarmTabBar(
Expand Down Expand Up @@ -359,7 +366,7 @@ func formatKeypadAmount(_ raw: String) -> String {

/// The expense chips, straight from the canonical categories (what gets stored).
private let expenseCats: [(label: String, bg: Color, fg: Color)] = ExpenseCategory.allCases.map {
($0.label, $0.tileBackground, $0 == .other ? Color.warmInkSoft : $0.tileColor)
($0.label, $0.tileBackground, $0.tileTextColor)
}

struct WarmQuickExpenseView: View {
Expand Down
21 changes: 16 additions & 5 deletions Savely/Views/ProfileTab/ProfileView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import SwiftUI
import SwiftData

struct ProfileView: View {
@Environment(\.dynamicTypeSize) private var typeSize
@StateObject private var viewModel = ProfileViewModel()
@EnvironmentObject var appViewModel: AppViewModel
@Environment(\.modelContext) private var modelContext
Expand Down Expand Up @@ -94,16 +95,19 @@ struct ProfileView: View {
.offset(x: 30, y: -20)

VStack(alignment: .leading, spacing: 4) {
// Full-opacity white on the green fill: 6.3:1 light, 5.0:1
// dark. The .7/.75 washes read as "secondary" but landed at
// 4.0/3.4 in dark, under the 4.5 a label needs.
Text("LIFETIME INCOME")
.warmFont(11, weight: .semibold)
.foregroundStyle(Color.warmOnGreen.opacity(0.7))
.foregroundStyle(Color.warmOnGreen)
.tracking(0.8)
Text(formattedAmount(lifetimeIncome))
.warmFont(40, weight: .regular, design: .serif)
.foregroundStyle(Color.warmOnGreen)
Text(savingSinceSubtitle)
.warmFont(13)
.foregroundStyle(Color.warmOnGreen.opacity(0.75))
.foregroundStyle(Color.warmOnGreen)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(20)
Expand All @@ -117,7 +121,12 @@ struct ProfileView: View {
// MARK: - Stats

private var statsStrip: some View {
HStack(spacing: 10) {
// Three across, or stacked at accessibility type sizes so the labels
// and figures keep one line each instead of wrapping into slivers.
let layout = typeSize.isAccessibilitySize
? AnyLayout(VStackLayout(spacing: 10))
: AnyLayout(HStackLayout(spacing: 10))
return layout {
ProfileStatCell(label: Strings.Profile.statSavedLabel, value: formattedAmount(totalSaved))
ProfileStatCell(label: Strings.Profile.statMovementsLabel, value: "\(movementCount)")
ProfileStatCell(label: Strings.Profile.statActiveGoalsLabel, value: "\(activeGoalCount)")
Expand Down Expand Up @@ -157,6 +166,7 @@ struct ProfileView: View {
Text("See all ›")
.warmFont(13, weight: .medium)
.foregroundStyle(Color.warmGreen)
.tappable44()
}
}

Expand Down Expand Up @@ -275,13 +285,14 @@ struct ProfileStatCell: View {

var body: some View {
VStack(alignment: .leading, spacing: 6) {
// Wrap rather than shrink: a third of the width at AX sizes cannot
// hold "ACTIVE GOALS" on one line, and the audit flags the clip.
Text(label)
.warmFont(11, weight: .semibold)
.foregroundStyle(Color.warmInkMuted)
.tracking(0.8)
.textCase(.uppercase)
.lineLimit(1)
.minimumScaleFactor(0.8)
.fixedSize(horizontal: false, vertical: true)
Text(value)
.warmFont(20, weight: .regular, design: .serif)
.foregroundStyle(Color.warmInk)
Expand Down
Loading
Loading