Files
FrenoCorp/Lendair/Views/FamilyPlanView.swift
Michael Freno 57a460761a FRE-4665: Implement Phase 3 AI training plans and premium features
- Models: TrainingPlan, Race, FamilyPlan, BeginnerMode, CommunityEvent
- Services: 5 service layers with protocol-based architecture
- ViewModels: 5 view models with @MainActor ObservableObject pattern
- Views: 10 SwiftUI views for all Phase 3 features
- Updated README with full Phase 3 documentation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-03 15:21:01 -04:00

245 lines
7.7 KiB
Swift

import SwiftUI
struct FamilyPlanView: View {
@StateObject private var viewModel = FamilyPlanViewModel()
@State private var showingInviteSheet = false
@State private var selectedMetric: LeaderboardMetric = .distance
var body: some View {
NavigationView {
Group {
if viewModel.isLoading && viewModel.familyPlan == nil {
loadingView
} else if let plan = viewModel.familyPlan {
planContent(plan)
} else {
emptyStateView
}
}
.navigationTitle("Family Plan")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if let plan = viewModel.familyPlan, plan.isActive {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showingInviteSheet = true
} label: {
Text("Invite")
}
}
}
}
.sheet(isPresented: $showingInviteSheet) {
InviteMemberSheet()
}
}
.onAppear {
Task {
await viewModel.fetchFamilyPlan()
await viewModel.fetchLeaderboard()
}
}
}
@ViewBuilder
private func planContent(_ plan: FamilyPlan) -> some View {
List {
Section("Plan Status") {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(plan.ownerName)
.font(.headline)
Text("\(plan.members.count)/\(plan.maxMembers) members")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Text(plan.subscriptionStatus.displayName)
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(plan.subscriptionStatus.color.opacity(0.2))
.cornerRadius(6)
.foregroundColor(plan.subscriptionStatus.color)
}
if let renewalDate = plan.renewalDate {
HStack {
Text("Renews")
.foregroundColor(.secondary)
Spacer()
Text(formatDate(renewalDate))
.fontWeight(.medium)
}
.font(.subheadline)
}
}
Section("Members") {
ForEach(plan.members) { member in
MemberRowView(member: member)
}
}
Section("Leaderboard") {
Picker("Metric", selection: $selectedMetric) {
ForEach(viewModel.metrics, id: \.self) { metric in
Text(metric.displayName).tag(metric)
}
}
.onChange(of: selectedMetric) { newValue in
viewModel.selectedMetric = newValue
Task { await viewModel.fetchLeaderboard() }
}
if viewModel.leaderboard.isEmpty {
Text("No data yet")
.foregroundColor(.secondary)
.font(.subheadline)
} else {
ForEach(viewModel.leaderboard) { entry in
LeaderboardRow(entry: entry, metric: selectedMetric)
}
}
}
}
.listStyle(.insetGrouped)
}
private var loadingView: some View {
VStack(spacing: 16) {
ProgressView()
Text("Loading Family Plan...")
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(.vertical, 60)
}
private var emptyStateView: some View {
VStack(spacing: 16) {
Image(systemName: "person.3.fill")
.font(.system(size: 64))
.foregroundColor(.secondary)
Text("No Family Plan")
.font(.title2)
.fontWeight(.semibold)
Text("Create a family plan to share your subscription with up to 6 members.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
.padding(.vertical, 60)
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter.string(from: date)
}
}
struct InviteMemberSheet: View {
@Environment(\.dismiss) var dismiss
@State private var email = ""
@State private var name = ""
var body: some View {
NavigationView {
Form {
Section("New Member") {
TextField("Name", text: $name)
TextField("Email", text: $email)
.keyboardType(.emailAddress)
.autocapitalization(.none)
}
}
.navigationTitle("Invite Member")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Send Invite") {
dismiss()
}
.disabled(email.isEmpty || name.isEmpty)
}
}
}
}
}
struct MemberRowView: View {
let member: FamilyMember
var body: some View {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(member.isPrimary ? Color.blue.opacity(0.2) : Color.secondary.opacity(0.15))
.frame(width: 40, height: 40)
Image(systemName: member.role.icon)
.font(.system(size: 18))
.foregroundColor(member.isPrimary ? .blue : .secondary)
}
VStack(alignment: .leading, spacing: 2) {
Text(member.name)
.font(.subheadline)
.fontWeight(.medium)
Text(member.email)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("\(Int(member.weeklyDistanceKm)) km")
.font(.caption)
.fontWeight(.medium)
Text("this week")
.font(.caption2)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 4)
}
}
struct LeaderboardRow: View {
let entry: FamilyLeaderboardEntry
let metric: LeaderboardMetric
var body: some View {
HStack(spacing: 12) {
Text("#\(entry.rank)")
.font(.headline)
.frame(width: 30)
Circle()
.fill(Color.secondary.opacity(0.2))
.frame(width: 32, height: 32)
Text(entry.memberName)
.font(.subheadline)
Spacer()
Text("\(Int(entry.value))\(metric.unit)")
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(.secondary)
}
}
}
#Preview {
FamilyPlanView()
}