44f712f345
Android UI Tests / ui-tests (push) Has been cancelled
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>
372 lines
16 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|