Files
honeyDueKMP/iosApp/iosApp/Profile/ProfileTabView.swift
T
Trey T 44f712f345
Android UI Tests / ui-tests (push) Has been cancelled
iOS: add biometric / PIN app lock (CL-5)
Gates an authenticated session behind Face ID / Touch ID with a 6-digit PIN
fallback. AppLockManager (Keychain-backed enabled flag + SHA-256 PIN hash, all
via KeychainHelper) arms the lock on scenePhase .background; RootView overlays
LockScreenView above all auth states when locked (the lock screen also serves
as the app-switcher privacy cover). AppLockSettingsView (Profile › App Lock)
toggles it, sets/changes the PIN, and toggles biometrics. NSFaceIDUsageDescription
added. Fully bypassed under UI tests (AppLockManager.isEnabled is false when
UITestRuntime is enabled) so the XCUITest suite is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:25:40 -05:00

372 lines
16 KiB
Swift

import SwiftUI
import ComposeApp
struct ProfileTabView: View {
@EnvironmentObject private var themeManager: ThemeManager
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@StateObject private var storeKitManager = StoreKitManager.shared
@State private var showingProfileEdit = false
@State private var showingLogoutAlert = false
@State private var showingThemeSelection = false
@State private var showUpgradePrompt = false
@State private var showRestoreSuccess = false
@State private var showingNotificationPreferences = false
@State private var showingAnimationTesting = false
@StateObject private var animationPreference = AnimationPreference.shared
var body: some View {
List {
// Section {
// HStack {
// Image(systemName: "person.circle.fill")
// .resizable()
// .frame(width: 60, height: 60)
// .foregroundColor(Color.appPrimary)
//
// VStack(alignment: .leading, spacing: 4) {
// Text("User Profile")
// .font(.headline)
// .foregroundColor(Color.appTextPrimary)
//
// Text("Manage your account")
// .font(.caption)
// .foregroundColor(Color.appTextSecondary)
// }
// }
// .padding(.vertical, 8)
// .sectionBackground()
// }
Section(L10n.Profile.account) {
Button(action: {
showingProfileEdit = true
}) {
Label(L10n.Profile.editProfile, systemImage: "person.crop.circle")
.foregroundColor(Color.appTextPrimary)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Profile.editProfileButton)
Button(action: {
showingNotificationPreferences = true
}) {
HStack {
Label(L10n.Profile.notifications, systemImage: "bell")
.foregroundColor(Color.appTextPrimary)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
.a11yDecorative()
}
}
.accessibilityLabel(L10n.Profile.notifications)
NavigationLink(destination: Text(L10n.Profile.privacy)) {
Label(L10n.Profile.privacy, systemImage: "lock.shield")
}
NavigationLink(destination: AppLockSettingsView()) {
Label("App Lock", systemImage: "lock.fill") // i18n-todo: add L10n.Profile.appLock key
.foregroundColor(Color.appTextPrimary)
}
}
.sectionBackground()
// Subscription Section - Only show if limitations are enabled on backend
if let subscription = subscriptionCache.currentSubscription, subscription.limitationsEnabled {
Section(L10n.Profile.subscription) {
// Trial banner
if subscription.trialActive, let trialEnd = subscription.trialEnd {
HStack(spacing: 8) {
Image(systemName: "clock.fill")
.foregroundColor(Color.appAccent)
Text("Free trial ends \(DateUtils.formatDateMedium(trialEnd))")
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(Color.appAccent)
}
.padding(.vertical, 6)
}
HStack {
Image(systemName: "crown.fill")
.foregroundColor(subscriptionCache.currentTier == "pro" ? Color.appAccent : Color.appTextSecondary)
VStack(alignment: .leading, spacing: 4) {
Text(subscriptionCache.currentTier == "pro" ? L10n.Profile.proPlan : L10n.Profile.freePlan)
.font(.headline)
.foregroundColor(Color.appTextPrimary)
if subscriptionCache.currentTier == "pro",
let expiresAt = subscription.expiresAt {
Text("\(L10n.Profile.activeUntil) \(DateUtils.formatDateMedium(expiresAt))")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
} else if !subscription.trialActive {
Text(L10n.Profile.limitedFeatures)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
}
}
.padding(.vertical, 4)
if subscriptionCache.currentTier != "pro" {
// Upgrade Benefits List
VStack(alignment: .leading, spacing: 8) {
Text(L10n.Profile.unlockPremiumFeatures)
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(Color.appPrimary)
.padding(.top, 4)
UpgradeBenefitRow(icon: "building.2.fill", text: L10n.Profile.benefitUnlimitedProperties)
UpgradeBenefitRow(icon: "doc.fill", text: L10n.Profile.benefitDocumentVault)
UpgradeBenefitRow(icon: "person.2.fill", text: L10n.Profile.benefitResidenceSharing)
UpgradeBenefitRow(icon: "square.and.arrow.up.fill", text: L10n.Profile.benefitContractorSharing)
UpgradeBenefitRow(icon: "bell.badge.fill", text: L10n.Profile.benefitActionableNotifications)
UpgradeBenefitRow(icon: "square.grid.2x2.fill", text: L10n.Profile.benefitWidgets)
}
.padding(.vertical, 4)
Button(action: { showUpgradePrompt = true }) {
Label(L10n.Profile.upgradeToPro, systemImage: "arrow.up.circle.fill")
.foregroundColor(Color.appPrimary)
}
} else {
// Subscription management varies by source platform
if subscription.subscriptionSource == "stripe" {
// Web/Stripe subscription - direct to web portal
Button(action: {
if let url = URL(string: "https://honeyDue.treytartt.com/settings") {
UIApplication.shared.open(url)
}
}) {
Label("Manage at honeyDue.treytartt.com", systemImage: "globe")
.foregroundColor(Color.appTextPrimary)
}
} else if subscription.subscriptionSource == "android" {
// Android subscription - informational only
HStack(spacing: 8) {
Image(systemName: "info.circle")
.foregroundColor(Color.appTextSecondary)
Text("Manage your subscription on your Android device")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
.padding(.vertical, 4)
} else {
// iOS subscription (source is "ios" or nil) - normal StoreKit management
Button(action: {
if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
UIApplication.shared.open(url)
}
}) {
Label(L10n.Profile.manageSubscription, systemImage: "gearshape.fill")
.foregroundColor(Color.appTextPrimary)
}
}
}
Button(action: {
Task {
await storeKitManager.restorePurchases()
showRestoreSuccess = !storeKitManager.purchasedProductIDs.isEmpty
}
}) {
Label(L10n.Profile.restorePurchases, systemImage: "arrow.clockwise")
.foregroundColor(Color.appTextSecondary)
}
}
.sectionBackground()
}
Section(L10n.Profile.appearance) {
Button(action: {
showingThemeSelection = true
}) {
HStack {
Label(L10n.Profile.theme, systemImage: "paintpalette")
.foregroundColor(Color.appTextPrimary)
Spacer()
Text(themeManager.currentTheme.displayName)
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
.a11yDecorative()
}
}
.accessibilityLabel("\(L10n.Profile.theme), \(themeManager.currentTheme.displayName)")
Button(action: {
showingAnimationTesting = true
}) {
HStack {
Label("Completion Animation", systemImage: "sparkles.rectangle.stack")
.foregroundColor(Color.appTextPrimary)
Spacer()
Text(LocalizedStringKey(animationPreference.selectedAnimation.rawValue))
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
.a11yDecorative()
}
}
.accessibilityLabel(String(format: String(localized: "Completion Animation, %@"), animationPreference.selectedAnimation.displayName))
}
.sectionBackground()
Section {
Toggle(isOn: Binding(
get: { !AnalyticsManager.shared.isOptedOut },
set: { enabled in
if enabled {
AnalyticsManager.shared.optIn()
} else {
AnalyticsManager.shared.optOut()
}
}
)) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Share Analytics")
.font(.body)
.foregroundStyle(Color.appTextPrimary)
Text("Help improve honeyDue by sharing anonymous usage data")
.font(.caption)
.foregroundStyle(Color.appTextSecondary)
}
} icon: {
Image(systemName: "chart.bar.xaxis")
.foregroundColor(Color.appPrimary)
}
}
.tint(Color.appPrimary)
} header: {
Text("Privacy")
} footer: {
Text("No personal data is collected. Analytics are fully anonymous.")
}
.sectionBackground()
Section(L10n.Profile.support) {
Button(action: {
sendSupportEmail()
}) {
HStack {
Label(L10n.Profile.contactSupport, systemImage: "envelope")
.foregroundColor(Color.appTextPrimary)
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
.a11yDecorative()
}
}
.accessibilityLabel(L10n.Profile.contactSupport)
.accessibilityHint("Opens email to contact support")
}
.sectionBackground()
Section {
Button(action: {
showingLogoutAlert = true
}) {
Label(L10n.Profile.logout, systemImage: "rectangle.portrait.and.arrow.right")
.foregroundColor(Color.appError)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Profile.logoutButton)
.sectionBackground()
}
Section {
VStack(alignment: .leading, spacing: 4) {
Text(L10n.Profile.appName)
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(Color.appTextPrimary)
Text(L10n.Profile.version)
.font(.caption2)
.foregroundColor(Color.appTextSecondary)
}
.sectionBackground()
}
}
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle(L10n.Profile.title)
.sheet(isPresented: $showingProfileEdit) {
ProfileView()
}
.sheet(isPresented: $showingThemeSelection) {
ThemeSelectionView()
}
.sheet(isPresented: $showingNotificationPreferences) {
NotificationPreferencesView()
}
.sheet(isPresented: $showingAnimationTesting) {
AnimationTestingView()
}
.alert(L10n.Profile.logout, isPresented: $showingLogoutAlert) {
Button(L10n.Common.cancel, role: .cancel) { }
Button(L10n.Profile.logout, role: .destructive) {
AuthenticationManager.shared.logout()
}
} message: {
Text(L10n.Profile.logoutConfirm)
}
.sheet(isPresented: $showUpgradePrompt) {
UpgradePromptView(triggerKey: "user_profile", isPresented: $showUpgradePrompt)
}
.alert(L10n.Profile.purchasesRestored, isPresented: $showRestoreSuccess) {
Button(L10n.Common.ok, role: .cancel) { }
} message: {
Text(L10n.Profile.purchasesRestoredMessage)
}
.onAppear {
AnalyticsManager.shared.trackScreen(.settings)
}
}
private func sendSupportEmail() {
let email = "honeydue@treymail.com" // i18n-ignore: support email address (non-UI)
let subject = String(localized: "honeyDue Support Request")
let urlString = "mailto:\(email)?subject=\(subject.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? subject)" // i18n-ignore: mailto URL construction (non-UI)
if let url = URL(string: urlString) {
UIApplication.shared.open(url)
}
}
}
// MARK: - Upgrade Benefit Row
private struct UpgradeBenefitRow: View {
let icon: String
let text: String
var body: some View {
HStack(spacing: 8) {
Image(systemName: icon)
.font(.caption)
.foregroundColor(Color.appPrimary)
.frame(width: 18)
Text(text)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
}
}