feat(polls): implement group trip polling MVP
Add complete group trip polling feature allowing users to share trips
with friends for voting using Borda count scoring.
New components:
- TripPoll and PollVote domain models with share codes and rankings
- LocalTripPoll and LocalPollVote SwiftData models for persistence
- CKTripPoll and CKPollVote CloudKit record wrappers
- PollService actor for CloudKit CRUD operations and subscriptions
- PollCreation/Detail/Voting views and view models
- Deep link handling for sportstime://poll/{code} URLs
- Debug Pro status override toggle in Settings
Integration:
- HomeView shows polls section in My Trips
- SportsTimeApp registers SwiftData models and handles deep links
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -432,51 +432,240 @@ struct SavedTripsListView: View {
|
||||
let trips: [SavedTrip]
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@State private var polls: [TripPoll] = []
|
||||
@State private var isLoadingPolls = false
|
||||
@State private var showCreatePoll = false
|
||||
@State private var selectedPoll: TripPoll?
|
||||
|
||||
/// Trips sorted by most cities (stops) first
|
||||
private var sortedTrips: [SavedTrip] {
|
||||
trips.sorted { ($0.trip?.stops.count ?? 0) > ($1.trip?.stops.count ?? 0) }
|
||||
}
|
||||
|
||||
/// Trips as domain objects for poll creation
|
||||
private var tripsForPollCreation: [Trip] {
|
||||
trips.compactMap { $0.trip }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: Theme.Spacing.lg) {
|
||||
// Polls Section
|
||||
pollsSection
|
||||
|
||||
// Trips Section
|
||||
tripsSection
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
}
|
||||
.themedBackground()
|
||||
.navigationTitle("My Trips")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Menu {
|
||||
Button {
|
||||
showCreatePoll = true
|
||||
} label: {
|
||||
Label("Create Poll", systemImage: "chart.bar.doc.horizontal")
|
||||
}
|
||||
.disabled(trips.count < 2)
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await loadPolls()
|
||||
}
|
||||
.refreshable {
|
||||
await loadPolls()
|
||||
}
|
||||
.sheet(isPresented: $showCreatePoll) {
|
||||
PollCreationView(trips: tripsForPollCreation) { poll in
|
||||
polls.insert(poll, at: 0)
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: TripPoll.self) { poll in
|
||||
PollDetailView(pollId: poll.id)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Polls Section
|
||||
|
||||
@ViewBuilder
|
||||
private var pollsSection: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
HStack {
|
||||
Text("Group Polls")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Spacer()
|
||||
|
||||
if trips.count >= 2 {
|
||||
Button {
|
||||
showCreatePoll = true
|
||||
} label: {
|
||||
Image(systemName: "plus.circle")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isLoadingPolls {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding()
|
||||
} else if polls.isEmpty {
|
||||
emptyPollsCard
|
||||
} else {
|
||||
ForEach(polls) { poll in
|
||||
NavigationLink(value: poll) {
|
||||
PollRowCard(poll: poll)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var emptyPollsCard: some View {
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
Image(systemName: "person.3")
|
||||
.font(.title)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
|
||||
Text("No group polls yet")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
if trips.count >= 2 {
|
||||
Text("Create a poll to let friends vote on trip options")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
.multilineTextAlignment(.center)
|
||||
} else {
|
||||
Text("Save at least 2 trips to create a poll")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Theme.Spacing.lg)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trips Section
|
||||
|
||||
@ViewBuilder
|
||||
private var tripsSection: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
Text("Saved Trips")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
if trips.isEmpty {
|
||||
VStack(spacing: 16) {
|
||||
Spacer()
|
||||
.frame(height: 100)
|
||||
|
||||
Image(systemName: "suitcase")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("No Saved Trips")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.font(.headline)
|
||||
|
||||
Text("Browse featured trips on the Home tab or create your own to get started.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Theme.Spacing.xl)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
} else {
|
||||
LazyVStack(spacing: Theme.Spacing.md) {
|
||||
ForEach(Array(sortedTrips.enumerated()), id: \.element.id) { index, savedTrip in
|
||||
if let trip = savedTrip.trip {
|
||||
NavigationLink {
|
||||
TripDetailView(trip: trip, games: savedTrip.games)
|
||||
} label: {
|
||||
SavedTripListRow(trip: trip)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.staggeredAnimation(index: index)
|
||||
ForEach(Array(sortedTrips.enumerated()), id: \.element.id) { index, savedTrip in
|
||||
if let trip = savedTrip.trip {
|
||||
NavigationLink {
|
||||
TripDetailView(trip: trip, games: savedTrip.games)
|
||||
} label: {
|
||||
SavedTripListRow(trip: trip)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.staggeredAnimation(index: index)
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
}
|
||||
}
|
||||
.themedBackground()
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func loadPolls() async {
|
||||
isLoadingPolls = true
|
||||
do {
|
||||
polls = try await PollService.shared.fetchMyPolls()
|
||||
} catch {
|
||||
// Silently fail - polls just won't show
|
||||
}
|
||||
isLoadingPolls = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Poll Row Card
|
||||
|
||||
private struct PollRowCard: View {
|
||||
let poll: TripPoll
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
// Icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Theme.warmOrange.opacity(0.15))
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
Image(systemName: "chart.bar.doc.horizontal")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||
Text(poll.title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
Label("\(poll.tripSnapshots.count) trips", systemImage: "map")
|
||||
Text("•")
|
||||
Text(poll.shareCode)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: Theme.cardShadow(colorScheme), radius: 6, y: 3)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user