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:
152
SportsTime/Features/Polls/Views/PollsListView.swift
Normal file
152
SportsTime/Features/Polls/Views/PollsListView.swift
Normal file
@@ -0,0 +1,152 @@
|
||||
//
|
||||
// PollsListView.swift
|
||||
// SportsTime
|
||||
//
|
||||
// View for listing user's polls
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PollsListView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var polls: [TripPoll] = []
|
||||
@State private var isLoading = false
|
||||
@State private var error: PollError?
|
||||
@State private var showJoinPoll = false
|
||||
@State private var joinCode = ""
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isLoading && polls.isEmpty {
|
||||
ProgressView("Loading polls...")
|
||||
} else if polls.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
pollsList
|
||||
}
|
||||
}
|
||||
.navigationTitle("Group Polls")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showJoinPoll = true
|
||||
} label: {
|
||||
Image(systemName: "link.badge.plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await loadPolls()
|
||||
}
|
||||
.task {
|
||||
await loadPolls()
|
||||
}
|
||||
.alert("Join Poll", isPresented: $showJoinPoll) {
|
||||
TextField("Enter code", text: $joinCode)
|
||||
.textInputAutocapitalization(.characters)
|
||||
Button("Join") {
|
||||
// Navigation will be handled by deep link
|
||||
if !joinCode.isEmpty {
|
||||
// TODO: Navigate to poll detail
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
joinCode = ""
|
||||
}
|
||||
} message: {
|
||||
Text("Enter the 6-character poll code")
|
||||
}
|
||||
.alert("Error", isPresented: .constant(error != nil)) {
|
||||
Button("OK") {
|
||||
error = nil
|
||||
}
|
||||
} message: {
|
||||
if let error {
|
||||
Text(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var emptyState: some View {
|
||||
ContentUnavailableView {
|
||||
Label("No Polls", systemImage: "chart.bar.doc.horizontal")
|
||||
} description: {
|
||||
Text("Create a poll from your saved trips to let friends vote on which trip to take.")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var pollsList: some View {
|
||||
List {
|
||||
ForEach(polls) { poll in
|
||||
NavigationLink(value: poll) {
|
||||
PollRowView(poll: poll)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.navigationDestination(for: TripPoll.self) { poll in
|
||||
PollDetailView(pollId: poll.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPolls() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
do {
|
||||
polls = try await PollService.shared.fetchMyPolls()
|
||||
} catch let pollError as PollError {
|
||||
error = pollError
|
||||
} catch {
|
||||
self.error = .unknown(error)
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Poll Row View
|
||||
|
||||
private struct PollRowView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let poll: TripPoll
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(poll.title)
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(poll.shareCode)
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Theme.warmOrange.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
HStack {
|
||||
Label("\(poll.tripSnapshots.count) trips", systemImage: "map")
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(poll.createdAt, style: .date)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
PollsListView()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user