|
|
@@ -10,107 +10,283 @@ struct SettingsView: View {
|
|
|
|
|
|
var body: some View {
|
|
|
ScrollView {
|
|
|
- VStack(alignment: .leading, spacing: AppSpacing.lg) {
|
|
|
- header
|
|
|
+ HStack(spacing: 0) {
|
|
|
+ Spacer(minLength: 0)
|
|
|
|
|
|
- settingsCard("Search defaults") {
|
|
|
- labeledField("Default country") {
|
|
|
- TextField("Country", text: $viewModel.defaultCountry)
|
|
|
- .textFieldStyle(.roundedBorder)
|
|
|
- }
|
|
|
- labeledField("Default city") {
|
|
|
- TextField("City (optional)", text: $viewModel.defaultCity)
|
|
|
- .textFieldStyle(.roundedBorder)
|
|
|
- }
|
|
|
- Toggle("Prefer remote roles", isOn: $viewModel.preferRemote)
|
|
|
- labeledField("Default freshness (days)") {
|
|
|
- Stepper("\(viewModel.defaultFreshnessDays) days", value: $viewModel.defaultFreshnessDays, in: 1...60)
|
|
|
- }
|
|
|
- }
|
|
|
+ VStack(alignment: .leading, spacing: AppSpacing.lg) {
|
|
|
+ header
|
|
|
|
|
|
- settingsCard("Notifications") {
|
|
|
- HStack {
|
|
|
- Text("macOS permission")
|
|
|
- Spacer()
|
|
|
- Text(viewModel.notificationPermissionLabel)
|
|
|
- .foregroundStyle(AppColors.textSecondary)
|
|
|
- }
|
|
|
- Toggle("Quiet hours (UI only)", isOn: $viewModel.quietHoursEnabled)
|
|
|
- Text("Alert delivery is mocked in Phase 1. System notifications arrive in a later phase.")
|
|
|
- .font(AppTypography.meta)
|
|
|
- .foregroundStyle(AppColors.textTertiary)
|
|
|
+ appearanceSection
|
|
|
+ notificationsSection
|
|
|
+ subscriptionSection
|
|
|
+ privacySection
|
|
|
+ aboutSection
|
|
|
}
|
|
|
+ .padding(.vertical, AppSpacing.xl)
|
|
|
+ .frame(maxWidth: 640, alignment: .leading)
|
|
|
|
|
|
- settingsCard("Appearance") {
|
|
|
- Picker("Theme", selection: Binding(
|
|
|
- get: { viewModel.appearanceMode },
|
|
|
- set: { viewModel.appearanceMode = $0 }
|
|
|
- )) {
|
|
|
- ForEach(AppearanceMode.allCases) { mode in
|
|
|
- Text(mode.displayName).tag(mode)
|
|
|
- }
|
|
|
- }
|
|
|
- .pickerStyle(.segmented)
|
|
|
- }
|
|
|
+ Spacer(minLength: 0)
|
|
|
+ }
|
|
|
+ .padding(.horizontal, AppSpacing.xl)
|
|
|
+ }
|
|
|
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
+ .background(AppColors.backgroundPrimary)
|
|
|
+ .alert("Restore Purchases", isPresented: Binding(
|
|
|
+ get: { viewModel.restoreStatusMessage != nil },
|
|
|
+ set: { if !$0 { viewModel.restoreStatusMessage = nil } }
|
|
|
+ )) {
|
|
|
+ Button("OK", role: .cancel) { viewModel.restoreStatusMessage = nil }
|
|
|
+ } message: {
|
|
|
+ Text(viewModel.restoreStatusMessage ?? "")
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- settingsCard("OpenAI") {
|
|
|
- HStack {
|
|
|
- Text("API key status")
|
|
|
- Spacer()
|
|
|
- Text(viewModel.openAIConfigured ? "Configured" : "Not configured")
|
|
|
- .foregroundStyle(viewModel.openAIConfigured ? AppColors.success : AppColors.textSecondary)
|
|
|
- .font(AppTypography.filterHeader)
|
|
|
- }
|
|
|
- Text("Keys are injected at build time in a later phase. Phase 1 uses mock interpretation only.")
|
|
|
- .font(AppTypography.meta)
|
|
|
- .foregroundStyle(AppColors.textTertiary)
|
|
|
- }
|
|
|
+ // MARK: - Header
|
|
|
|
|
|
- settingsCard("Privacy & data") {
|
|
|
- Text("Search queries may be processed by OpenAI in later phases. Indeed listings open on indeed.com. Local saved jobs and alerts stay on this Mac.")
|
|
|
- .font(AppTypography.body)
|
|
|
- .foregroundStyle(AppColors.textSecondary)
|
|
|
+ private var header: some View {
|
|
|
+ VStack(alignment: .leading, spacing: 6) {
|
|
|
+ Text("Settings")
|
|
|
+ .font(AppTypography.heroTitle)
|
|
|
+ .foregroundStyle(AppColors.textPrimary)
|
|
|
+ Text("Theme, subscription, and about Indeed AI Jobs.")
|
|
|
+ .font(AppTypography.heroSubtitle)
|
|
|
+ .foregroundStyle(AppColors.textSecondary)
|
|
|
+ }
|
|
|
+ .frame(maxWidth: .infinity, alignment: .leading)
|
|
|
+ }
|
|
|
|
|
|
- Button("Clear local data") {}
|
|
|
- .disabled(true)
|
|
|
- .help("Available after persistence ships in Phase 5")
|
|
|
+ // MARK: - Appearance
|
|
|
|
|
|
- Button("Export saved jobs") {}
|
|
|
- .disabled(true)
|
|
|
- }
|
|
|
+ private var appearanceSection: some View {
|
|
|
+ settingsCard(
|
|
|
+ title: "Appearance",
|
|
|
+ subtitle: "Choose how Indeed AI Jobs looks on this Mac. Default follows your system theme."
|
|
|
+ ) {
|
|
|
+ appearancePicker
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- settingsCard("Subscription") {
|
|
|
- Text("Coming soon")
|
|
|
- .font(AppTypography.body)
|
|
|
- .foregroundStyle(AppColors.textSecondary)
|
|
|
+ private var appearancePicker: some View {
|
|
|
+ HStack(spacing: 0) {
|
|
|
+ ForEach(AppearanceMode.allCases) { mode in
|
|
|
+ let isSelected = viewModel.appearanceMode == mode
|
|
|
+ Button {
|
|
|
+ viewModel.appearanceMode = mode
|
|
|
+ } label: {
|
|
|
+ HStack(spacing: 6) {
|
|
|
+ Image(systemName: mode.systemImage)
|
|
|
+ .font(.system(size: 12, weight: .semibold))
|
|
|
+ Text(mode.displayName)
|
|
|
+ .font(AppTypography.chip)
|
|
|
+ }
|
|
|
+ .foregroundStyle(isSelected ? AppColors.textOnBrand : AppColors.textSecondary)
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
+ .padding(.vertical, 10)
|
|
|
+ .background(
|
|
|
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
|
+ .fill(isSelected ? AppColors.brand : Color.clear)
|
|
|
+ )
|
|
|
+ .contentShape(Rectangle())
|
|
|
}
|
|
|
+ .buttonStyle(.plain)
|
|
|
+ .accessibilityAddTraits(isSelected ? .isSelected : [])
|
|
|
+ .accessibilityLabel("\(mode.displayName) appearance")
|
|
|
}
|
|
|
- .padding(AppSpacing.xl)
|
|
|
- .frame(maxWidth: 760, alignment: .leading)
|
|
|
}
|
|
|
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
- .background(AppColors.backgroundPrimary)
|
|
|
+ .padding(3)
|
|
|
+ .background(
|
|
|
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
|
+ .fill(AppColors.backgroundPrimary)
|
|
|
+ )
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
|
+ .stroke(AppColors.border, lineWidth: 1)
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
- private var header: some View {
|
|
|
- VStack(alignment: .leading, spacing: 4) {
|
|
|
- Text("Settings")
|
|
|
- .font(AppTypography.sectionTitle)
|
|
|
- .foregroundStyle(AppColors.textPrimary)
|
|
|
- Text("Defaults, appearance, and privacy for Indeed AI Jobs.")
|
|
|
+ // MARK: - Notifications
|
|
|
+
|
|
|
+ private var notificationsSection: some View {
|
|
|
+ settingsCard(
|
|
|
+ title: "Notifications",
|
|
|
+ subtitle: "Control how job alerts reach you on this Mac."
|
|
|
+ ) {
|
|
|
+ settingsRow(
|
|
|
+ icon: "bell.badge",
|
|
|
+ title: "macOS permission",
|
|
|
+ subtitle: viewModel.notificationPermissionLabel,
|
|
|
+ showsChevron: false
|
|
|
+ )
|
|
|
+
|
|
|
+ Divider().foregroundStyle(AppColors.border)
|
|
|
+
|
|
|
+ Toggle("Quiet hours", isOn: $viewModel.quietHoursEnabled)
|
|
|
+ .toggleStyle(FilterCheckboxToggleStyle())
|
|
|
+ .padding(.vertical, 4)
|
|
|
+
|
|
|
+ Text("Alert delivery is mocked in Phase 1. System notifications arrive in a later phase.")
|
|
|
+ .font(AppTypography.meta)
|
|
|
+ .foregroundStyle(AppColors.textTertiary)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Subscription
|
|
|
+
|
|
|
+ private var subscriptionSection: some View {
|
|
|
+ settingsCard(
|
|
|
+ title: "Subscription",
|
|
|
+ subtitle: "Manage Free vs Pro access for unlimited AI searches and job alerts."
|
|
|
+ ) {
|
|
|
+ settingsRow(
|
|
|
+ icon: "crown.fill",
|
|
|
+ title: viewModel.proPlanName,
|
|
|
+ subtitle: viewModel.isProActive
|
|
|
+ ? "Your Yearly plan is active for unlimited AI searches and full tools."
|
|
|
+ : "Upgrade for unlimited AI searches and full job alert tools.",
|
|
|
+ trailing: viewModel.isProActive ? "Active" : nil,
|
|
|
+ showsChevron: true,
|
|
|
+ action: {}
|
|
|
+ )
|
|
|
+
|
|
|
+ Divider().foregroundStyle(AppColors.border)
|
|
|
+
|
|
|
+ settingsRow(
|
|
|
+ icon: "arrow.clockwise",
|
|
|
+ title: "Restore Purchases",
|
|
|
+ subtitle: "Restore previous Pro purchases on this Mac.",
|
|
|
+ showsChevron: true,
|
|
|
+ action: { viewModel.restorePurchases() }
|
|
|
+ )
|
|
|
+
|
|
|
+ Divider().foregroundStyle(AppColors.border)
|
|
|
+
|
|
|
+ settingsRow(
|
|
|
+ icon: "creditcard",
|
|
|
+ title: "Manage Subscription",
|
|
|
+ subtitle: "Change or cancel your plan in the App Store.",
|
|
|
+ showsChevron: true,
|
|
|
+ action: { viewModel.manageSubscription() }
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Privacy & data
|
|
|
+
|
|
|
+ private var privacySection: some View {
|
|
|
+ settingsCard(
|
|
|
+ title: "Privacy & data",
|
|
|
+ subtitle: "Local saved jobs and alerts stay on this Mac."
|
|
|
+ ) {
|
|
|
+ Text("Search queries may be processed by AI in later phases. Indeed listings open on indeed.com.")
|
|
|
.font(AppTypography.body)
|
|
|
.foregroundStyle(AppColors.textSecondary)
|
|
|
+
|
|
|
+ settingsRow(
|
|
|
+ icon: "trash",
|
|
|
+ title: "Clear local data",
|
|
|
+ subtitle: "Remove saved jobs and alerts stored on this Mac.",
|
|
|
+ showsChevron: false
|
|
|
+ )
|
|
|
+ .opacity(0.45)
|
|
|
+ .help("Available after persistence ships in Phase 5")
|
|
|
+
|
|
|
+ Divider().foregroundStyle(AppColors.border)
|
|
|
+
|
|
|
+ settingsRow(
|
|
|
+ icon: "square.and.arrow.up",
|
|
|
+ title: "Export saved jobs",
|
|
|
+ subtitle: "Download your saved listings for backup.",
|
|
|
+ showsChevron: false
|
|
|
+ )
|
|
|
+ .opacity(0.45)
|
|
|
+ .help("Available after persistence ships in Phase 5")
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private func settingsCard<Content: View>(_ title: String, @ViewBuilder content: () -> Content) -> some View {
|
|
|
- VStack(alignment: .leading, spacing: AppSpacing.sm) {
|
|
|
- Text(title)
|
|
|
- .font(AppTypography.filterHeader)
|
|
|
- .foregroundStyle(AppColors.textPrimary)
|
|
|
+ // MARK: - About Us
|
|
|
+
|
|
|
+ private var aboutSection: some View {
|
|
|
+ settingsCard(
|
|
|
+ title: "About Us",
|
|
|
+ subtitle: "Product info, support, and legal."
|
|
|
+ ) {
|
|
|
+ HStack(spacing: AppSpacing.sm) {
|
|
|
+ ZStack {
|
|
|
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
|
+ .fill(AppColors.brand)
|
|
|
+ .frame(width: 40, height: 40)
|
|
|
+ Image(systemName: "checkmark")
|
|
|
+ .font(.system(size: 16, weight: .bold))
|
|
|
+ .foregroundStyle(.white)
|
|
|
+ }
|
|
|
+
|
|
|
+ VStack(alignment: .leading, spacing: 2) {
|
|
|
+ Text("Indeed AI Jobs")
|
|
|
+ .font(AppTypography.filterHeader)
|
|
|
+ .foregroundStyle(AppColors.textPrimary)
|
|
|
+ Text(viewModel.appVersionLabel)
|
|
|
+ .font(AppTypography.meta)
|
|
|
+ .foregroundStyle(AppColors.textSecondary)
|
|
|
+ }
|
|
|
+
|
|
|
+ Spacer(minLength: 0)
|
|
|
+ }
|
|
|
+ .accessibilityElement(children: .combine)
|
|
|
+ .accessibilityLabel("Indeed AI Jobs, \(viewModel.appVersionLabel)")
|
|
|
+
|
|
|
+ Divider().foregroundStyle(AppColors.border)
|
|
|
+
|
|
|
+ settingsRow(
|
|
|
+ icon: "globe",
|
|
|
+ title: "Website",
|
|
|
+ subtitle: "Visit the Indeed AI Jobs homepage.",
|
|
|
+ showsChevron: true,
|
|
|
+ action: { viewModel.openWebsite() }
|
|
|
+ )
|
|
|
+
|
|
|
+ Divider().foregroundStyle(AppColors.border)
|
|
|
+
|
|
|
+ settingsRow(
|
|
|
+ icon: "questionmark.circle",
|
|
|
+ title: "Get Support",
|
|
|
+ subtitle: "Help with search, alerts, saved jobs, and Pro.",
|
|
|
+ showsChevron: true,
|
|
|
+ action: { viewModel.openSupport() }
|
|
|
+ )
|
|
|
+
|
|
|
+ Divider().foregroundStyle(AppColors.border)
|
|
|
+
|
|
|
+ settingsRow(
|
|
|
+ icon: "hand.raised",
|
|
|
+ title: "Privacy Policy",
|
|
|
+ subtitle: "How we handle your data.",
|
|
|
+ showsChevron: true,
|
|
|
+ action: { viewModel.openPrivacyPolicy() }
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Building blocks
|
|
|
+
|
|
|
+ private func settingsCard<Content: View>(
|
|
|
+ title: String,
|
|
|
+ subtitle: String,
|
|
|
+ @ViewBuilder content: () -> Content
|
|
|
+ ) -> some View {
|
|
|
+ VStack(alignment: .leading, spacing: AppSpacing.md) {
|
|
|
+ VStack(alignment: .leading, spacing: 4) {
|
|
|
+ Text(title)
|
|
|
+ .font(AppTypography.filterHeader)
|
|
|
+ .foregroundStyle(AppColors.textPrimary)
|
|
|
+ Text(subtitle)
|
|
|
+ .font(AppTypography.meta)
|
|
|
+ .foregroundStyle(AppColors.textSecondary)
|
|
|
+ .fixedSize(horizontal: false, vertical: true)
|
|
|
+ }
|
|
|
+
|
|
|
content()
|
|
|
}
|
|
|
- .padding(AppSpacing.md)
|
|
|
+ .padding(AppSpacing.lg)
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
.background(AppColors.surfaceCard)
|
|
|
.clipShape(RoundedRectangle(cornerRadius: AppSpacing.cardRadius, style: .continuous))
|
|
|
@@ -118,14 +294,60 @@ struct SettingsView: View {
|
|
|
RoundedRectangle(cornerRadius: AppSpacing.cardRadius, style: .continuous)
|
|
|
.stroke(AppColors.border, lineWidth: 1)
|
|
|
)
|
|
|
+ .shadow(color: Color.black.opacity(AppShadow.cardOpacity), radius: AppShadow.cardRadius, x: 0, y: AppShadow.cardY)
|
|
|
}
|
|
|
|
|
|
- private func labeledField<Content: View>(_ title: String, @ViewBuilder content: () -> Content) -> some View {
|
|
|
- VStack(alignment: .leading, spacing: 4) {
|
|
|
- Text(title)
|
|
|
- .font(AppTypography.caption)
|
|
|
- .foregroundStyle(AppColors.textTertiary)
|
|
|
- content()
|
|
|
+ @ViewBuilder
|
|
|
+ private func settingsRow(
|
|
|
+ icon: String,
|
|
|
+ title: String,
|
|
|
+ subtitle: String,
|
|
|
+ trailing: String? = nil,
|
|
|
+ showsChevron: Bool,
|
|
|
+ action: (() -> Void)? = nil
|
|
|
+ ) -> some View {
|
|
|
+ let rowContent = HStack(alignment: .center, spacing: AppSpacing.sm) {
|
|
|
+ Image(systemName: icon)
|
|
|
+ .font(.system(size: 13, weight: .semibold))
|
|
|
+ .foregroundStyle(AppColors.brand)
|
|
|
+ .frame(width: 28, height: 28)
|
|
|
+ .background(AppColors.brandSoft)
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
|
|
|
+
|
|
|
+ VStack(alignment: .leading, spacing: 2) {
|
|
|
+ Text(title)
|
|
|
+ .font(AppTypography.filterHeader)
|
|
|
+ .foregroundStyle(AppColors.textPrimary)
|
|
|
+ Text(subtitle)
|
|
|
+ .font(AppTypography.meta)
|
|
|
+ .foregroundStyle(AppColors.textSecondary)
|
|
|
+ .fixedSize(horizontal: false, vertical: true)
|
|
|
+ }
|
|
|
+
|
|
|
+ Spacer(minLength: AppSpacing.sm)
|
|
|
+
|
|
|
+ if let trailing {
|
|
|
+ Text(trailing)
|
|
|
+ .font(AppTypography.meta)
|
|
|
+ .foregroundStyle(AppColors.textTertiary)
|
|
|
+ }
|
|
|
+
|
|
|
+ if showsChevron {
|
|
|
+ Image(systemName: "chevron.right")
|
|
|
+ .font(.system(size: 11, weight: .semibold))
|
|
|
+ .foregroundStyle(AppColors.textTertiary)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .padding(.vertical, 6)
|
|
|
+ .contentShape(Rectangle())
|
|
|
+
|
|
|
+ if let action {
|
|
|
+ Button(action: action) {
|
|
|
+ rowContent
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+ } else {
|
|
|
+ rowContent
|
|
|
}
|
|
|
}
|
|
|
}
|