- 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>
245 lines
7.7 KiB
Swift
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()
|
|
}
|