import SwiftUI import SwiftData import AuthenticationServices /// Household screen: sign in, create or join a household, see who is in it and /// leave. The content sync itself runs in `HouseholdSyncService`. struct HouseholdView: View { @Environment(\.modelContext) private var context @Environment(\.dismiss) private var dismiss @Query private var allSettings: [AppSettings] @StateObject private var auth = AuthService.shared @StateObject private var households = HouseholdService.shared @StateObject private var sync = HouseholdSyncService.shared @State private var householdName: String = "" @State private var joinCode: String = "" @State private var isWorking = false @State private var errorMessage: String? @State private var showJoinChoice = false @State private var showLeaveConfirm = false @State private var showPaywall = false private var settings: AppSettings? { allSettings.first } private var isPremium: Bool { settings?.isPremium ?? false } private var isInHousehold: Bool { HouseholdRuntime.householdId != nil } var body: some View { Form { if HouseholdRuntime.needsRelaunch { Section { Label("household_relaunch_needed", systemImage: "arrow.clockwise.circle.fill") .font(.mealMoodSmall) .foregroundColor(.mealMoodWarning) } .listRowBackground(Color.mealMoodSurface) } if !auth.isSignedIn { signInSection } else if isInHousehold { householdSection membersSection inviteSection leaveSection } else { createSection joinSection } if let errorMessage { Section { Text(errorMessage) .font(.mealMoodCaption) .foregroundColor(.mealMoodWarning) } .listRowBackground(Color.mealMoodSurface) } } .scrollContentBackground(.hidden) .background(Color.mealMoodBackground.ignoresSafeArea()) .tint(.mealMoodCoral) .navigationTitle("household_title") .navigationBarTitleDisplayMode(.inline) .disabled(isWorking) .onAppear { households.start() AnalyticsService.logScreenView("Household") } .sheet(isPresented: $showPaywall) { if let settings { NavigationStack { PremiumView(settings: settings, source: "household") } } } .confirmationDialog("household_join_choice_title", isPresented: $showJoinChoice, titleVisibility: .visible) { Button("household_join_choice_merge") { join(bringingLocalContent: true) } Button("household_join_choice_replace", role: .destructive) { join(bringingLocalContent: false) } Button("reset_cancel", role: .cancel) {} } message: { Text("household_join_choice_message") } .alert("household_leave_confirm_title", isPresented: $showLeaveConfirm) { Button("reset_cancel", role: .cancel) {} Button("household_leave_confirm_confirm", role: .destructive) { leave() } } message: { Text("household_leave_confirm_message") } } // MARK: - Sections private var signInSection: some View { Section { Text("household_intro") .font(.mealMoodCaption) .foregroundColor(.mealMoodTextSecondary) SignInWithAppleButton(.signIn) { request in auth.prepare(request: request) } onCompletion: { result in Task { do { try await auth.completeSignInWithApple(result) errorMessage = nil } catch { errorMessage = error.localizedDescription } } } .signInWithAppleButtonStyle(.black) .frame(height: 46) .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) } footer: { Text("household_sign_in_footer") } .listRowBackground(Color.mealMoodSurface) } private var createSection: some View { Section { TextField("household_name_placeholder", text: $householdName) Button { guard isPremium else { showPaywall = true AnalyticsService.logEvent("paywall_viewed", parameters: ["source": "household_create"]) return } create() } label: { HStack { Label("household_create", systemImage: "house.fill") if !isPremium { Spacer() Image(systemName: "star.circle.fill").foregroundColor(.mealMoodCoral) } } } .disabled(householdName.trimmingCharacters(in: .whitespaces).isEmpty) } header: { Text("household_create_section") } footer: { if isPremium { Text("household_create_footer") } else { Text("household_create_premium_footer") } } .listRowBackground(Color.mealMoodSurface) } private var joinSection: some View { Section { TextField("household_code_placeholder", text: $joinCode) .textInputAutocapitalization(.characters) .autocorrectionDisabled() Button { showJoinChoice = true } label: { Label("household_join", systemImage: "person.badge.plus") } .disabled(!HouseholdService.isPlausibleCode(joinCode)) } header: { Text("household_join_section") } footer: { Text("household_join_footer") } .listRowBackground(Color.mealMoodSurface) } private var householdSection: some View { Section { HStack { Label(households.household?.name ?? HouseholdRuntime.householdName ?? "", systemImage: "house.fill") Spacer() if sync.isSyncing { ProgressView() } else if let lastSyncedAt = sync.lastSyncedAt { Text(lastSyncedAt.formatted(date: .omitted, time: .shortened)) .font(.mealMoodCaption) .foregroundColor(.mealMoodTextSecondary) } } if let syncError = sync.lastError { Text(syncError) .font(.mealMoodCaption) .foregroundColor(.mealMoodWarning) } } header: { Text("household_section") } .listRowBackground(Color.mealMoodSurface) } private var membersSection: some View { Section { ForEach(households.members) { member in HStack { Label( member.displayName.isEmpty ? String(localized: "household_member_unnamed") : member.displayName, systemImage: member.role == "owner" ? "crown.fill" : "person.fill" ) Spacer() if member.id == auth.uid { Text("household_member_you") .font(.mealMoodCaption) .foregroundColor(.mealMoodTextSecondary) } } } } header: { Text("household_members_section") } .listRowBackground(Color.mealMoodSurface) } private var inviteSection: some View { Section { if let code = households.household?.inviteCode { HStack { Text(code) .font(.system(.title3, design: .monospaced)) .fontWeight(.bold) .foregroundColor(.mealMoodTextPrimary) Spacer() ShareLink(item: String(format: String(localized: "household_invite_share"), code)) { Image(systemName: "square.and.arrow.up") } } if let expiry = households.household?.inviteExpiresAt { Text(String(format: String(localized: "household_invite_expires"), expiry.formatted(date: .abbreviated, time: .omitted))) .font(.mealMoodCaption) .foregroundColor(.mealMoodTextSecondary) } } Button { regenerateCode() } label: { Label("household_invite_regenerate", systemImage: "arrow.triangle.2.circlepath") } } header: { Text("household_invite_section") } footer: { Text("household_invite_footer") } .listRowBackground(Color.mealMoodSurface) } private var leaveSection: some View { Section { Button(role: .destructive) { showLeaveConfirm = true } label: { Label("household_leave", systemImage: "rectangle.portrait.and.arrow.right") } } .listRowBackground(Color.mealMoodSurface) } // MARK: - Actions private func create() { run { let name = householdName.trimmingCharacters(in: .whitespaces) _ = try await households.createHousehold(name: name, displayName: auth.displayName ?? "") // Everything already planned on this device becomes the household's // starting point. await sync.seedNewHousehold() } } private func join(bringingLocalContent: Bool) { run { _ = try await households.join(code: joinCode, displayName: auth.displayName ?? "") if !bringingLocalContent { sync.replaceLocalContent(context: context) } joinCode = "" } } private func leave() { run { try await households.leaveHousehold() } } private func regenerateCode() { run { try await households.regenerateInviteCode() } } private func run(_ operation: @escaping () async throws -> Void) { isWorking = true errorMessage = nil Task { do { try await operation() } catch { errorMessage = error.localizedDescription CrashlyticsService.record(error, context: "household_action") } isWorking = false } } }