fix(ios): address Pass B warnings from code audit

This commit is contained in:
null 2026-06-20 22:58:11 -05:00
parent cb54ed3079
commit 59c239694a
6 changed files with 133 additions and 177 deletions

View File

@ -85,9 +85,6 @@ struct DateMatchView: View {
.background(Color.closerBackground)
.navigationTitle("Date Ideas")
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(isPresented: .constant(false)) {
DateBuilderView()
}
}
private func swipe(_ direction: SwipeDirection) {

View File

@ -2,22 +2,28 @@ import SwiftUI
struct ContentView: View {
@EnvironmentObject var appState: AppState
var body: some View {
Group {
switch appState.authState {
case .loading:
LoadingView(message: "Getting ready...")
case .unauthenticated:
OnboardingFlow()
case .authenticated(_, let isAnonymous):
if isAnonymous {
CreateProfileView()
} else if appState.currentUser?.coupleId == nil {
PairPromptView()
} else {
MainTabView()
}
NavigationStack {
rootView
}
.environmentObject(appState)
}
@ViewBuilder
private var rootView: some View {
switch appState.authState {
case .loading:
LoadingView(message: "Getting ready...")
case .unauthenticated:
OnboardingFlow()
case .authenticated(_, let isAnonymous):
if isAnonymous {
CreateProfileView()
} else if appState.currentUser?.coupleId == nil {
PairPromptView()
} else {
MainTabView()
}
}
}
@ -28,52 +34,42 @@ struct ContentView: View {
struct MainTabView: View {
@EnvironmentObject var appState: AppState
@State private var selectedTab: Tab = .home
enum Tab: Hashable {
case home, dailyQuestion, play, questionPacks, settings
}
var body: some View {
TabView(selection: $selectedTab) {
NavigationStack {
HomeView()
}
.tabItem {
Label("Home", systemImage: selectedTab == .home ? "house.fill" : "house")
}
.tag(Tab.home)
NavigationStack {
DailyQuestionView()
}
.tabItem {
Label("Today", systemImage: selectedTab == .dailyQuestion ? "heart.fill" : "heart")
}
.tag(Tab.dailyQuestion)
NavigationStack {
PlayHubView()
}
.tabItem {
Label("Play", systemImage: selectedTab == .play ? "play.fill" : "play")
}
.tag(Tab.play)
NavigationStack {
QuestionPackLibraryView()
}
.tabItem {
Label("Packs", systemImage: selectedTab == .questionPacks ? "star.fill" : "star")
}
.tag(Tab.questionPacks)
NavigationStack {
SettingsView()
}
.tabItem {
Label("Settings", systemImage: selectedTab == .settings ? "gearshape.fill" : "gearshape")
}
.tag(Tab.settings)
HomeView()
.tabItem {
Label("Home", systemImage: selectedTab == .home ? "house.fill" : "house")
}
.tag(Tab.home)
DailyQuestionView()
.tabItem {
Label("Today", systemImage: selectedTab == .dailyQuestion ? "heart.fill" : "heart")
}
.tag(Tab.dailyQuestion)
PlayHubView()
.tabItem {
Label("Play", systemImage: selectedTab == .play ? "play.fill" : "play")
}
.tag(Tab.play)
QuestionPackLibraryView()
.tabItem {
Label("Packs", systemImage: selectedTab == .questionPacks ? "star.fill" : "star")
}
.tag(Tab.questionPacks)
SettingsView()
.tabItem {
Label("Settings", systemImage: selectedTab == .settings ? "gearshape.fill" : "gearshape")
}
.tag(Tab.settings)
}
.tint(.closerPrimary)
}
@ -84,17 +80,15 @@ struct MainTabView: View {
struct OnboardingFlow: View {
@State private var showLogin = false
@State private var showSignUp = false
var body: some View {
NavigationStack {
OnboardingView(showLogin: $showLogin, showSignUp: $showSignUp)
.navigationDestination(isPresented: $showLogin) {
LoginView()
}
.navigationDestination(isPresented: $showSignUp) {
SignUpView()
}
}
OnboardingView(showLogin: $showLogin, showSignUp: $showSignUp)
.navigationDestination(isPresented: $showLogin) {
LoginView()
}
.navigationDestination(isPresented: $showSignUp) {
SignUpView()
}
}
}

View File

@ -6,45 +6,45 @@ struct PairPromptView: View {
@State private var showCreateInvite = false
@State private var showAcceptInvite = false
@State private var showEmailInvite = false
var body: some View {
NavigationStack {
VStack(spacing: CloserSpacing.xxl) {
Spacer()
Image(systemName: "link.circle.fill")
.font(.system(size: 72))
.foregroundColor(.closerPrimary)
Text("Connect with Your Partner")
.font(CloserFont.title1)
.foregroundColor(.closerText)
.multilineTextAlignment(.center)
Text("Create an invite code for your partner to join, or enter their code to connect.")
.font(CloserFont.callout)
.foregroundColor(.closerTextSecondary)
.multilineTextAlignment(.center)
.closerPadding()
VStack(spacing: CloserSpacing.lg) {
Button(action: { showCreateInvite = true }) {
Label("Create Invite Code", systemImage: "plus.circle.fill")
}
.buttonStyle(PrimaryButtonStyle())
Button(action: { showAcceptInvite = true }) {
Label("Enter Partner's Code", systemImage: "key.fill")
}
.buttonStyle(SecondaryButtonStyle())
Button(action: { showEmailInvite = true }) {
Label("Invite by Email", systemImage: "envelope.fill")
}
.buttonStyle(SecondaryButtonStyle())
}
.closerPadding()
Spacer()
}
.background(Color.closerBackground)
@ -68,7 +68,7 @@ struct CreateInviteView: View {
@State private var inviteCode = ""
@State private var isLoading = false
@State private var errorMessage: String?
var body: some View {
ScrollView {
VStack(spacing: CloserSpacing.xxl) {
@ -85,7 +85,7 @@ struct CreateInviteView: View {
.multilineTextAlignment(.center)
}
.padding(.top, CloserSpacing.xxl)
if !inviteCode.isEmpty {
VStack(spacing: CloserSpacing.md) {
Text(inviteCode)
@ -95,13 +95,13 @@ struct CreateInviteView: View {
.padding(CloserSpacing.xxl)
.background(Color.closerSurface)
.cornerRadius(CloserRadius.large)
Button(action: copyCode) {
Label("Copy Code", systemImage: "doc.on.doc")
}
.buttonStyle(SecondaryButtonStyle())
.frame(maxWidth: 200)
Button(action: shareCode) {
Label("Share", systemImage: "square.and.arrow.up")
}
@ -116,13 +116,13 @@ struct CreateInviteView: View {
}
.buttonStyle(PrimaryButtonStyle())
}
if let error = errorMessage {
Text(error)
.font(CloserFont.caption)
.foregroundColor(.closerDanger)
}
NavigationLink {
InviteConfirmView()
} label: {
@ -136,17 +136,23 @@ struct CreateInviteView: View {
.background(Color.closerBackground)
.navigationBarTitleDisplayMode(.inline)
}
private func generateInvite() {
isLoading = true
errorMessage = nil
Task {
do {
// TODO: Move invite creation to createInviteCallable Cloud Function.
// 6-character codes are enumerable; direct client writes to the invites
// collection expose them to enumeration. The iOS side should call
// createInviteCallable() once it exists in functions/src/invites/ and
// return the generated code instead of writing here. Leaving direct
// Firestore write as a placeholder until that function is implemented.
let userId = try FirestoreService.shared.userId()
let code = generateSixCharCode()
self.inviteCode = code
let invite = Invite(
id: code,
code: code,
@ -159,7 +165,7 @@ struct CreateInviteView: View {
acceptedAt: nil,
acceptedByUserId: nil
)
let inviteRef = FirestoreService.shared.inviteDocument(code)
try await FirestoreService.shared.setDocument(invite, at: inviteRef, merge: false)
} catch {
@ -168,11 +174,11 @@ struct CreateInviteView: View {
isLoading = false
}
}
private func copyCode() {
UIPasteboard.general.string = inviteCode
}
private func shareCode() {
let av = UIActivityViewController(activityItems: [inviteCode], applicationActivities: nil)
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
@ -181,7 +187,7 @@ struct CreateInviteView: View {
root.present(av, animated: true)
}
}
private func generateSixCharCode() -> String {
let chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // Avoid ambiguous 0/O, 1/I
return String((0..<6).map { _ in chars.randomElement()! })
@ -195,7 +201,7 @@ struct AcceptInviteView: View {
@State private var code = ""
@State private var isLoading = false
@State private var errorMessage: String?
var body: some View {
ScrollView {
VStack(spacing: CloserSpacing.xxl) {
@ -212,7 +218,7 @@ struct AcceptInviteView: View {
.multilineTextAlignment(.center)
}
.padding(.top, CloserSpacing.xxl)
VStack(spacing: CloserSpacing.md) {
TextField("XXXXXX", text: $code)
.font(.system(size: 32, weight: .bold, design: .monospaced))
@ -228,13 +234,13 @@ struct AcceptInviteView: View {
code = String(newValue.prefix(6))
}
}
if let error = errorMessage {
Text(error)
.font(CloserFont.caption)
.foregroundColor(.closerDanger)
}
Button(action: acceptInvite) {
if isLoading {
ProgressView().tint(.white)
@ -251,12 +257,12 @@ struct AcceptInviteView: View {
.background(Color.closerBackground)
.navigationBarTitleDisplayMode(.inline)
}
private func acceptInvite() {
guard code.count == 6 else { return }
isLoading = true
errorMessage = nil
Task {
do {
let coupleId = try await FirestoreService.shared.acceptInviteCallable(code: code)
@ -277,7 +283,7 @@ struct EmailInviteView: View {
@State private var isLoading = false
@State private var errorMessage: String?
@State private var successMessage: String?
var body: some View {
ScrollView {
VStack(spacing: CloserSpacing.xxl) {
@ -293,7 +299,7 @@ struct EmailInviteView: View {
.foregroundColor(.closerTextSecondary)
}
.padding(.top, CloserSpacing.xxl)
VStack(spacing: CloserSpacing.md) {
TextField("partner@example.com", text: $email)
.textContentType(.emailAddress)
@ -303,7 +309,7 @@ struct EmailInviteView: View {
.padding()
.background(Color.closerSurface)
.cornerRadius(CloserRadius.medium)
if let success = successMessage {
Text(success)
.font(CloserFont.callout)
@ -314,7 +320,7 @@ struct EmailInviteView: View {
.font(CloserFont.caption)
.foregroundColor(.closerDanger)
}
Button(action: sendInvite) {
if isLoading {
ProgressView().tint(.white)
@ -331,19 +337,22 @@ struct EmailInviteView: View {
.background(Color.closerBackground)
.navigationBarTitleDisplayMode(.inline)
}
private func sendInvite() {
// Email invite sends via Cloud Function or mail service
// For MVP, generate invite code and share system share sheet
guard !email.isEmpty else { return }
isLoading = true
errorMessage = nil
Task {
do {
// TODO: Use createInviteCallable Cloud Function instead of direct
// client writes to the invites collection. Leaving direct Firestore
// write as a placeholder until createInviteCallable is implemented.
let userId = try FirestoreService.shared.userId()
let code = generateSixCharCode()
let invite = Invite(
id: code,
code: code,
@ -356,7 +365,7 @@ struct EmailInviteView: View {
acceptedAt: nil,
acceptedByUserId: nil
)
try await FirestoreService.shared.setDocument(invite, at: FirestoreService.shared.inviteDocument(code), merge: false)
successMessage = "Invitation sent! Share this code: \(code)"
} catch {
@ -365,7 +374,7 @@ struct EmailInviteView: View {
isLoading = false
}
}
private func generateSixCharCode() -> String {
let chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
return String((0..<6).map { _ in chars.randomElement()! })
@ -376,22 +385,22 @@ struct EmailInviteView: View {
struct InviteConfirmView: View {
@EnvironmentObject var appState: AppState
var body: some View {
VStack(spacing: CloserSpacing.xxl) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 72))
.foregroundColor(.closerSuccess)
Text("Connected!")
.font(CloserFont.title1)
.foregroundColor(.closerText)
Text("You and your partner are now connected. Start exploring questions and games together.")
.font(CloserFont.callout)
.foregroundColor(.closerTextSecondary)
.multilineTextAlignment(.center)
Button("Let's Go!") {
Task { await appState.refreshData() }
}
@ -411,21 +420,21 @@ struct RecoveryView: View {
Image(systemName: "key.icloud.fill")
.font(.system(size: 48))
.foregroundColor(.closerPrimary)
Text("Unlock Answers")
.font(CloserFont.title1)
.foregroundColor(.closerText)
Text("Enter your recovery phrase to restore access to encrypted answers. This is a 12-word phrase generated when E2EE was first enabled.")
.font(CloserFont.callout)
.foregroundColor(.closerTextSecondary)
.multilineTextAlignment(.center)
Text("Recovery phrase setup is available when E2EE is fully implemented.")
.font(CloserFont.footnote)
.foregroundColor(.closerTextSecondary)
.italic()
Spacer()
}
.closerPadding()
@ -442,21 +451,21 @@ struct EncryptionUpgradeView: View {
Image(systemName: "lock.shield.fill")
.font(.system(size: 48))
.foregroundColor(.closerPrimary)
Text("Secure Your Answers")
.font(CloserFont.title1)
.foregroundColor(.closerText)
Text("End-to-end encryption ensures your answers are only visible to you and your partner.")
.font(CloserFont.callout)
.foregroundColor(.closerTextSecondary)
.multilineTextAlignment(.center)
Text("E2EE upgrade is available when the full encryption layer is implemented.")
.font(CloserFont.footnote)
.foregroundColor(.closerTextSecondary)
.italic()
Spacer()
}
.closerPadding()

View File

@ -263,24 +263,8 @@ struct ThisOrThatView: View {
.font(CloserFont.title3)
.foregroundColor(.closerText)
ForEach(pairs[currentPair].0, pairs[currentPair].1, id: \.self) { option in
Button(action: { choose(option) }) {
Text(option)
.font(CloserFont.body)
.foregroundColor(.closerText)
.frame(maxWidth: .infinity)
.padding()
.background(Color.closerSurface)
.cornerRadius(CloserRadius.large)
.overlay(RoundedRectangle(cornerRadius: CloserRadius.large).stroke(Color.closerDivider))
}
}
Text("or")
.font(CloserFont.body)
.foregroundColor(.closerTextSecondary)
ForEach(pairs[currentPair].1, pairs[currentPair].0, id: \.self) { option in
let options = [pairs[currentPair].0, pairs[currentPair].1]
ForEach(options, id: \.self) { option in
Button(action: { choose(option) }) {
Text(option)
.font(CloserFont.body)

View File

@ -552,23 +552,25 @@ struct QuestionThreadView: View {
// MARK: - Sample Data
// Fallback sample data. IDs must match real Android seed entries so that
// navigation to category/pack/question threads references actual data.
let bundledQuestions: [Question] = [
Question(id: "q1", text: "What made you smile today?", type: "text", options: nil, scaleMin: nil, scaleMax: nil, categoryId: nil, packId: nil),
Question(id: "q2", text: "What's one thing you appreciate about your partner?", type: "text", options: nil, scaleMin: nil, scaleMax: nil, categoryId: nil, packId: nil),
Question(id: "q3", text: "How connected do you feel today?", type: "scale", options: nil, scaleMin: 1, scaleMax: 10, categoryId: nil, packId: nil),
Question(id: "q4", text: "What's your ideal weekend activity together?", type: "multiple_choice", options: ["Relax at home", "Outdoor adventure", "Date night out", "Try something new"], scaleMin: nil, scaleMax: nil, categoryId: nil, packId: nil),
Question(id: "communication_001", text: "What is one small thing I do that helps you feel heard?", type: "text", options: nil, scaleMin: nil, scaleMax: nil, categoryId: "communication", packId: nil),
Question(id: "communication_002", text: "When do you feel it is easiest to talk to me?", type: "text", options: nil, scaleMin: nil, scaleMax: nil, categoryId: "communication", packId: nil),
Question(id: "communication_151", text: "When you are upset, what helps most first?", type: "single_choice", options: ["Comfort", "Space", "Advice", "Distraction"], scaleMin: nil, scaleMax: nil, categoryId: "communication", packId: nil),
Question(id: "date_night_001", text: "What would make a simple dinner feel fun for both of us?", type: "text", options: nil, scaleMin: nil, scaleMax: nil, categoryId: "date_night", packId: nil),
]
let sampleQuestions: [Question] = [
Question(id: "s1", text: "What's a dream you'd like to pursue together?", type: "text", options: nil, scaleMin: nil, scaleMax: nil, categoryId: nil, packId: nil),
Question(id: "s2", text: "How do you feel loved most?", type: "multiple_choice", options: ["Words of affirmation", "Quality time", "Physical touch", "Acts of service", "Gifts"], scaleMin: nil, scaleMax: nil, categoryId: nil, packId: nil),
Question(id: "s3", text: "Rate your communication today", type: "scale", options: nil, scaleMin: 1, scaleMax: 10, categoryId: nil, packId: nil),
Question(id: "s4", text: "What's one new thing you want to try as a couple?", type: "text", options: nil, scaleMin: nil, scaleMax: nil, categoryId: nil, packId: nil),
Question(id: "fun_001", text: "What is one thing we do together that always makes you smile?", type: "text", options: nil, scaleMin: nil, scaleMax: nil, categoryId: "fun", packId: nil),
Question(id: "fun_002", text: "What is one silly memory of us that you still love?", type: "text", options: nil, scaleMin: nil, scaleMax: nil, categoryId: "fun", packId: nil),
Question(id: "values_001", text: "What is one value that quietly guides your life?", type: "text", options: nil, scaleMin: nil, scaleMax: nil, categoryId: "values", packId: nil),
Question(id: "trust_001", text: "What makes it easy for you to trust someone?", type: "text", options: nil, scaleMin: nil, scaleMax: nil, categoryId: "trust", packId: nil),
]
let samplePacks: [QuestionPack] = [
QuestionPack(id: "p1", name: "Getting Closer", description: "Deepen your connection", categories: nil, isPremium: false),
QuestionPack(id: "p2", name: "Fun & Playful", description: "Lighthearted questions", categories: nil, isPremium: false),
QuestionPack(id: "p3", name: "Intimacy", description: "Build emotional intimacy", categories: nil, isPremium: true),
QuestionPack(id: "p4", name: "Future Together", description: "Plan your future", categories: nil, isPremium: true),
QuestionPack(id: "communication", name: "Communication", description: "Questions about listening, expressing needs, understanding each other, and talking clearly.", categories: nil, isPremium: false),
QuestionPack(id: "fun", name: "Fun & Playful", description: "Lighthearted questions about playfulness, laughter, and shared activities.", categories: nil, isPremium: false),
QuestionPack(id: "date_night", name: "Date Night", description: "Questions designed for dates, meals, walks, or quiet time together.", categories: nil, isPremium: true),
QuestionPack(id: "trust", name: "Trust", description: "Questions about trust, repair, and rebuilding.", categories: nil, isPremium: true),
]

View File

@ -376,33 +376,3 @@ struct WheelHistoryView: View {
}
}
// MARK: - Navigation helper
extension Binding where Value == String? {
func unwrapped<T: Hashable>(_ defaultValue: T) -> Binding<T> where T == String {
Binding<T>(
get: { self.wrappedValue as? T ?? defaultValue },
set: { self.wrappedValue = $0 as? String }
)
}
}
extension View {
func navigationDestination<T: Hashable>(item: Binding<T?>, @ViewBuilder destination: @escaping (T) -> some View) -> some View {
background(
NavigationLink(
isActive: Binding(
get: { item.wrappedValue != nil },
set: { if !$0 { item.wrappedValue = nil } }
),
destination: {
if let value = item.wrappedValue {
destination(value)
}
},
label: EmptyView.init
)
.hidden()
)
}
}