Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 103 additions & 37 deletions ElectionDrop.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

24 changes: 15 additions & 9 deletions ElectionDrop/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,33 @@
import SwiftUI

struct ContentView: View {
@ObservedObject var viewModel: ElectionViewModel
@ObservedObject var viewModel: DataModel
@State private var selectedTab = 0

var body: some View {
TabView(selection: $selectedTab) {
NavigationStack {
VStack {
if viewModel.isLoading {
ProgressView("Loading Elections...")
} else {
ElectionListView(elections: viewModel.elections)
}
if viewModel.isInitialLoading {
ProgressView("Loading Contests...")
} else {
ContestListView(
contests: viewModel.contests,
updates: viewModel.updates,
electionName: viewModel.currentElectionName,
isRefreshing: viewModel.isRefreshing,
onRefresh: {
await viewModel.loadElectionData()
}
)
}
}
.tag(0)
.tabItem {
Label("Elections", systemImage: "pencil.and.list.clipboard")
Label("Contests", systemImage: "pencil.and.list.clipboard")
}

NavigationStack {
SettingsView()
SettingsView(updateContests: viewModel.loadElectionData)
}
.tag(1)
.tabItem {
Expand Down
174 changes: 174 additions & 0 deletions ElectionDrop/Contest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import ElectionsGQL
import Foundation

struct Contest: Identifiable, Hashable, Codable {
let districtName: String
let ballotTitle: String
let jurisdictionTypes: [JurisdictionType]?
let ballotResponses: [BallotResponse]
var id: String

func hash(into hasher: inout Hasher) {
hasher.combine(id)
}

var group: ContestGroup {
return .city
}

static func == (lhs: Contest, rhs: Contest) -> Bool {
lhs.id == rhs.id
}

static func fromGqlResponse(
from gqlContest: ContestData
) -> Contest {
return Contest(
districtName: gqlContest.district!,
ballotTitle: gqlContest.ballotTitle!,
jurisdictionTypes: gqlContest.jurisdictions!.compactMap({
JurisdictionType(rawValue: $0!)
}),
ballotResponses: gqlContest.ballotResponsesByContestId.nodes.compactMap({
BallotResponse.fromGqlResponse(from: $0!)
}),
id: gqlContest.id
)
}

var isPCO: Bool {
return self.ballotTitle.contains("Precinct Committee Officer")
}

}

struct BallotResponse: Identifiable, Codable, Equatable {
let id: String
let response: String
let party: String

static func fromGqlResponse(
from gqlResponse: ContestData.BallotResponsesByContestId.Node
) -> BallotResponse {
return BallotResponse(
id: gqlResponse.id,
response: gqlResponse.name!,
party: gqlResponse.party!
)
}
}

struct ElectionResultsUpdate: Identifiable, Codable, Equatable {
let id: String
let updateTime: Date
let hash: String
let jurisdictionType: JurisdictionType
var results: [ContestResult]

static func == (lhs: ElectionResultsUpdate, rhs: ElectionResultsUpdate)
-> Bool
{
lhs.id == rhs.id && lhs.updateTime == rhs.updateTime
}

func getResults(for ballotResponseId: String) -> [ContestResult] {
return results.filter {
$0.ballotResponseId == ballotResponseId
}
}

func formattedUpdateDate() -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "EEEE, MMMM d"

let formattedDate = dateFormatter.string(from: updateTime)

let day = Calendar.current.component(.day, from: updateTime)
let suffix = daySuffix(for: day)

return formattedDate + suffix
}

func formattedUpdateTime() -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "h:mm a"
dateFormatter.timeZone = TimeZone(identifier: "America/Los_Angeles")

let timeString = dateFormatter.string(from: updateTime)

let timeZone = TimeZone(identifier: "America/Los_Angeles")!
let dateInPST = updateTime.addingTimeInterval(
TimeInterval(timeZone.secondsFromGMT(for: updateTime)))

let isDST = timeZone.isDaylightSavingTime(for: dateInPST)
let zoneAbbreviation = isDST ? "PDT" : "PST"

return "\(timeString) \(zoneAbbreviation)"
}

private func daySuffix(for day: Int) -> String {
switch day {
case 1, 21, 31:
return "st"
case 2, 22:
return "nd"
case 3, 23:
return "rd"
default:
return "th"
}
}

static func fromGqlResponse(
from gqlUpdate: UpdateData
) -> ElectionResultsUpdate {
let voteTallies = gqlUpdate.voteTalliesByUpdateId.nodes.compactMap({
node -> ContestResult? in
guard let id = node?.id,
let ballotResponseId = node?.ballotResponseId,
let votes = Int(node?.votes ?? ""),
let votePercentage = Float(node?.votePercentage ?? "")
else {
return nil
}
return ContestResult(
id: id,
ballotResponseId: ballotResponseId,
voteCount: votes,
votePercent: votePercentage
)
})

let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)

return ElectionResultsUpdate(
id: gqlUpdate.id,
updateTime: dateFormatter.date(from: gqlUpdate.timestamp!)!,
hash: gqlUpdate.hash!,
jurisdictionType: JurisdictionType(
rawValue: gqlUpdate.jurisdictionType!)!,
results: voteTallies
)
}
}

struct ContestResult: Identifiable, Codable, Equatable {
let id: String
let ballotResponseId: String
let voteCount: Int
let votePercent: Float

static func == (lhs: ContestResult, rhs: ContestResult) -> Bool {
lhs.id == rhs.id && lhs.ballotResponseId == rhs.ballotResponseId
&& lhs.voteCount == rhs.voteCount
&& lhs.votePercent == rhs.votePercent
}
}

enum ContestResultDisplayFormat: String, CaseIterable {
case percentOfVote = "Percent of Vote"
case totalVotes = "Total Votes"
}
Original file line number Diff line number Diff line change
@@ -1,29 +1,26 @@
// MARK: - Model

// ElectionGroup.swift
import SwiftUI

enum ElectionGroup: String, CaseIterable {
enum ContestGroup: String, CaseIterable, Codable {
case state = "State"
case city = "City"
case federal = "Federal"
case specialPurposeDistrict = "Special Purpose District"

static func groupElections(_ elections: [Election], showPCOs: Bool) -> [String: [String: [Election]]] {
switch elections.first?.group {
static func groupContests(_ contests: [Contest], showPCOs: Bool) -> [String: [String: [Contest]]] {
switch contests.first?.group {
case .state:
return groupStateElections(elections, showPCOs: showPCOs)
return groupStateContests(contests, showPCOs: showPCOs)
case .city, .specialPurposeDistrict:
return ["": Dictionary(grouping: elections) { $0.districtName }]
return ["": Dictionary(grouping: contests) { $0.districtName }]
case .federal:
return ["": Dictionary(grouping: elections) { $0.ballotTitle }]
return ["": Dictionary(grouping: contests) { $0.ballotTitle }]
case .none:
return [:]
}
}

static func groupStateElections(_ elections: [Election], showPCOs: Bool) -> [String: [String: [Election]]] {
var stateSubGroups: [String: [String: [Election]]] = [
static func groupStateContests(_ contests: [Contest], showPCOs: Bool) -> [String: [String: [Contest]]] {
var stateSubGroups: [String: [String: [Contest]]] = [
"State Legislature": [:],
"State Executive": [:],
"State Supreme Court": [:]
Expand All @@ -33,40 +30,40 @@ enum ElectionGroup: String, CaseIterable {
stateSubGroups["Precinct Committee Officer"] = [:]
}

for election in elections {
if showPCOs && election.districtType == "Precinct Committee Officer" {
stateSubGroups["Precinct Committee Officer"]?[election.ballotTitle, default: []].append(election)
} else if election.districtType == "State Supreme Court" {
stateSubGroups["State Supreme Court"]?[election.ballotTitle, default: []].append(election)
} else if election.ballotTitle.contains("Representative") || election.ballotTitle.contains("State Senator") {
stateSubGroups["State Legislature"]?[election.districtName, default: []].append(election)
for contest in contests {
if showPCOs && contest.districtName.contains("Precinct Committee Officer") {
stateSubGroups["Precinct Committee Officer"]?[contest.ballotTitle, default: []].append(contest)
} else if contest.districtName.contains("State Supreme Court") {
stateSubGroups["State Supreme Court"]?[contest.ballotTitle, default: []].append(contest)
} else if contest.ballotTitle.contains("Representative") || contest.ballotTitle.contains("State Senator") {
stateSubGroups["State Legislature"]?[contest.districtName, default: []].append(contest)
} else {
stateSubGroups["State Executive"]?[election.ballotTitle, default: []] = [election]
stateSubGroups["State Executive"]?[contest.ballotTitle, default: []] = [contest]
}
}

return stateSubGroups.filter { !$0.value.isEmpty }
}

@ViewBuilder
func view(for subGrouping: String, subGroupData: [String: [Election]], expandedBinding: @escaping (String) -> Binding<Bool>, isSearching: Bool) -> some View {
func view(for subGrouping: String, subGroupData: [String: [Contest]], expandedBinding: @escaping (String) -> Binding<Bool>, isSearching: Bool) -> some View {
if self == .state {
CustomDisclosureGroup(
isExpanded: expandedBinding("\(rawValue)-\(subGrouping)"),
content: {
ForEach(sortedKeys(for: subGrouping, in: subGroupData), id: \.self) { key in
if let elections = subGroupData[key], !elections.isEmpty {
if let contests = subGroupData[key], !contests.isEmpty {
if subGrouping == "State Legislature" {
CustomDisclosureGroup(
isExpanded: expandedBinding("\(rawValue)-\(subGrouping)-\(key)"),
content: {
electionsList(elections: elections)
contestList(contests: contests)
},
label: { Text(key) }
)
.padding(.leading, 20)
} else {
electionsList(elections: elections)
contestList(contests: contests)
}
}
}
Expand All @@ -76,11 +73,11 @@ enum ElectionGroup: String, CaseIterable {
.animation(.easeInOut, value: expandedBinding("\(rawValue)-\(subGrouping)").wrappedValue)
} else {
ForEach(sortedKeys(for: subGrouping, in: subGroupData), id: \.self) { key in
if let elections = subGroupData[key], !elections.isEmpty {
if let contest = subGroupData[key], !contest.isEmpty {
CustomDisclosureGroup(
isExpanded: expandedBinding("\(subGrouping)-\(key)"),
content: {
electionsList(elections: elections)
contestList(contests: contest)
},
label: { Text(key) }
)
Expand All @@ -90,16 +87,16 @@ enum ElectionGroup: String, CaseIterable {
}
}

private func electionsList(elections: [Election]) -> some View {
ForEach(elections.sorted(by: { $0.districtSortKey < $1.districtSortKey }), id: \.id) { election in
ElectionRow(election: election)
private func contestList(contests: [Contest]) -> some View {
ForEach(contests, id: \.id) { contest in
ContestRow(contest: contest)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}

private func sortedKeys(for subGrouping: String, in subGroupData: [String: [Election]]) -> [String] {
private func sortedKeys(for subGrouping: String, in subGroupData: [String: [Contest]]) -> [String] {
return subGroupData.keys.sorted {
subGroupData[$0]?.first?.districtSortKey ?? Int.max < subGroupData[$1]?.first?.districtSortKey ?? Int.max
$0.localizedStandardCompare($1) == .orderedDescending
}
}
}
Loading