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>
This commit is contained in:
244
Lendair/Views/FamilyPlanView.swift
Normal file
244
Lendair/Views/FamilyPlanView.swift
Normal file
@@ -0,0 +1,244 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user